13 Commits

Author SHA1 Message Date
fc9589b6f9 fix: trigger_update setzt GIT_TAG/GIT_COMMIT env vars für docker compose rebuild 2026-02-22 14:32:08 +01:00
6d2251bcf5 alpha-1.1: Login-Page Version-Marker hinzugefügt 2026-02-22 14:25:44 +01:00
fd79065519 feat: Git-Tag-basierte Versionierung (Alpha/Beta/Release statt Commit-Hash) 2026-02-22 14:12:32 +01:00
e9e2e67991 feat: add Windows DNS, LDAP, and Update settings tabs to UI
- Settings page: 3 new tabs (Windows DNS, LDAP / AD, Updates)
- Windows DNS tab: enable toggle, server/zone/username/password/record-IP,
  save + test connection button
- LDAP tab: enable toggle, server/port/SSL/bind-DN/password/base-DN/
  user-filter/group-DN, save + test connection button
- Updates tab: current + latest version info card with update-available
  badge, one-click update button (git pull + rebuild), git repo/branch/
  token settings form
- Azure AD tab: added Allowed Group Object ID field
- app.js: settings-dns-form, settings-ldap-form, settings-git-form
  submit handlers; testDnsConnection(), testLdapConnection(),
  loadVersionInfo(), triggerUpdate() functions; loadSettings() extended
  for all new fields
- en.json: all new translation keys
- de.json: complete German translation (was mostly empty before)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 21:48:15 +01:00
f92cdfbbef feat: add update management system with version check and one-click update
- Bake version info (commit, branch, date) into /app/version.json at build time
  via Docker ARG GIT_COMMIT/GIT_BRANCH/GIT_COMMIT_DATE
- Mount source directory as /app-source for in-container git operations
- Add git config safe.directory for /app-source (ownership mismatch fix)
- Add SystemConfig fields: git_repo_url, git_branch, git_token_encrypted
- Add DB migrations for the three new columns
- Add git_token encryption in update_settings() handler
- New endpoints:
    GET  /api/settings/version  — current version + latest from Gitea API
    POST /api/settings/update   — DB backup + git pull + docker compose rebuild
- New service: app/services/update_service.py
    get_current_version()  — reads /app/version.json
    check_for_updates()    — queries Gitea API for latest commit on branch
    backup_database()      — timestamped SQLite copy to /app/backups/
    trigger_update()       — git pull + fire-and-forget compose rebuild
- New script: update.sh — SSH-based manual update with health check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 21:33:43 +01:00
7793ca3666 feat: add Windows DNS integration and LDAP/AD authentication
Windows DNS (WinRM):
- New dns_service.py: create/delete A-records via PowerShell over WinRM (NTLM)
- Idempotent create (removes existing record first), graceful delete
- DNS failures are non-fatal — deployment continues, error logged
- test-dns endpoint: GET /api/settings/test-dns
- Integrated into deploy_customer() and undeploy_customer()

LDAP / Active Directory auth:
- New ldap_service.py: service-account bind + user search + user bind (ldap3)
- Optional AD group restriction via ldap_group_dn
- Login flow: LDAP first → local fallback (prevents admin lockout)
- LDAP users auto-created with auth_provider="ldap" and role="viewer"
- test-ldap endpoint: GET /api/settings/test-ldap
- reset-password/reset-mfa guards extended to block LDAP users

All credentials (dns_password, ldap_bind_password) encrypted with Fernet.
New DB columns added via backwards-compatible migrations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 21:06:51 +01:00
bc9aa6624f security: fix CORS wildcard, add security headers, enforce role check, sanitize errors
- CORS: remove allow_origins=["*"]; restrict to ALLOWED_ORIGINS env var
  (comma-separated list); default is no cross-origin access. Removed
  allow_credentials=True and method/header wildcards.
- Security headers middleware: add X-Content-Type-Options, X-Frame-Options,
  X-XSS-Protection, Referrer-Policy, Strict-Transport-Security to all
  responses.
- users.py: guard POST /api/users so only users with role="admin" can
  create new accounts (prevents privilege escalation by non-admin roles).
- auth.py: remove raw exception detail from Azure AD 500 response to
  avoid leaking internal error messages / stack traces to clients.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 00:39:43 +01:00
1bbe4904a7 fix: resolve circular import, async blocking, SELinux and delete timeout issues
- Extract shared SlowAPI limiter to app/limiter.py to break circular
  import between app.main and app.routers.auth
- Seed default SystemConfig row (id=1) on first DB init so settings
  page works out of the box
- Make all docker_service.compose_* functions async (run_in_executor)
  so long docker pulls/stops no longer block the async event loop
- Propagate async to netbird_service stop/start/restart and await
  callers in deployments router
- Move customer delete to BackgroundTasks so the HTTP response returns
  immediately and avoids frontend "Network error" on slow machines
- docker-compose: add :z SELinux labels, mount docker.sock directly,
  add security_opt label:disable for socket access, extra_hosts for
  host.docker.internal, enable DELETE/VOLUMES on socket proxy
- npm_service: auto-detect outbound host IP via UDP socket when
  HOST_IP env var is not set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 00:30:25 +01:00
0ac15e4db9 rename: CLAUDE_CODE_SPEC.md -> ProjectAISpec.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 22:39:39 +01:00
c00b52df83 Add Project Spec for AI 2026-02-18 22:27:55 +01:00
72bad11129 security: apply four immediate security fixes
Fix #1 - SECRET_KEY startup validation (config.py, .env):
- App refuses to start if SECRET_KEY is missing, shorter than 32 chars,
  or matches a known insecure default value
- .env: replaced hardcoded test key with placeholder + generation hint

Fix #2 - Docker socket proxy (docker-compose.yml):
- Add tecnativa/docker-socket-proxy sidecar
- Only expose required Docker API endpoints (CONTAINERS, IMAGES,
  NETWORKS, POST, EXEC); dangerous endpoints explicitly blocked
- Remove direct /var/run/docker.sock mount from main container
- Route Docker API via DOCKER_HOST=tcp://docker-socket-proxy:2375

Fix #3 - Azure AD group whitelist (auth.py, models.py, validators.py):
- New azure_allowed_group_id field in SystemConfig
- After token exchange, verify group membership via Graph API /me/memberOf
- Deny login with HTTP 403 if user is not in the required group
- New Azure AD users now get role 'viewer' instead of 'admin'

Fix #4 - Rate limiting on login (main.py, auth.py, requirements.txt):
- Add slowapi==0.1.9 dependency
- Initialize SlowAPI limiter in main.py with 429 exception handler
- Apply 10 requests/minute limit per IP on /login and /mfa/verify
2026-02-18 21:28:49 +01:00
40456bfaba Deutsch korrigiert 2026-02-09 15:53:14 +01:00
c7fc4758e3 Add SSL certificate mode: Let's Encrypt or Wildcard per NPM
Settings > NPM Integration now allows choosing between per-customer
Let's Encrypt certificates (default) or a shared wildcard certificate
already uploaded in NPM. Includes backend, frontend UI, and i18n support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 00:01:28 +01:00
29 changed files with 2748 additions and 209 deletions

1
.gitignore vendored
View File

@@ -65,7 +65,6 @@ htmlcov/
# Claude Code # Claude Code
.claude/ .claude/
CLAUDE_CODE_SPEC.md
PROJECT_SUMMARY.md PROJECT_SUMMARY.md
QUICKSTART.md QUICKSTART.md
VS_CODE_SETUP.md VS_CODE_SETUP.md

View File

@@ -13,7 +13,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& chmod a+r /etc/apt/keyrings/docker.gpg \ && chmod a+r /etc/apt/keyrings/docker.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \ && apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \ && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Set working directory # Set working directory
@@ -28,6 +28,16 @@ COPY app/ ./app/
COPY templates/ ./templates/ COPY templates/ ./templates/
COPY static/ ./static/ COPY static/ ./static/
# Bake version info at build time
ARG GIT_COMMIT=unknown
ARG GIT_BRANCH=unknown
ARG GIT_COMMIT_DATE=unknown
ARG GIT_TAG=unknown
RUN echo "{\"tag\": \"$GIT_TAG\", \"commit\": \"$GIT_COMMIT\", \"branch\": \"$GIT_BRANCH\", \"date\": \"$GIT_COMMIT_DATE\"}" > /app/version.json
# Allow git to operate in the /app-source volume (owner may differ from container user)
RUN git config --global --add safe.directory /app-source
# Create data directories # Create data directories
RUN mkdir -p /app/data /app/logs /app/backups RUN mkdir -p /app/data /app/logs /app/backups

459
ProjectAISpec.md Normal file
View File

@@ -0,0 +1,459 @@
# NetBird MSP Appliance - Claude Code Specification
## Project Overview
Build a complete, production-ready multi-tenant NetBird management platform that runs entirely in Docker containers. This is an MSP (Managed Service Provider) tool to manage 100+ isolated NetBird instances from a single web interface.
## Technology Stack
- **Backend**: Python 3.11+ with FastAPI
- **Frontend**: HTML5 + Bootstrap 5 + Vanilla JavaScript (no frameworks)
- **Database**: SQLite
- **Containerization**: Docker + Docker Compose
- **Templating**: Jinja2 for Docker Compose generation
- **Integration**: Docker Python SDK, Nginx Proxy Manager API
## Project Structure
```
netbird-msp-appliance/
├── README.md # Main documentation
├── QUICKSTART.md # Quick start guide
├── ARCHITECTURE.md # Architecture documentation
├── LICENSE # MIT License
├── .gitignore # Git ignore file
├── .env.example # Environment variables template
├── install.sh # One-click installation script
├── docker-compose.yml # Main application container
├── Dockerfile # Application container definition
├── requirements.txt # Python dependencies
├── app/ # Python application
│ ├── __init__.py
│ ├── main.py # FastAPI entry point
│ ├── models.py # SQLAlchemy models
│ ├── database.py # Database setup
│ ├── dependencies.py # FastAPI dependencies
│ │
│ ├── routers/ # API endpoints
│ │ ├── __init__.py
│ │ ├── auth.py # Authentication endpoints
│ │ ├── customers.py # Customer CRUD
│ │ ├── deployments.py # Deployment management
│ │ ├── monitoring.py # Status & health checks
│ │ └── settings.py # System configuration
│ │
│ ├── services/ # Business logic
│ │ ├── __init__.py
│ │ ├── docker_service.py # Docker container management
│ │ ├── npm_service.py # NPM API integration
│ │ ├── netbird_service.py # NetBird deployment orchestration
│ │ └── port_manager.py # UDP port allocation
│ │
│ └── utils/ # Utilities
│ ├── __init__.py
│ ├── config.py # Configuration management
│ ├── security.py # Encryption, hashing
│ └── validators.py # Input validation
├── templates/ # Jinja2 templates
│ ├── docker-compose.yml.j2 # Per-customer Docker Compose
│ ├── management.json.j2 # NetBird management config
│ └── relay.env.j2 # Relay environment variables
├── static/ # Frontend files
│ ├── index.html # Main dashboard
│ ├── css/
│ │ └── styles.css # Custom styles
│ └── js/
│ └── app.js # Frontend JavaScript
├── tests/ # Unit & integration tests
│ ├── __init__.py
│ ├── test_customer_api.py
│ ├── test_deployment.py
│ └── test_docker_service.py
└── docs/ # Additional documentation
├── API.md # API documentation
├── DEPLOYMENT.md # Deployment guide
└── TROUBLESHOOTING.md # Common issues
```
## Key Features to Implement
### 1. Customer Management
- **Create Customer**: Web form → API → Deploy NetBird instance
- **List Customers**: Paginated table with search/filter
- **Customer Details**: Status, logs, setup URL, actions
- **Delete Customer**: Remove all containers, NPM entries, data
### 2. Automated Deployment
**Workflow when creating customer:**
1. Validate inputs (subdomain unique, email valid)
2. Allocate ports (Management internal, Relay UDP public)
3. Generate configs from Jinja2 templates
4. Create instance directory: `/opt/netbird-instances/kunde{id}/`
5. Write `docker-compose.yml`, `management.json`, `relay.env`
6. Start Docker containers via Docker SDK
7. Wait for health checks (max 60s)
8. Create NPM proxy hosts via API (with SSL)
9. Update database with deployment info
10. Return setup URL to user
### 3. Web-Based Configuration
**All settings in database, editable via UI:**
- Base Domain
- Admin Email
- NPM API URL & Token
- NetBird Docker Images
- Port Ranges
- Data Directories
No manual config file editing required!
### 4. Nginx Proxy Manager Integration
**Per customer, create proxy host:**
- Domain: `{subdomain}.{base_domain}`
- Forward to: `netbird-kunde{id}-dashboard:80`
- SSL: Automatic Let's Encrypt
- Advanced config: Route `/api/*` to management, `/signalexchange.*` to signal, `/relay` to relay
### 5. Port Management
**UDP Ports for STUN/Relay (publicly accessible):**
- Customer 1: 3478
- Customer 2: 3479
- ...
- Customer 100: 3577
**Algorithm:**
- Find next available port starting from 3478
- Check if port not in use (via `netstat` or database)
- Assign to customer
- Store in database
### 6. Monitoring & Health Checks
- Container status (running/stopped/failed)
- Health check endpoints (HTTP checks to management service)
- Resource usage (via Docker stats API)
- Relay connectivity test
## Database Schema
### Table: customers
```sql
CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
company TEXT,
subdomain TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
max_devices INTEGER DEFAULT 20,
notes TEXT,
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'inactive', 'deploying', 'error')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Table: deployments
```sql
CREATE TABLE deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL UNIQUE,
container_prefix TEXT NOT NULL,
relay_udp_port INTEGER UNIQUE NOT NULL,
npm_proxy_id INTEGER,
relay_secret TEXT NOT NULL,
setup_url TEXT,
deployment_status TEXT DEFAULT 'pending' CHECK(deployment_status IN ('pending', 'running', 'stopped', 'failed')),
deployed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_health_check TIMESTAMP,
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE
);
```
### Table: system_config
```sql
CREATE TABLE system_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
base_domain TEXT NOT NULL,
admin_email TEXT NOT NULL,
npm_api_url TEXT NOT NULL,
npm_api_token_encrypted TEXT NOT NULL,
netbird_management_image TEXT DEFAULT 'netbirdio/management:latest',
netbird_signal_image TEXT DEFAULT 'netbirdio/signal:latest',
netbird_relay_image TEXT DEFAULT 'netbirdio/relay:latest',
netbird_dashboard_image TEXT DEFAULT 'netbirdio/dashboard:latest',
data_dir TEXT DEFAULT '/opt/netbird-instances',
docker_network TEXT DEFAULT 'npm-network',
relay_base_port INTEGER DEFAULT 3478,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Table: deployment_logs
```sql
CREATE TABLE deployment_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL,
action TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('success', 'error', 'info')),
message TEXT,
details TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE
);
```
### Table: users (simple auth)
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
## API Endpoints to Implement
### Authentication
```
POST /api/auth/login # Login and get token
POST /api/auth/logout # Logout
GET /api/auth/me # Get current user
POST /api/auth/change-password
```
### Customers
```
POST /api/customers # Create + auto-deploy
GET /api/customers # List all (pagination, search, filter)
GET /api/customers/{id} # Get details
PUT /api/customers/{id} # Update
DELETE /api/customers/{id} # Delete + cleanup
```
### Deployments
```
POST /api/customers/{id}/deploy # Manual deploy
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
```
### Monitoring
```
GET /api/monitoring/status # System overview
GET /api/monitoring/customers # All customers status
GET /api/monitoring/resources # Host resource usage
```
### Settings
```
GET /api/settings/system # Get system config
PUT /api/settings/system # Update system config
GET /api/settings/test-npm # Test NPM connectivity
```
## Docker Compose Template (Per Customer)
```yaml
version: '3.8'
networks:
npm-network:
external: true
services:
netbird-management:
image: {{ netbird_management_image }}
container_name: netbird-kunde{{ customer_id }}-management
restart: unless-stopped
networks:
- npm-network
volumes:
- {{ instance_dir }}/data/management:/var/lib/netbird
- {{ instance_dir }}/management.json:/etc/netbird/management.json
command: ["--port", "80", "--log-file", "console", "--log-level", "info",
"--single-account-mode-domain={{ subdomain }}.{{ base_domain }}",
"--dns-domain={{ subdomain }}.{{ base_domain }}"]
netbird-signal:
image: {{ netbird_signal_image }}
container_name: netbird-kunde{{ customer_id }}-signal
restart: unless-stopped
networks:
- npm-network
volumes:
- {{ instance_dir }}/data/signal:/var/lib/netbird
netbird-relay:
image: {{ netbird_relay_image }}
container_name: netbird-kunde{{ customer_id }}-relay
restart: unless-stopped
networks:
- npm-network
ports:
- "{{ 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:
image: {{ netbird_dashboard_image }}
container_name: netbird-kunde{{ customer_id }}-dashboard
restart: unless-stopped
networks:
- npm-network
environment:
- NETBIRD_MGMT_API_ENDPOINT=https://{{ subdomain }}.{{ base_domain }}
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://{{ subdomain }}.{{ base_domain }}
```
## Frontend Requirements
### Main Dashboard (index.html)
**Layout:**
- Navbar: Logo, "New Customer" button, User menu (settings, logout)
- Stats Cards: Total customers, Active, Inactive, Errors
- Customer Table: Name, Subdomain, Status, Devices, Actions
- Pagination: 25 customers per page
- Search bar: Filter by name, subdomain, email
- Status filter dropdown: All, Active, Inactive, Error
**Customer Table Actions:**
- View Details (→ customer detail page)
- Start/Stop/Restart (inline buttons)
- Delete (with confirmation modal)
### Customer Detail Page
**Tabs:**
1. **Info**: All customer details, edit button
2. **Deployment**: Status, Setup URL (copy button), Container status
3. **Logs**: Real-time logs from all containers (auto-refresh)
4. **Health**: Health check results, relay connectivity test
### Settings Page
**Tabs:**
1. **System Configuration**: All system settings, save button
2. **NPM Integration**: API URL, Token, Test button
3. **Images**: NetBird Docker image tags
4. **Security**: Change admin password
### Modal Dialogs
- New/Edit Customer Form
- Delete Confirmation
- Deployment Progress (with spinner)
- Error Display
## Security Requirements
1. **Password Hashing**: Use bcrypt for admin password
2. **Secret Encryption**: Encrypt NPM token and relay secrets with Fernet
3. **Input Validation**: Pydantic models for all API inputs
4. **SQL Injection Prevention**: Use SQLAlchemy ORM (no raw queries)
5. **CSRF Protection**: Token-based authentication
6. **Rate Limiting**: Prevent brute force on login endpoint
## Error Handling
All operations should have comprehensive error handling:
```python
try:
# Deploy customer
result = deploy_customer(customer_id)
except DockerException as e:
# Rollback: Stop containers
# Log error
# Update status to 'failed'
# Return error to user
except NPMException as e:
# Rollback: Remove containers
# Log error
# Update status to 'failed'
except Exception as e:
# Generic rollback
# Log error
# Alert admin
```
## Testing Requirements
1. **Unit Tests**: All services (docker_service, npm_service, etc.)
2. **Integration Tests**: Full deployment workflow
3. **API Tests**: All endpoints with different scenarios
4. **Mock External Dependencies**: Docker API, NPM API
## Deployment Process
1. Clone repository
2. Run `./install.sh`
3. Access `http://server-ip:8000`
4. Complete setup wizard
5. Deploy first customer
## System Requirements Documentation
**Include in README.md:**
### For 100 Customers:
- **CPU**: 16 cores (minimum 8)
- **RAM**: 64 GB (minimum) - 128 GB (recommended)
- Formula: `(100 customers × 600 MB) + 8 GB overhead = 68 GB`
- **Disk**: 500 GB SSD (minimum) - 1 TB recommended
- **Network**: 1 Gbps dedicated connection
- **OS**: Ubuntu 22.04 LTS or 24.04 LTS
### Port Requirements:
- **TCP 8000**: Web UI
- **UDP 3478-3577**: Relay/STUN (100 ports for 100 customers)
## Success Criteria
✅ One-command installation via `install.sh`
✅ Web-based configuration (no manual file editing)
✅ Customer deployment < 2 minutes
✅ All settings in database
✅ Automatic NPM integration
✅ Comprehensive error handling
✅ Clean, professional UI
✅ Full API documentation (auto-generated)
✅ Health monitoring
✅ Easy to deploy on fresh Ubuntu VM
## Special Notes for Claude Code
- **Use type hints** throughout Python code
- **Document all functions** with docstrings
- **Follow PEP 8** style guidelines
- **Create modular code**: Each service should be independently testable
- **Use async/await** where appropriate (FastAPI endpoints)
- **Provide comprehensive comments** for complex logic
- **Include error messages** that help users troubleshoot
## File Priorities
Create in this order:
1. Basic structure (directories, requirements.txt, Dockerfile, docker-compose.yml)
2. Database models and setup (models.py, database.py)
3. Core services (docker_service.py, port_manager.py)
4. API routers (start with customers.py)
5. NPM integration (npm_service.py)
6. Templates (Jinja2 files)
7. Frontend (HTML, CSS, JS)
8. Installation script
9. Documentation
10. Tests
This specification provides everything needed to build a production-ready NetBird MSP Appliance!

View File

@@ -34,6 +34,7 @@ A management solution for running isolated NetBird instances for your MSP busine
- **Complete Isolation** — Each customer gets their own NetBird stack with separate data - **Complete Isolation** — Each customer gets their own NetBird stack with separate data
- **One-Click Deployment** — Deploy new customer instances in under 2 minutes - **One-Click Deployment** — Deploy new customer instances in under 2 minutes
- **Nginx Proxy Manager Integration** — Automatic SSL certificates and reverse proxy setup - **Nginx Proxy Manager Integration** — Automatic SSL certificates and reverse proxy setup
- **SSL Certificate Modes** — Choose between per-customer Let's Encrypt certificates or a shared wildcard certificate
- **Docker-Based** — Everything runs in containers for easy deployment - **Docker-Based** — Everything runs in containers for easy deployment
### Dashboard ### Dashboard
@@ -269,7 +270,8 @@ Available under **Settings** in the web interface:
| Tab | Settings | | Tab | Settings |
|-----|----------| |-----|----------|
| **System** | Base domain, admin email, NPM credentials, Docker images, port ranges, data directory | | **System** | Base domain, admin email, Docker images, port ranges, data directory |
| **NPM Integration** | NPM API URL, login credentials, SSL certificate mode (Let's Encrypt / Wildcard), wildcard certificate selection |
| **Branding** | Platform name, subtitle, logo upload, default language | | **Branding** | Platform name, subtitle, logo upload, default language |
| **Users** | Create/edit/delete admin users, per-user language preference, MFA reset | | **Users** | Create/edit/delete admin users, per-user language preference, MFA reset |
| **Azure AD** | Azure AD / Entra ID SSO configuration | | **Azure AD** | Azure AD / Entra ID SSO configuration |
@@ -342,6 +344,26 @@ When MFA is enabled and a user logs in for the first time:
- **Disable own TOTP** — In Settings > Security, click "Disable my TOTP" to remove your own MFA setup - **Disable own TOTP** — In Settings > Security, click "Disable my TOTP" to remove your own MFA setup
- **Disable MFA globally** — Uncheck the toggle in Settings > Security to allow login without MFA - **Disable MFA globally** — Uncheck the toggle in Settings > Security to allow login without MFA
### SSL Certificate Mode
The appliance supports two SSL certificate modes for customer proxy hosts, configurable under **Settings > NPM Integration**:
#### Let's Encrypt (default)
Each customer gets an individual Let's Encrypt certificate via HTTP-01 validation. This is the default behavior and requires no additional setup beyond a valid admin email.
#### Wildcard Certificate
Use a pre-existing wildcard certificate (e.g. `*.yourdomain.com`) already uploaded in NPM. All customer proxy hosts share this certificate — no per-customer LE validation needed.
**Setup:**
1. Upload a wildcard certificate in Nginx Proxy Manager (e.g. via DNS challenge)
2. Go to **Settings > NPM Integration**
3. Set **SSL Mode** to "Wildcard Certificate"
4. Click the refresh button to load certificates from NPM
5. Select your wildcard certificate from the dropdown
6. Click **Save NPM Settings**
New customer deployments will automatically use the selected wildcard certificate.
--- ---
## API Documentation ## API Documentation
@@ -376,6 +398,7 @@ GET /api/customers/{id}/logs # Get container logs
GET /api/customers/{id}/health # Health check GET /api/customers/{id}/health # Health check
GET /api/settings/branding # Get branding (public, no auth) GET /api/settings/branding # Get branding (public, no auth)
GET /api/settings/npm-certificates # List NPM SSL certificates
PUT /api/settings # Update system settings PUT /api/settings # Update system settings
GET /api/users # List users GET /api/users # List users
POST /api/users # Create user POST /api/users # Create user

View File

@@ -51,6 +51,22 @@ def init_db() -> None:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_run_migrations() _run_migrations()
# Insert default SystemConfig row (id=1) if it doesn't exist yet
db = SessionLocal()
try:
if not db.query(SystemConfig).filter(SystemConfig.id == 1).first():
db.add(SystemConfig(
id=1,
base_domain="example.com",
admin_email="admin@example.com",
npm_api_url="http://localhost:81",
npm_api_email_encrypted="",
npm_api_password_encrypted="",
))
db.commit()
finally:
db.close()
def _run_migrations() -> None: def _run_migrations() -> None:
"""Add columns that may be missing from older database versions.""" """Add columns that may be missing from older database versions."""
@@ -83,6 +99,29 @@ def _run_migrations() -> None:
("system_config", "mfa_enabled", "BOOLEAN DEFAULT 0"), ("system_config", "mfa_enabled", "BOOLEAN DEFAULT 0"),
("users", "totp_secret_encrypted", "TEXT"), ("users", "totp_secret_encrypted", "TEXT"),
("users", "totp_enabled", "BOOLEAN DEFAULT 0"), ("users", "totp_enabled", "BOOLEAN DEFAULT 0"),
("system_config", "ssl_mode", "TEXT DEFAULT 'letsencrypt'"),
("system_config", "wildcard_cert_id", "INTEGER"),
# Windows DNS
("system_config", "dns_enabled", "BOOLEAN DEFAULT 0"),
("system_config", "dns_server", "TEXT"),
("system_config", "dns_username", "TEXT"),
("system_config", "dns_password_encrypted", "TEXT"),
("system_config", "dns_zone", "TEXT"),
("system_config", "dns_record_ip", "TEXT"),
# LDAP
("system_config", "ldap_enabled", "BOOLEAN DEFAULT 0"),
("system_config", "ldap_server", "TEXT"),
("system_config", "ldap_port", "INTEGER DEFAULT 389"),
("system_config", "ldap_use_ssl", "BOOLEAN DEFAULT 0"),
("system_config", "ldap_bind_dn", "TEXT"),
("system_config", "ldap_bind_password_encrypted", "TEXT"),
("system_config", "ldap_base_dn", "TEXT"),
("system_config", "ldap_user_filter", "TEXT DEFAULT '(sAMAccountName={username})'"),
("system_config", "ldap_group_dn", "TEXT"),
# Update management
("system_config", "git_repo_url", "TEXT"),
("system_config", "git_branch", "TEXT DEFAULT 'main'"),
("system_config", "git_token_encrypted", "TEXT"),
] ]
for table, column, col_type in migrations: for table, column, col_type in migrations:
if not _has_column(table, column): if not _has_column(table, column):

5
app/limiter.py Normal file
View File

@@ -0,0 +1,5 @@
"""Shared rate limiter instance."""
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)

View File

@@ -3,12 +3,15 @@
import logging import logging
import os import os
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from app.database import init_db from app.database import init_db
from app.limiter import limiter
from app.routers import auth, customers, deployments, monitoring, settings, users from app.routers import auth, customers, deployments, monitoring, settings, users
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -21,6 +24,9 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Application # Application
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -33,15 +39,40 @@ app = FastAPI(
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json",
) )
# CORS — allow same-origin; adjust if needed # Attach limiter to app state and register the 429 exception handler
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS — restrict to explicitly configured origins only.
# Set ALLOWED_ORIGINS in .env as a comma-separated list of allowed origins,
# e.g. ALLOWED_ORIGINS=https://myapp.example.com
# If unset, no cross-origin requests are allowed (same-origin only).
_raw_origins = os.environ.get("ALLOWED_ORIGINS", "")
_allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=_allowed_origins,
allow_credentials=True, allow_credentials=False,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["Authorization", "Content-Type"],
) )
# ---------------------------------------------------------------------------
# Security headers middleware
# ---------------------------------------------------------------------------
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
"""Attach standard security headers to every response."""
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routers # Routers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -161,11 +161,44 @@ class SystemConfig(Base):
) )
branding_logo_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) branding_logo_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
default_language: Mapped[Optional[str]] = mapped_column(String(10), default="en") default_language: Mapped[Optional[str]] = mapped_column(String(10), default="en")
ssl_mode: Mapped[str] = mapped_column(String(20), default="letsencrypt")
wildcard_cert_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False) mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
azure_enabled: Mapped[bool] = mapped_column(Boolean, default=False) azure_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
azure_tenant_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) 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_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
azure_client_secret_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) azure_client_secret_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
azure_allowed_group_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True,
comment="If set, only Azure AD users in this group (object ID) are allowed to log in."
)
# Windows DNS integration
dns_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
dns_server: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
dns_username: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
dns_password_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
dns_zone: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
dns_record_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
# LDAP / Active Directory authentication
ldap_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
ldap_server: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
ldap_port: Mapped[int] = mapped_column(Integer, default=389)
ldap_use_ssl: Mapped[bool] = mapped_column(Boolean, default=False)
ldap_bind_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
ldap_bind_password_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
ldap_base_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
ldap_user_filter: Mapped[Optional[str]] = mapped_column(
String(255), default="(sAMAccountName={username})"
)
ldap_group_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Update management
git_repo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
git_branch: Mapped[Optional[str]] = mapped_column(String(100), default="main")
git_token_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
@@ -194,11 +227,32 @@ class SystemConfig(Base):
"branding_subtitle": self.branding_subtitle or "Multi-Tenant Management Platform", "branding_subtitle": self.branding_subtitle or "Multi-Tenant Management Platform",
"branding_logo_path": self.branding_logo_path, "branding_logo_path": self.branding_logo_path,
"default_language": self.default_language or "en", "default_language": self.default_language or "en",
"ssl_mode": self.ssl_mode or "letsencrypt",
"wildcard_cert_id": self.wildcard_cert_id,
"mfa_enabled": bool(self.mfa_enabled), "mfa_enabled": bool(self.mfa_enabled),
"azure_enabled": bool(self.azure_enabled), "azure_enabled": bool(self.azure_enabled),
"azure_tenant_id": self.azure_tenant_id or "", "azure_tenant_id": self.azure_tenant_id or "",
"azure_client_id": self.azure_client_id or "", "azure_client_id": self.azure_client_id or "",
"azure_client_secret_set": bool(self.azure_client_secret_encrypted), "azure_client_secret_set": bool(self.azure_client_secret_encrypted),
"azure_allowed_group_id": self.azure_allowed_group_id or "",
"dns_enabled": bool(self.dns_enabled),
"dns_server": self.dns_server or "",
"dns_username": self.dns_username or "",
"dns_password_set": bool(self.dns_password_encrypted),
"dns_zone": self.dns_zone or "",
"dns_record_ip": self.dns_record_ip or "",
"ldap_enabled": bool(self.ldap_enabled),
"ldap_server": self.ldap_server or "",
"ldap_port": self.ldap_port or 389,
"ldap_use_ssl": bool(self.ldap_use_ssl),
"ldap_bind_dn": self.ldap_bind_dn or "",
"ldap_bind_password_set": bool(self.ldap_bind_password_encrypted),
"ldap_base_dn": self.ldap_base_dn or "",
"ldap_user_filter": self.ldap_user_filter or "(sAMAccountName={username})",
"ldap_group_dn": self.ldap_group_dn or "",
"git_repo_url": self.git_repo_url or "",
"git_branch": self.git_branch or "main",
"git_token_set": bool(self.git_token_encrypted),
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
} }

View File

@@ -6,13 +6,15 @@ import logging
import secrets import secrets
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.dependencies import create_access_token, create_mfa_token, get_current_user, verify_mfa_token from app.dependencies import create_access_token, create_mfa_token, get_current_user, verify_mfa_token
from app.models import SystemConfig, User from app.models import SystemConfig, User
from app.services import ldap_service
from app.utils.config import get_system_config
from app.utils.security import ( from app.utils.security import (
decrypt_value, decrypt_value,
encrypt_value, encrypt_value,
@@ -27,26 +29,102 @@ from app.utils.validators import ChangePasswordRequest, LoginRequest, MfaTokenRe
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
from app.limiter import limiter
@router.post("/login") @router.post("/login")
async def login(payload: LoginRequest, db: Session = Depends(get_db)): @limiter.limit("10/minute")
"""Authenticate with username/password. May require MFA as a second step.""" async def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == payload.username).first() """Authenticate with username/password. May require MFA as a second step.
if not user or not verify_password(payload.password, user.password_hash):
Auth flow:
1. If LDAP is enabled: try LDAP authentication first.
- Success → find or auto-create local User with auth_provider="ldap"
- Wrong password (user found in LDAP) → HTTP 401
- User not found in LDAP → fall through to local auth
2. Local auth: verify bcrypt hash for users with auth_provider="local"
3. On success: check MFA requirement (local users only) then issue JWT
Rate-limited to 10 attempts per minute per IP address.
"""
config = get_system_config(db)
user: User | None = None
# ------------------------------------------------------------------
# Step 1: LDAP authentication (if enabled)
# ------------------------------------------------------------------
if config and config.ldap_enabled and config.ldap_server:
try:
ldap_info = await ldap_service.authenticate_ldap(
payload.username, payload.password, config
)
if ldap_info is not None:
# User authenticated via LDAP — find or create local record
user = db.query(User).filter(User.username == ldap_info["username"]).first()
if not user:
user = User(
username=ldap_info["username"],
password_hash=hash_password(secrets.token_urlsafe(32)),
email=ldap_info.get("email", ""),
is_active=True,
role="viewer",
auth_provider="ldap",
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("LDAP user '%s' auto-created with role 'viewer'.", ldap_info["username"])
elif not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled.",
)
else:
# Keep auth_provider in sync in case it was changed
if user.auth_provider != "ldap":
user.auth_provider = "ldap"
db.commit()
except ValueError as exc:
# User found in LDAP but wrong password or group denied
logger.warning("LDAP login failed for '%s': %s", payload.username, exc)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password.", detail="Invalid username or password.",
) )
except RuntimeError as exc:
# LDAP server unreachable — log and fall through to local auth
logger.error("LDAP server error, falling back to local auth: %s", exc)
# ------------------------------------------------------------------
# Step 2: Local authentication (if LDAP didn't produce a user)
# ------------------------------------------------------------------
if user is None:
local_user = db.query(User).filter(User.username == payload.username).first()
if local_user and local_user.auth_provider == "local":
if not verify_password(payload.password, local_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password.",
)
user = local_user
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password.",
)
if not user.is_active: if not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled.", detail="Account is disabled.",
) )
# Check if MFA is required (only for local users) # ------------------------------------------------------------------
# Step 3: MFA check (local users only)
# ------------------------------------------------------------------
if user.auth_provider == "local": if user.auth_provider == "local":
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first() sys_config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if config and getattr(config, "mfa_enabled", False): if sys_config and getattr(sys_config, "mfa_enabled", False):
mfa_token = create_mfa_token(user.username) mfa_token = create_mfa_token(user.username)
return { return {
"mfa_required": True, "mfa_required": True,
@@ -55,7 +133,7 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)):
} }
token = create_access_token(user.username) token = create_access_token(user.username)
logger.info("User %s logged in.", user.username) logger.info("User %s logged in (provider: %s).", user.username, user.auth_provider)
return { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -129,8 +207,12 @@ async def mfa_setup_complete(payload: MfaVerifyRequest, db: Session = Depends(ge
@router.post("/mfa/verify") @router.post("/mfa/verify")
async def mfa_verify(payload: MfaVerifyRequest, db: Session = Depends(get_db)): @limiter.limit("10/minute")
"""Verify a TOTP code for users who already have MFA set up.""" async def mfa_verify(request: Request, payload: MfaVerifyRequest, db: Session = Depends(get_db)):
"""Verify a TOTP code for users who already have MFA set up.
Rate-limited to 10 attempts per minute per IP address.
"""
username = verify_mfa_token(payload.mfa_token) username = verify_mfa_token(payload.mfa_token)
user = db.query(User).filter(User.username == username).first() user = db.query(User).filter(User.username == username).first()
if not user: if not user:
@@ -262,17 +344,18 @@ async def azure_callback(
try: try:
import msal import msal
import httpx as _httpx
client_secret = decrypt_value(config.azure_client_secret_encrypted) client_secret = decrypt_value(config.azure_client_secret_encrypted)
authority = f"https://login.microsoftonline.com/{config.azure_tenant_id}" authority = f"https://login.microsoftonline.com/{config.azure_tenant_id}"
app = msal.ConfidentialClientApplication( msal_app = msal.ConfidentialClientApplication(
config.azure_client_id, config.azure_client_id,
authority=authority, authority=authority,
client_credential=client_secret, client_credential=client_secret,
) )
result = app.acquire_token_by_authorization_code( result = msal_app.acquire_token_by_authorization_code(
payload.code, payload.code,
scopes=["User.Read"], scopes=["User.Read"],
redirect_uri=payload.redirect_uri, redirect_uri=payload.redirect_uri,
@@ -287,7 +370,8 @@ async def azure_callback(
id_token_claims = result.get("id_token_claims", {}) id_token_claims = result.get("id_token_claims", {})
email = id_token_claims.get("preferred_username") or id_token_claims.get("email", "") email = id_token_claims.get("preferred_username") or id_token_claims.get("email", "")
display_name = id_token_claims.get("name", email) display_name = id_token_claims.get("name", email) # noqa: F841
user_access_token = result.get("access_token", "")
if not email: if not email:
raise HTTPException( raise HTTPException(
@@ -295,6 +379,54 @@ async def azure_callback(
detail="Could not determine email from Azure AD token.", detail="Could not determine email from Azure AD token.",
) )
# -----------------------------------------------------------------
# Group membership check (Fix #3 Azure AD group whitelist)
# -----------------------------------------------------------------
allowed_group_id = getattr(config, "azure_allowed_group_id", None)
if allowed_group_id:
# Use the user's own access token to check their group membership
# via the Microsoft Graph API (requires GroupMember.Read.All or
# the user's own memberOf delegated permission).
graph_url = "https://graph.microsoft.com/v1.0/me/memberOf"
is_member = False
try:
async with _httpx.AsyncClient(timeout=10) as http:
resp = await http.get(
graph_url,
headers={"Authorization": f"Bearer {user_access_token}"},
)
if resp.status_code == 200:
groups = resp.json().get("value", [])
is_member = any(
g.get("id") == allowed_group_id for g in groups
)
else:
logger.warning(
"Graph API group check returned %s for user '%s'.",
resp.status_code, email,
)
except Exception as graph_exc:
logger.error("Graph API group check failed: %s", graph_exc)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Could not verify Azure AD group membership. Please try again.",
)
if not is_member:
logger.warning(
"Azure AD login denied for '%s': not a member of required group '%s'.",
email, allowed_group_id,
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied: you are not a member of the required Azure AD group.",
)
else:
logger.warning(
"azure_allowed_group_id is not configured. All Azure AD tenant users can log in. "
"Set azure_allowed_group_id in Settings to restrict access."
)
# Find or create user # Find or create user
user = db.query(User).filter(User.username == email).first() user = db.query(User).filter(User.username == email).first()
if not user: if not user:
@@ -303,13 +435,13 @@ async def azure_callback(
password_hash=hash_password(secrets.token_urlsafe(32)), password_hash=hash_password(secrets.token_urlsafe(32)),
email=email, email=email,
is_active=True, is_active=True,
role="admin", role="viewer", # New Azure users start as viewer; promote manually
auth_provider="azure", auth_provider="azure",
) )
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
logger.info("Azure AD user '%s' auto-created.", email) logger.info("Azure AD user '%s' auto-created with role 'viewer'.", email)
elif not user.is_active: elif not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
@@ -326,9 +458,9 @@ async def azure_callback(
except HTTPException: except HTTPException:
raise raise
except Exception as exc: except Exception:
logger.exception("Azure AD authentication error") logger.exception("Azure AD authentication error")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Azure AD authentication error: {exc}", detail="Azure AD authentication failed. Please try again or contact support.",
) )

View File

@@ -211,12 +211,14 @@ async def update_customer(
@router.delete("/{customer_id}") @router.delete("/{customer_id}")
async def delete_customer( async def delete_customer(
customer_id: int, customer_id: int,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Delete a customer and clean up all resources. """Delete a customer and clean up all resources.
Removes containers, NPM proxy, instance directory, and database records. Removes containers, NPM proxy, instance directory, and database records.
Cleanup runs in background so the response returns immediately.
Args: Args:
customer_id: Customer ID. customer_id: Customer ID.
@@ -231,15 +233,23 @@ async def delete_customer(
detail="Customer not found.", detail="Customer not found.",
) )
# Undeploy first (containers, NPM, files) # Mark as deleting immediately so UI reflects the state
try: customer.status = "inactive"
await netbird_service.undeploy_customer(db, customer_id)
except Exception:
logger.exception("Undeploy error for customer %d (continuing with delete)", customer_id)
# Delete customer record (cascades to deployment + logs)
db.delete(customer)
db.commit() db.commit()
logger.info("Customer %d deleted by %s.", customer_id, current_user.username) async def _delete_in_background(cid: int) -> None:
return {"message": f"Customer {customer_id} deleted successfully."} bg_db = SessionLocal()
try:
await netbird_service.undeploy_customer(bg_db, cid)
c = bg_db.query(Customer).filter(Customer.id == cid).first()
if c:
bg_db.delete(c)
bg_db.commit()
logger.info("Customer %d deleted by %s.", cid, current_user.username)
except Exception:
logger.exception("Background delete failed for customer %d", cid)
finally:
bg_db.close()
background_tasks.add_task(_delete_in_background, customer_id)
return {"message": f"Customer {customer_id} deletion started."}

View File

@@ -72,7 +72,7 @@ async def start_customer(
Result dict. Result dict.
""" """
_require_customer(db, customer_id) _require_customer(db, customer_id)
result = netbird_service.start_customer(db, customer_id) result = await netbird_service.start_customer(db, customer_id)
if not result.get("success"): if not result.get("success"):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -96,7 +96,7 @@ async def stop_customer(
Result dict. Result dict.
""" """
_require_customer(db, customer_id) _require_customer(db, customer_id)
result = netbird_service.stop_customer(db, customer_id) result = await netbird_service.stop_customer(db, customer_id)
if not result.get("success"): if not result.get("success"):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -120,7 +120,7 @@ async def restart_customer(
Result dict. Result dict.
""" """
_require_customer(db, customer_id) _require_customer(db, customer_id)
result = netbird_service.restart_customer(db, customer_id) result = await netbird_service.restart_customer(db, customer_id)
if not result.get("success"): if not result.get("success"):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -15,8 +15,8 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.dependencies import get_current_user from app.dependencies import get_current_user
from app.models import SystemConfig, User from app.models import SystemConfig, User
from app.services import npm_service from app.services import dns_service, ldap_service, npm_service, update_service
from app.utils.config import get_system_config from app.utils.config import DATABASE_PATH, get_system_config
from app.utils.security import encrypt_value from app.utils.security import encrypt_value
from app.utils.validators import SystemConfigUpdate from app.utils.validators import SystemConfigUpdate
@@ -86,6 +86,18 @@ async def update_settings(
raw_secret = update_data.pop("azure_client_secret") raw_secret = update_data.pop("azure_client_secret")
row.azure_client_secret_encrypted = encrypt_value(raw_secret) row.azure_client_secret_encrypted = encrypt_value(raw_secret)
# Handle DNS password encryption
if "dns_password" in update_data:
row.dns_password_encrypted = encrypt_value(update_data.pop("dns_password"))
# Handle LDAP bind password encryption
if "ldap_bind_password" in update_data:
row.ldap_bind_password_encrypted = encrypt_value(update_data.pop("ldap_bind_password"))
# Handle git token encryption
if "git_token" in update_data:
row.git_token_encrypted = encrypt_value(update_data.pop("git_token"))
for field, value in update_data.items(): for field, value in update_data.items():
if hasattr(row, field): if hasattr(row, field):
setattr(row, field, value) setattr(row, field, value)
@@ -129,6 +141,99 @@ async def test_npm(
return result return result
@router.get("/npm-certificates")
async def list_npm_certificates(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""List all SSL certificates configured in NPM.
Used by the frontend to populate the wildcard certificate dropdown.
Returns:
List of certificate dicts with id, domain_names, provider, expires_on, is_wildcard.
"""
config = get_system_config(db)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System configuration not initialized.",
)
if not config.npm_api_url or not config.npm_api_email or not config.npm_api_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="NPM API URL or credentials not configured.",
)
result = await npm_service.list_certificates(
config.npm_api_url, config.npm_api_email, config.npm_api_password
)
if "error" in result:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=result["error"],
)
return result["certificates"]
@router.get("/test-dns")
async def test_dns(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Test connectivity to the Windows DNS server via WinRM.
Returns:
Dict with ``ok`` and ``message``.
"""
config = get_system_config(db)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System configuration not initialized.",
)
if not config.dns_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Windows DNS integration is not enabled.",
)
if not config.dns_server or not config.dns_username or not config.dns_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="DNS server, username, or password not configured.",
)
return await dns_service.test_dns_connection(config)
@router.get("/test-ldap")
async def test_ldap(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Test connectivity to the LDAP / Active Directory server.
Returns:
Dict with ``ok`` and ``message``.
"""
config = get_system_config(db)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System configuration not initialized.",
)
if not config.ldap_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="LDAP authentication is not enabled.",
)
if not config.ldap_server or not config.ldap_bind_dn or not config.ldap_bind_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="LDAP server, bind DN, or bind password not configured.",
)
return await ldap_service.test_ldap_connection(config)
@router.get("/branding") @router.get("/branding")
async def get_branding(db: Session = Depends(get_db)): async def get_branding(db: Session = Depends(get_db)):
"""Public endpoint — returns branding info for the login page (no auth required).""" """Public endpoint — returns branding info for the login page (no auth required)."""
@@ -209,3 +314,61 @@ async def delete_logo(
db.commit() db.commit()
return {"branding_logo_path": None} return {"branding_logo_path": None}
@router.get("/version")
async def get_version(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Return current installed version and latest available from the git remote.
Returns:
Dict with current version, latest version, and needs_update flag.
"""
config = get_system_config(db)
current = update_service.get_current_version()
if not config or not config.git_repo_url:
return {"current": current, "latest": None, "needs_update": False}
result = await update_service.check_for_updates(config)
return result
@router.post("/update")
async def trigger_update(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Backup the database, git pull the latest code, and rebuild the container.
The rebuild is fire-and-forget — the app will restart in ~60 seconds.
Only admin users may trigger an update.
Returns:
Dict with ok, message, and backup path.
"""
if getattr(current_user, "role", "admin") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admin users can trigger an update.",
)
config = get_system_config(db)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System configuration not initialized.",
)
if not config.git_repo_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="git_repo_url is not configured in settings.",
)
result = update_service.trigger_update(config, DATABASE_PATH)
if not result.get("ok"):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=result.get("message", "Update failed."),
)
logger.info("Update triggered by %s.", current_user.username)
return result

View File

@@ -33,6 +33,12 @@ async def create_user(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Create a new local user.""" """Create a new local user."""
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can create new users.",
)
existing = db.query(User).filter(User.username == payload.username).first() existing = db.query(User).filter(User.username == payload.username).first()
if existing: if existing:
raise HTTPException( raise HTTPException(
@@ -120,7 +126,7 @@ async def reset_password(
if user.auth_provider != "local": if user.auth_provider != "local":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot reset password for Azure AD users.", detail="Cannot reset password for external auth users (Azure AD / LDAP).",
) )
new_password = secrets.token_urlsafe(16) new_password = secrets.token_urlsafe(16)
@@ -145,7 +151,7 @@ async def reset_mfa(
if user.auth_provider != "local": if user.auth_provider != "local":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot reset MFA for Azure AD users.", detail="Cannot reset MFA for external auth users (Azure AD / LDAP).",
) )
user.totp_enabled = False user.totp_enabled = False

153
app/services/dns_service.py Normal file
View File

@@ -0,0 +1,153 @@
"""Windows DNS Server integration via WinRM + PowerShell.
Uses pywinrm to execute PowerShell DNS cmdlets on a remote Windows DNS server.
All WinRM operations run in a thread executor since pywinrm is synchronous.
Typical usage:
config = get_system_config(db)
result = await create_dns_record("kunde1", config)
# result == {"ok": True, "message": "A-record 'kunde1.example.com → 10.0.0.5' created."}
"""
import asyncio
import logging
from typing import Any
logger = logging.getLogger(__name__)
def _winrm_run(server: str, username: str, password: str, ps_script: str) -> tuple[int, str, str]:
"""Execute a PowerShell script via WinRM and return (status_code, stdout, stderr).
Runs synchronously — must be called via run_in_executor.
"""
import winrm # imported here so the app starts even without pywinrm installed
session = winrm.Session(
target=server,
auth=(username, password),
transport="ntlm",
)
result = session.run_ps(ps_script)
stdout = result.std_out.decode("utf-8", errors="replace").strip()
stderr = result.std_err.decode("utf-8", errors="replace").strip()
return result.status_code, stdout, stderr
async def _run_ps(server: str, username: str, password: str, ps_script: str) -> tuple[int, str, str]:
"""Async wrapper: runs _winrm_run in a thread executor."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _winrm_run, server, username, password, ps_script)
async def test_dns_connection(config: Any) -> dict:
"""Test WinRM connectivity to the Windows DNS server.
Runs 'Get-DnsServerZone' to verify the configured zone exists.
Args:
config: AppConfig with dns_server, dns_username, dns_password, dns_zone.
Returns:
Dict with ``ok`` (bool) and ``message`` (str).
"""
zone = config.dns_zone.strip()
ps = f"Get-DnsServerZone -Name '{zone}' | Select-Object ZoneName, ZoneType"
try:
code, stdout, stderr = await _run_ps(
config.dns_server, config.dns_username, config.dns_password, ps
)
if code == 0 and zone.lower() in stdout.lower():
return {"ok": True, "message": f"Connected. Zone '{zone}' found on {config.dns_server}."}
err = stderr or stdout or "Unknown error"
return {"ok": False, "message": f"Zone '{zone}' not found or access denied: {err[:300]}"}
except ImportError:
return {"ok": False, "message": "pywinrm is not installed. Add 'pywinrm' to requirements.txt."}
except Exception as exc:
logger.error("DNS connection test failed: %s", exc)
return {"ok": False, "message": f"Connection failed: {exc}"}
async def create_dns_record(subdomain: str, config: Any) -> dict:
"""Create an A-record in the Windows DNS server.
Record: {subdomain}.{zone}{dns_record_ip}
If a record already exists for the subdomain, it is removed first to avoid
duplicate-record errors (idempotent behaviour for re-deployments).
Args:
subdomain: The customer subdomain (e.g. ``kunde1``).
config: AppConfig with DNS settings.
Returns:
Dict with ``ok`` (bool) and ``message`` (str).
"""
zone = config.dns_zone.strip()
ip = config.dns_record_ip.strip()
name = subdomain.strip()
# Remove existing record first (idempotent — ignore errors)
ps_remove = (
f"Try {{"
f" Remove-DnsServerResourceRecord -ZoneName '{zone}' -RRType 'A' -Name '{name}' -Force -ErrorAction SilentlyContinue"
f"}} Catch {{}}"
)
# Create new A-record
ps_add = f"Add-DnsServerResourceRecordA -ZoneName '{zone}' -Name '{name}' -IPv4Address '{ip}' -TimeToLive 00:05:00"
ps_script = f"{ps_remove}\n{ps_add}"
try:
code, stdout, stderr = await _run_ps(
config.dns_server, config.dns_username, config.dns_password, ps_script
)
if code == 0:
logger.info("DNS A-record created: %s.%s%s", name, zone, ip)
return {"ok": True, "message": f"A-record '{name}.{zone}{ip}' created successfully."}
err = stderr or stdout or "Unknown error"
logger.warning("DNS A-record creation failed for %s.%s: %s", name, zone, err)
return {"ok": False, "message": f"Failed to create DNS record: {err[:300]}"}
except ImportError:
return {"ok": False, "message": "pywinrm is not installed. Add 'pywinrm' to requirements.txt."}
except Exception as exc:
logger.error("DNS create_record error for %s.%s: %s", name, zone, exc)
return {"ok": False, "message": f"DNS error: {exc}"}
async def delete_dns_record(subdomain: str, config: Any) -> dict:
"""Delete the A-record for a customer subdomain from the Windows DNS server.
Args:
subdomain: The customer subdomain (e.g. ``kunde1``).
config: AppConfig with DNS settings.
Returns:
Dict with ``ok`` (bool) and ``message`` (str).
"""
zone = config.dns_zone.strip()
name = subdomain.strip()
ps_script = (
f"Remove-DnsServerResourceRecord -ZoneName '{zone}' -RRType 'A' -Name '{name}' -Force"
)
try:
code, stdout, stderr = await _run_ps(
config.dns_server, config.dns_username, config.dns_password, ps_script
)
if code == 0:
logger.info("DNS A-record deleted: %s.%s", name, zone)
return {"ok": True, "message": f"A-record '{name}.{zone}' deleted successfully."}
err = stderr or stdout or "Unknown error"
# Record not found is acceptable during deletion
if "not found" in err.lower() or "does not exist" in err.lower():
logger.info("DNS A-record %s.%s not found (already deleted).", name, zone)
return {"ok": True, "message": f"A-record '{name}.{zone}' not found (already deleted)."}
logger.warning("DNS A-record deletion failed for %s.%s: %s", name, zone, err)
return {"ok": False, "message": f"Failed to delete DNS record: {err[:300]}"}
except ImportError:
return {"ok": False, "message": "pywinrm is not installed. Add 'pywinrm' to requirements.txt."}
except Exception as exc:
logger.error("DNS delete_record error for %s.%s: %s", name, zone, exc)
return {"ok": False, "message": f"DNS error: {exc}"}

View File

@@ -5,6 +5,7 @@ per-customer Docker Compose stacks. Also provides log retrieval and
container health/status information. container health/status information.
""" """
import asyncio
import logging import logging
import os import os
import subprocess import subprocess
@@ -17,6 +18,15 @@ from docker.errors import DockerException, NotFound
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def _run_cmd(cmd: list[str], timeout: int = 120) -> subprocess.CompletedProcess:
"""Run a subprocess command in a thread pool to avoid blocking the event loop."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor( # type: ignore[arg-type]
None,
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
)
def _get_client() -> docker.DockerClient: def _get_client() -> docker.DockerClient:
"""Return a Docker client connected via the Unix socket. """Return a Docker client connected via the Unix socket.
@@ -26,7 +36,7 @@ def _get_client() -> docker.DockerClient:
return docker.from_env() return docker.from_env()
def compose_up( async def compose_up(
instance_dir: str, instance_dir: str,
project_name: str, project_name: str,
services: Optional[list[str]] = None, services: Optional[list[str]] = None,
@@ -63,7 +73,7 @@ def compose_up(
cmd.extend(services) cmd.extend(services)
logger.info("Running: %s", " ".join(cmd)) logger.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) result = await _run_cmd(cmd, timeout=timeout)
if result.returncode != 0: if result.returncode != 0:
logger.error("docker compose up failed: %s", result.stderr) logger.error("docker compose up failed: %s", result.stderr)
@@ -74,7 +84,7 @@ def compose_up(
return True return True
def compose_down(instance_dir: str, project_name: str, remove_volumes: bool = False) -> bool: async def compose_down(instance_dir: str, project_name: str, remove_volumes: bool = False) -> bool:
"""Run ``docker compose down`` for a customer instance. """Run ``docker compose down`` for a customer instance.
Args: Args:
@@ -96,14 +106,14 @@ def compose_down(instance_dir: str, project_name: str, remove_volumes: bool = Fa
cmd.append("-v") cmd.append("-v")
logger.info("Running: %s", " ".join(cmd)) logger.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) result = await _run_cmd(cmd)
if result.returncode != 0: if result.returncode != 0:
logger.warning("docker compose down returned non-zero: %s", result.stderr) logger.warning("docker compose down returned non-zero: %s", result.stderr)
return True return True
def compose_stop(instance_dir: str, project_name: str) -> bool: async def compose_stop(instance_dir: str, project_name: str) -> bool:
"""Run ``docker compose stop`` for a customer instance. """Run ``docker compose stop`` for a customer instance.
Args: Args:
@@ -121,11 +131,11 @@ def compose_stop(instance_dir: str, project_name: str) -> bool:
"stop", "stop",
] ]
logger.info("Running: %s", " ".join(cmd)) logger.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) result = await _run_cmd(cmd)
return result.returncode == 0 return result.returncode == 0
def compose_start(instance_dir: str, project_name: str) -> bool: async def compose_start(instance_dir: str, project_name: str) -> bool:
"""Run ``docker compose start`` for a customer instance. """Run ``docker compose start`` for a customer instance.
Args: Args:
@@ -143,11 +153,11 @@ def compose_start(instance_dir: str, project_name: str) -> bool:
"start", "start",
] ]
logger.info("Running: %s", " ".join(cmd)) logger.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) result = await _run_cmd(cmd)
return result.returncode == 0 return result.returncode == 0
def compose_restart(instance_dir: str, project_name: str) -> bool: async def compose_restart(instance_dir: str, project_name: str) -> bool:
"""Run ``docker compose restart`` for a customer instance. """Run ``docker compose restart`` for a customer instance.
Args: Args:
@@ -165,7 +175,7 @@ def compose_restart(instance_dir: str, project_name: str) -> bool:
"restart", "restart",
] ]
logger.info("Running: %s", " ".join(cmd)) logger.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) result = await _run_cmd(cmd)
return result.returncode == 0 return result.returncode == 0

View File

@@ -0,0 +1,180 @@
"""Active Directory / LDAP authentication via ldap3.
Provides LDAP-based user authentication as an alternative to local password
authentication. Supports standard Active Directory via sAMAccountName lookup
and optional group membership restriction.
All ldap3 operations run in a thread executor since ldap3 is synchronous.
Authentication flow:
1. Bind with service account (ldap_bind_dn + ldap_bind_password)
2. Search for the user entry using ldap_user_filter
3. If ldap_group_dn is set: verify group membership
4. Re-bind with the user's own DN + supplied password to verify credentials
5. Return user info dict on success
Raises:
ValueError: If the user was found but the password is wrong.
RuntimeError: If LDAP is misconfigured or the server is unreachable.
"""
import asyncio
import logging
from typing import Any
logger = logging.getLogger(__name__)
def _ldap_test(server: str, port: int, use_ssl: bool, bind_dn: str, bind_password: str) -> dict:
"""Synchronous LDAP connectivity test — bind with service account.
Returns dict with ``ok`` and ``message``.
"""
from ldap3 import ALL, SIMPLE, Connection, Server as LdapServer, SUBTREE # noqa: F401
srv = LdapServer(server, port=port, use_ssl=use_ssl, get_info=ALL)
try:
conn = Connection(srv, user=bind_dn, password=bind_password, authentication=SIMPLE, auto_bind=True)
conn.unbind()
return {"ok": True, "message": f"Bind successful to {server}:{port} as '{bind_dn}'."}
except Exception as exc:
return {"ok": False, "message": f"LDAP bind failed: {exc}"}
def _ldap_authenticate(
server: str,
port: int,
use_ssl: bool,
bind_dn: str,
bind_password: str,
base_dn: str,
user_filter: str,
group_dn: str,
username: str,
password: str,
) -> dict | None:
"""Synchronous LDAP authentication.
Returns:
User info dict on success: {"username": ..., "email": ..., "display_name": ...}
None if user was not found in LDAP (caller may fall back to local auth).
Raises:
ValueError: Correct username but wrong password.
RuntimeError: LDAP server error / misconfiguration.
"""
from ldap3 import ALL, SIMPLE, SUBTREE, Connection, Server as LdapServer
srv = LdapServer(server, port=port, use_ssl=use_ssl, get_info=ALL)
# Step 1: Bind with service account to search for the user
try:
conn = Connection(srv, user=bind_dn, password=bind_password, authentication=SIMPLE, auto_bind=True)
except Exception as exc:
raise RuntimeError(f"LDAP service account bind failed: {exc}") from exc
# Step 2: Search for user
safe_filter = user_filter.replace("{username}", username.replace("(", "").replace(")", "").replace("*", ""))
conn.search(
search_base=base_dn,
search_filter=safe_filter,
search_scope=SUBTREE,
attributes=["distinguishedName", "mail", "displayName", "sAMAccountName", "memberOf"],
)
if not conn.entries:
conn.unbind()
return None # User not found in LDAP — caller falls back to local auth
entry = conn.entries[0]
user_dn = entry.entry_dn
email = str(entry.mail.value) if entry.mail else username
display_name = str(entry.displayName.value) if entry.displayName else username
# Step 3: Optional group membership check
if group_dn:
member_of = [str(g) for g in entry.memberOf] if entry.memberOf else []
if not any(group_dn.lower() == g.lower() for g in member_of):
conn.unbind()
logger.warning(
"LDAP login denied for '%s': not a member of required group '%s'.",
username, group_dn,
)
raise ValueError(f"Access denied: not a member of the required AD group.")
conn.unbind()
# Step 4: Verify user's password by binding as the user
try:
user_conn = Connection(srv, user=user_dn, password=password, authentication=SIMPLE, auto_bind=True)
user_conn.unbind()
except Exception:
raise ValueError("Invalid password.")
return {
"username": username.lower(),
"email": email,
"display_name": display_name,
}
async def test_ldap_connection(config: Any) -> dict:
"""Test connectivity to the LDAP / Active Directory server.
Attempts a service account bind to verify credentials and reachability.
Args:
config: AppConfig with LDAP settings.
Returns:
Dict with ``ok`` (bool) and ``message`` (str).
"""
try:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
_ldap_test,
config.ldap_server,
config.ldap_port,
config.ldap_use_ssl,
config.ldap_bind_dn,
config.ldap_bind_password,
)
except ImportError:
return {"ok": False, "message": "ldap3 is not installed. Add 'ldap3' to requirements.txt."}
except Exception as exc:
logger.error("LDAP test_connection error: %s", exc)
return {"ok": False, "message": f"LDAP error: {exc}"}
async def authenticate_ldap(username: str, password: str, config: Any) -> dict | None:
"""Authenticate a user against LDAP / Active Directory.
Args:
username: The login username (matched via ldap_user_filter).
password: The user's password.
config: AppConfig with LDAP settings.
Returns:
User info dict on success: {"username": ..., "email": ..., "display_name": ...}
None if the user was not found in LDAP.
Raises:
ValueError: User found but password incorrect, or group membership denied.
RuntimeError: LDAP server unreachable or misconfigured.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
_ldap_authenticate,
config.ldap_server,
config.ldap_port,
config.ldap_use_ssl,
config.ldap_bind_dn,
config.ldap_bind_password,
config.ldap_base_dn,
config.ldap_user_filter,
config.ldap_group_dn,
username,
password,
)

View File

@@ -30,7 +30,7 @@ from jinja2 import Environment, FileSystemLoader
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Customer, Deployment, DeploymentLog from app.models import Customer, Deployment, DeploymentLog
from app.services import docker_service, npm_service, port_manager from app.services import dns_service, docker_service, npm_service, port_manager
from app.utils.config import get_system_config from app.utils.config import get_system_config
from app.utils.security import encrypt_value, generate_datastore_encryption_key, generate_relay_secret from app.utils.security import encrypt_value, generate_datastore_encryption_key, generate_relay_secret
@@ -204,14 +204,14 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
# Step 5b: Stop existing containers if re-deploying # Step 5b: Stop existing containers if re-deploying
if existing_deployment: if existing_deployment:
try: try:
docker_service.compose_down(instance_dir, container_prefix, remove_volumes=False) await docker_service.compose_down(instance_dir, container_prefix, remove_volumes=False)
_log_action(db, customer_id, "deploy", "info", _log_action(db, customer_id, "deploy", "info",
"Stopped existing containers for re-deployment.") "Stopped existing containers for re-deployment.")
except Exception as exc: except Exception as exc:
logger.warning("Could not stop existing containers: %s", exc) logger.warning("Could not stop existing containers: %s", exc)
# Step 6: Start all Docker containers # Step 6: Start all Docker containers
docker_service.compose_up(instance_dir, container_prefix, timeout=120) await docker_service.compose_up(instance_dir, container_prefix, timeout=120)
_log_action(db, customer_id, "deploy", "info", "Docker containers started.") _log_action(db, customer_id, "deploy", "info", "Docker containers started.")
# Step 7: Wait for containers to be healthy # Step 7: Wait for containers to be healthy
@@ -277,6 +277,8 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
forward_host=forward_host, forward_host=forward_host,
forward_port=dashboard_port, forward_port=dashboard_port,
admin_email=config.admin_email, admin_email=config.admin_email,
ssl_mode=config.ssl_mode,
wildcard_cert_id=config.wildcard_cert_id,
) )
npm_proxy_id = npm_result.get("proxy_id") npm_proxy_id = npm_result.get("proxy_id")
if npm_result.get("error"): if npm_result.get("error"):
@@ -324,7 +326,20 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
"Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.", "Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.",
) )
# Step 10: Create or update deployment record # Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment)
if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip:
try:
dns_result = await dns_service.create_dns_record(customer.subdomain, config)
if dns_result["ok"]:
_log_action(db, customer_id, "dns_create", "success", dns_result["message"])
else:
_log_action(db, customer_id, "dns_create", "error", dns_result["message"])
logger.warning("DNS record creation failed (non-fatal): %s", dns_result["message"])
except Exception as exc:
logger.error("DNS service error (non-fatal): %s", exc)
_log_action(db, customer_id, "dns_create", "error", str(exc))
# Step 11: Create or update deployment record
setup_url = external_url setup_url = external_url
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
@@ -371,7 +386,7 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
# Rollback: stop containers if they were started # Rollback: stop containers if they were started
try: try:
docker_service.compose_down( await docker_service.compose_down(
instance_dir or os.path.join(config.data_dir, f"kunde{customer_id}"), instance_dir or os.path.join(config.data_dir, f"kunde{customer_id}"),
container_prefix, container_prefix,
remove_volumes=True, remove_volumes=True,
@@ -412,7 +427,7 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
# Stop and remove containers # Stop and remove containers
try: try:
docker_service.compose_down(instance_dir, deployment.container_prefix, remove_volumes=True) await docker_service.compose_down(instance_dir, deployment.container_prefix, remove_volumes=True)
_log_action(db, customer_id, "undeploy", "info", "Containers removed.") _log_action(db, customer_id, "undeploy", "info", "Containers removed.")
except Exception as exc: except Exception as exc:
_log_action(db, customer_id, "undeploy", "error", f"Container removal error: {exc}") _log_action(db, customer_id, "undeploy", "error", f"Container removal error: {exc}")
@@ -439,6 +454,17 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
except Exception as exc: except Exception as exc:
_log_action(db, customer_id, "undeploy", "error", f"NPM stream removal error: {exc}") _log_action(db, customer_id, "undeploy", "error", f"NPM stream removal error: {exc}")
# Remove Windows DNS A-record (non-fatal)
if config and config.dns_enabled and config.dns_server and config.dns_zone:
try:
dns_result = await dns_service.delete_dns_record(customer.subdomain, config)
if dns_result["ok"]:
_log_action(db, customer_id, "undeploy", "info", dns_result["message"])
else:
_log_action(db, customer_id, "undeploy", "error", f"DNS removal: {dns_result['message']}")
except Exception as exc:
logger.error("DNS record deletion failed (non-fatal): %s", exc)
# Remove instance directory # Remove instance directory
if os.path.isdir(instance_dir): if os.path.isdir(instance_dir):
try: try:
@@ -455,7 +481,7 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
return {"success": True} return {"success": True}
def stop_customer(db: Session, customer_id: int) -> dict[str, Any]: async def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
"""Stop containers for a customer.""" """Stop containers for a customer."""
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
config = get_system_config(db) config = get_system_config(db)
@@ -463,7 +489,7 @@ def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
return {"success": False, "error": "Deployment or config not found."} return {"success": False, "error": "Deployment or config not found."}
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}")
ok = docker_service.compose_stop(instance_dir, deployment.container_prefix) ok = await docker_service.compose_stop(instance_dir, deployment.container_prefix)
if ok: if ok:
deployment.deployment_status = "stopped" deployment.deployment_status = "stopped"
customer = db.query(Customer).filter(Customer.id == customer_id).first() customer = db.query(Customer).filter(Customer.id == customer_id).first()
@@ -476,7 +502,7 @@ def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
return {"success": ok} return {"success": ok}
def start_customer(db: Session, customer_id: int) -> dict[str, Any]: async def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
"""Start containers for a customer.""" """Start containers for a customer."""
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
config = get_system_config(db) config = get_system_config(db)
@@ -484,7 +510,7 @@ def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
return {"success": False, "error": "Deployment or config not found."} return {"success": False, "error": "Deployment or config not found."}
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}")
ok = docker_service.compose_start(instance_dir, deployment.container_prefix) ok = await docker_service.compose_start(instance_dir, deployment.container_prefix)
if ok: if ok:
deployment.deployment_status = "running" deployment.deployment_status = "running"
customer = db.query(Customer).filter(Customer.id == customer_id).first() customer = db.query(Customer).filter(Customer.id == customer_id).first()
@@ -497,7 +523,7 @@ def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
return {"success": ok} return {"success": ok}
def restart_customer(db: Session, customer_id: int) -> dict[str, Any]: async def restart_customer(db: Session, customer_id: int) -> dict[str, Any]:
"""Restart containers for a customer.""" """Restart containers for a customer."""
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
config = get_system_config(db) config = get_system_config(db)
@@ -505,7 +531,7 @@ def restart_customer(db: Session, customer_id: int) -> dict[str, Any]:
return {"success": False, "error": "Deployment or config not found."} return {"success": False, "error": "Deployment or config not found."}
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}")
ok = docker_service.compose_restart(instance_dir, deployment.container_prefix) ok = await docker_service.compose_restart(instance_dir, deployment.container_prefix)
if ok: if ok:
deployment.deployment_status = "running" deployment.deployment_status = "running"
customer = db.query(Customer).filter(Customer.id == customer_id).first() customer = db.query(Customer).filter(Customer.id == customer_id).first()

View File

@@ -14,6 +14,7 @@ Also manages NPM streams for STUN/TURN relay UDP ports.
import logging import logging
import os import os
import socket
from typing import Any from typing import Any
import httpx import httpx
@@ -41,7 +42,17 @@ def _get_forward_host() -> str:
logger.info("Using HOST_IP from environment: %s", host_ip) logger.info("Using HOST_IP from environment: %s", host_ip)
return host_ip return host_ip
logger.warning("HOST_IP not set in environment — please add HOST_IP=<your-server-ip> to .env") # Auto-detect: connect to external address to find the outbound interface IP
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
detected = s.getsockname()[0]
logger.info("Auto-detected host IP: %s (set HOST_IP in .env to override)", detected)
return detected
except Exception:
pass
logger.warning("Could not detect host IP — falling back to 127.0.0.1. Set HOST_IP in .env!")
return "127.0.0.1" return "127.0.0.1"
@@ -112,6 +123,45 @@ async def test_npm_connection(api_url: str, email: str, password: str) -> dict[s
return {"ok": False, "message": f"Unexpected error: {exc}"} return {"ok": False, "message": f"Unexpected error: {exc}"}
async def list_certificates(api_url: str, email: str, password: str) -> dict[str, Any]:
"""Fetch all SSL certificates from NPM.
Args:
api_url: NPM API base URL.
email: NPM login email.
password: NPM login password.
Returns:
Dict with ``certificates`` list on success, or ``error`` on failure.
"""
try:
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
token = await _npm_login(client, api_url, email, password)
headers = {"Authorization": f"Bearer {token}"}
resp = await client.get(f"{api_url}/nginx/certificates", headers=headers)
if resp.status_code == 200:
result = []
for cert in resp.json():
domains = cert.get("domain_names", [])
result.append({
"id": cert.get("id"),
"domain_names": domains,
"provider": cert.get("provider", "unknown"),
"expires_on": cert.get("expires_on"),
"is_wildcard": any(d.startswith("*.") for d in domains),
})
return {"certificates": result}
return {"error": f"NPM returned {resp.status_code}: {resp.text[:200]}"}
except RuntimeError as exc:
return {"error": str(exc)}
except httpx.ConnectError:
return {"error": "Connection refused. Is NPM running and reachable?"}
except httpx.TimeoutException:
return {"error": "Connection timed out."}
except Exception as exc:
return {"error": f"Unexpected error: {exc}"}
async def _find_cert_by_domain( async def _find_cert_by_domain(
client: httpx.AsyncClient, api_url: str, headers: dict, domain: str client: httpx.AsyncClient, api_url: str, headers: dict, domain: str
) -> int | None: ) -> int | None:
@@ -169,6 +219,8 @@ async def create_proxy_host(
forward_host: str, forward_host: str,
forward_port: int = 80, forward_port: int = 80,
admin_email: str = "", admin_email: str = "",
ssl_mode: str = "letsencrypt",
wildcard_cert_id: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a proxy host entry in NPM with SSL for a customer. """Create a proxy host entry in NPM with SSL for a customer.
@@ -265,7 +317,10 @@ async def create_proxy_host(
return {"error": error_msg} return {"error": error_msg}
# Step 2: Request SSL certificate and enable HTTPS # Step 2: Request SSL certificate and enable HTTPS
ssl_ok = await _request_ssl(client, api_url, headers, proxy_id, domain, admin_email) ssl_ok = await _request_ssl(
client, api_url, headers, proxy_id, domain, admin_email,
ssl_mode=ssl_mode, wildcard_cert_id=wildcard_cert_id,
)
return {"proxy_id": proxy_id, "ssl": ssl_ok} return {"proxy_id": proxy_id, "ssl": ssl_ok}
except RuntimeError as exc: except RuntimeError as exc:
@@ -283,13 +338,14 @@ async def _request_ssl(
proxy_id: int, proxy_id: int,
domain: str, domain: str,
admin_email: str, admin_email: str,
ssl_mode: str = "letsencrypt",
wildcard_cert_id: int | None = None,
) -> bool: ) -> bool:
"""Request a Let's Encrypt SSL certificate and enable HTTPS on the proxy host. """Request an SSL certificate and enable HTTPS on the proxy host.
Flow: Supports two modes:
1. Create LE certificate via NPM API (HTTP-01 validation, up to 120s) - ``letsencrypt``: Create a per-domain LE certificate (HTTP-01 validation).
2. Assign certificate to the proxy host - ``wildcard``: Assign a pre-existing wildcard certificate from NPM.
3. Enable ssl_forced + hsts on the proxy host
Args: Args:
client: httpx client (already authenticated). client: httpx client (already authenticated).
@@ -298,10 +354,49 @@ async def _request_ssl(
proxy_id: The proxy host ID. proxy_id: The proxy host ID.
domain: The domain to certify. domain: The domain to certify.
admin_email: Contact email for LE. admin_email: Contact email for LE.
ssl_mode: ``"letsencrypt"`` or ``"wildcard"``.
wildcard_cert_id: NPM certificate ID for wildcard mode.
Returns: Returns:
True if SSL was successfully enabled, False otherwise. True if SSL was successfully enabled, False otherwise.
""" """
# Wildcard mode: assign the pre-existing wildcard cert directly
if ssl_mode == "wildcard" and wildcard_cert_id:
logger.info(
"Wildcard mode: assigning cert id=%s to proxy host %s for %s",
wildcard_cert_id, proxy_id, domain,
)
ssl_update = {
"certificate_id": wildcard_cert_id,
"ssl_forced": True,
"hsts_enabled": True,
"http2_support": True,
}
try:
update_resp = await client.put(
f"{api_url}/nginx/proxy-hosts/{proxy_id}",
json=ssl_update,
headers=headers,
)
if update_resp.status_code in (200, 201):
logger.info(
"SSL enabled on proxy host %s (wildcard cert_id=%s)",
proxy_id, wildcard_cert_id,
)
return True
logger.error(
"Failed to assign wildcard cert %s to proxy host %s: HTTP %s%s",
wildcard_cert_id, proxy_id,
update_resp.status_code, update_resp.text[:300],
)
return False
except Exception as exc:
logger.error(
"Failed to assign wildcard cert to proxy host %s: %s", proxy_id, exc,
)
return False
# Let's Encrypt mode (default)
if not admin_email: if not admin_email:
logger.warning("No admin email set — skipping SSL certificate for %s", domain) logger.warning("No admin email set — skipping SSL certificate for %s", domain)
return False return False

View File

@@ -0,0 +1,258 @@
"""Update management — version check and in-place update via git + docker compose."""
import json
import logging
import os
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
import httpx
SOURCE_DIR = "/app-source"
VERSION_FILE = "/app/version.json"
BACKUP_DIR = "/app/backups"
logger = logging.getLogger(__name__)
def get_current_version() -> dict:
"""Read the version baked at build time from /app/version.json."""
try:
data = json.loads(Path(VERSION_FILE).read_text())
return {
"tag": data.get("tag", "unknown"),
"commit": data.get("commit", "unknown"),
"branch": data.get("branch", "unknown"),
"date": data.get("date", "unknown"),
}
except Exception:
return {"tag": "unknown", "commit": "unknown", "branch": "unknown", "date": "unknown"}
async def check_for_updates(config: Any) -> dict:
"""Query the Gitea API for the latest tag and commit on the configured branch.
Parses the repo URL to build the Gitea API endpoint:
https://git.example.com/owner/repo
→ https://git.example.com/api/v1/repos/owner/repo/...
Uses tags for version comparison when available, falls back to commit SHAs.
Returns dict with current, latest, needs_update, and optional error.
"""
current = get_current_version()
if not config.git_repo_url:
return {
"current": current,
"latest": None,
"needs_update": False,
"error": "git_repo_url not configured",
}
repo_url = config.git_repo_url.rstrip("/")
parts = repo_url.split("/")
if len(parts) < 5:
return {
"current": current,
"latest": None,
"needs_update": False,
"error": f"Cannot parse repo URL: {repo_url}",
}
base_url = "/".join(parts[:-2])
owner = parts[-2]
repo = parts[-1]
branch = config.git_branch or "main"
branch_api = f"{base_url}/api/v1/repos/{owner}/{repo}/branches/{branch}"
tags_api = f"{base_url}/api/v1/repos/{owner}/{repo}/tags?limit=1"
headers = {}
if config.git_token:
headers["Authorization"] = f"token {config.git_token}"
try:
async with httpx.AsyncClient(timeout=10) as client:
# Fetch branch info (latest commit)
resp = await client.get(branch_api, headers=headers)
if resp.status_code != 200:
return {
"current": current,
"latest": None,
"needs_update": False,
"error": f"Gitea API returned HTTP {resp.status_code}",
}
data = resp.json()
latest_commit = data.get("commit", {})
full_sha = latest_commit.get("id", "unknown")
short_sha = full_sha[:8] if full_sha != "unknown" else "unknown"
# Fetch latest tag
latest_tag = "unknown"
try:
tag_resp = await client.get(tags_api, headers=headers)
if tag_resp.status_code == 200:
tags = tag_resp.json()
if tags and len(tags) > 0:
latest_tag = tags[0].get("name", "unknown")
except Exception:
pass # Tag fetch is best-effort
latest = {
"tag": latest_tag,
"commit": short_sha,
"commit_full": full_sha,
"message": latest_commit.get("commit", {}).get("message", "").split("\n")[0],
"date": latest_commit.get("commit", {}).get("committer", {}).get("date", ""),
"branch": branch,
}
# Determine if update is needed: prefer tag comparison, fallback to commit
current_tag = current.get("tag", "unknown")
current_sha = current.get("commit", "unknown")
if current_tag != "unknown" and latest_tag != "unknown":
needs_update = current_tag != latest_tag
else:
needs_update = (
current_sha != "unknown"
and short_sha != "unknown"
and current_sha != short_sha
and not full_sha.startswith(current_sha)
)
return {"current": current, "latest": latest, "needs_update": needs_update}
except Exception as exc:
return {
"current": current,
"latest": None,
"needs_update": False,
"error": str(exc),
}
def backup_database(db_path: str) -> str:
"""Create a timestamped backup of the SQLite database.
Returns the backup file path.
"""
Path(BACKUP_DIR).mkdir(parents=True, exist_ok=True)
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
backup_path = f"{BACKUP_DIR}/netbird_msp_{timestamp}.db"
shutil.copy2(db_path, backup_path)
logger.info("Database backed up to %s", backup_path)
return backup_path
def trigger_update(config: Any, db_path: str) -> dict:
"""Backup DB, git pull latest code, then fire-and-forget docker compose rebuild.
Returns immediately after launching the rebuild. The container will restart
in ~30-60 seconds causing a brief HTTP connection drop.
Args:
config: AppConfig with git_repo_url, git_branch, git_token.
db_path: Absolute path to the SQLite database file.
Returns:
Dict with ok (bool), message, backup path, and pulled_branch.
"""
# 1. Backup database before any changes
try:
backup_path = backup_database(db_path)
except Exception as exc:
logger.error("Database backup failed: %s", exc)
return {"ok": False, "message": f"Database backup failed: {exc}", "backup": None}
# 2. Build git pull command (embed token in URL if provided)
branch = config.git_branch or "main"
if config.git_token and config.git_repo_url:
scheme_sep = config.git_repo_url.split("://", 1)
if len(scheme_sep) == 2:
auth_url = f"{scheme_sep[0]}://token:{config.git_token}@{scheme_sep[1]}"
else:
auth_url = config.git_repo_url
pull_cmd = ["git", "-C", SOURCE_DIR, "pull", auth_url, branch]
else:
pull_cmd = ["git", "-C", SOURCE_DIR, "pull", "origin", branch]
# 3. Git pull (synchronous — must complete before rebuild)
try:
result = subprocess.run(
pull_cmd,
capture_output=True,
text=True,
timeout=120,
)
except subprocess.TimeoutExpired:
return {"ok": False, "message": "git pull timed out after 120s.", "backup": backup_path}
except Exception as exc:
return {"ok": False, "message": f"git pull error: {exc}", "backup": backup_path}
if result.returncode != 0:
stderr = result.stderr.strip()[:500]
logger.error("git pull failed (exit %d): %s", result.returncode, stderr)
return {
"ok": False,
"message": f"git pull failed: {stderr}",
"backup": backup_path,
}
logger.info("git pull succeeded: %s", result.stdout.strip()[:200])
# 4. Read version info from the freshly-pulled source
build_env = os.environ.copy()
try:
build_env["GIT_COMMIT"] = subprocess.run(
["git", "-C", SOURCE_DIR, "rev-parse", "--short", "HEAD"],
capture_output=True, text=True, timeout=10,
).stdout.strip() or "unknown"
build_env["GIT_BRANCH"] = subprocess.run(
["git", "-C", SOURCE_DIR, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=10,
).stdout.strip() or "unknown"
build_env["GIT_COMMIT_DATE"] = subprocess.run(
["git", "-C", SOURCE_DIR, "log", "-1", "--format=%cI"],
capture_output=True, text=True, timeout=10,
).stdout.strip() or "unknown"
tag_result = subprocess.run(
["git", "-C", SOURCE_DIR, "describe", "--tags", "--abbrev=0"],
capture_output=True, text=True, timeout=10,
)
build_env["GIT_TAG"] = tag_result.stdout.strip() if tag_result.returncode == 0 else "unknown"
except Exception as exc:
logger.warning("Could not read version info from source: %s", exc)
logger.info(
"Rebuilding with GIT_TAG=%s GIT_COMMIT=%s GIT_BRANCH=%s",
build_env.get("GIT_TAG", "?"),
build_env.get("GIT_COMMIT", "?"),
build_env.get("GIT_BRANCH", "?"),
)
# 5. Fire-and-forget docker compose rebuild — the container will restart itself
compose_cmd = [
"docker", "compose",
"-f", f"{SOURCE_DIR}/docker-compose.yml",
"up", "--build", "-d",
]
log_path = Path(BACKUP_DIR) / "update_rebuild.log"
log_file = open(log_path, "w")
subprocess.Popen(
compose_cmd,
stdout=log_file,
stderr=log_file,
env=build_env,
)
logger.info("docker compose up --build -d triggered — container will restart shortly.")
return {
"ok": True,
"message": (
"Update gestartet. Die App wird in ca. 60 Sekunden mit der neuen Version verfügbar sein."
),
"backup": backup_path,
"pulled_branch": branch,
}

View File

@@ -31,10 +31,59 @@ class AppConfig:
docker_network: str docker_network: str
relay_base_port: int relay_base_port: int
dashboard_base_port: int dashboard_base_port: int
ssl_mode: str
wildcard_cert_id: int | None
# Windows DNS
dns_enabled: bool = False
dns_server: str = ""
dns_username: str = ""
dns_password: str = "" # decrypted
dns_zone: str = ""
dns_record_ip: str = ""
# LDAP
ldap_enabled: bool = False
ldap_server: str = ""
ldap_port: int = 389
ldap_use_ssl: bool = False
ldap_bind_dn: str = ""
ldap_bind_password: str = "" # decrypted
ldap_base_dn: str = ""
ldap_user_filter: str = "(sAMAccountName={username})"
ldap_group_dn: str = ""
# Update management
git_repo_url: str = ""
git_branch: str = "main"
git_token: str = "" # decrypted
# ---------------------------------------------------------------------------
# Environment-level settings (not stored in DB) # Environment-level settings (not stored in DB)
SECRET_KEY: str = os.environ.get("SECRET_KEY", "change-me-in-production") # ---------------------------------------------------------------------------
# Known insecure default values that must never be used in production.
_INSECURE_KEY_VALUES: set[str] = {
"change-me-in-production",
"local-test-secret-key-not-for-production-1234",
"secret",
"changeme",
"",
}
SECRET_KEY: str = os.environ.get("SECRET_KEY", "")
# --- Startup security gate ---
# Abort immediately if the key is missing, too short, or a known default.
_MIN_KEY_LENGTH = 32
if SECRET_KEY in _INSECURE_KEY_VALUES or len(SECRET_KEY) < _MIN_KEY_LENGTH:
raise RuntimeError(
"FATAL: SECRET_KEY is insecure, missing, or too short.\n"
f" Current length : {len(SECRET_KEY)} characters (minimum: {_MIN_KEY_LENGTH})\n"
" The key must be at least 32 random characters and must not be a known default value.\n"
" Generate a secure key with:\n"
" python3 -c \"import secrets; print(secrets.token_hex(32))\"\n"
" Then set it in your .env file as: SECRET_KEY=<generated-value>"
)
DATABASE_PATH: str = os.environ.get("DATABASE_PATH", "/app/data/netbird_msp.db") DATABASE_PATH: str = os.environ.get("DATABASE_PATH", "/app/data/netbird_msp.db")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO") LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
JWT_ALGORITHM: str = "HS256" JWT_ALGORITHM: str = "HS256"
@@ -64,6 +113,18 @@ def get_system_config(db: Session) -> Optional[AppConfig]:
npm_password = decrypt_value(row.npm_api_password_encrypted) npm_password = decrypt_value(row.npm_api_password_encrypted)
except Exception: except Exception:
npm_password = "" npm_password = ""
try:
dns_password = decrypt_value(row.dns_password_encrypted) if row.dns_password_encrypted else ""
except Exception:
dns_password = ""
try:
ldap_bind_password = decrypt_value(row.ldap_bind_password_encrypted) if row.ldap_bind_password_encrypted else ""
except Exception:
ldap_bind_password = ""
try:
git_token = decrypt_value(row.git_token_encrypted) if row.git_token_encrypted else ""
except Exception:
git_token = ""
return AppConfig( return AppConfig(
base_domain=row.base_domain, base_domain=row.base_domain,
@@ -79,4 +140,24 @@ def get_system_config(db: Session) -> Optional[AppConfig]:
docker_network=row.docker_network, docker_network=row.docker_network,
relay_base_port=row.relay_base_port, relay_base_port=row.relay_base_port,
dashboard_base_port=getattr(row, "dashboard_base_port", 9000) or 9000, dashboard_base_port=getattr(row, "dashboard_base_port", 9000) or 9000,
ssl_mode=getattr(row, "ssl_mode", "letsencrypt") or "letsencrypt",
wildcard_cert_id=getattr(row, "wildcard_cert_id", None),
dns_enabled=bool(getattr(row, "dns_enabled", False)),
dns_server=getattr(row, "dns_server", "") or "",
dns_username=getattr(row, "dns_username", "") or "",
dns_password=dns_password,
dns_zone=getattr(row, "dns_zone", "") or "",
dns_record_ip=getattr(row, "dns_record_ip", "") or "",
ldap_enabled=bool(getattr(row, "ldap_enabled", False)),
ldap_server=getattr(row, "ldap_server", "") or "",
ldap_port=getattr(row, "ldap_port", 389) or 389,
ldap_use_ssl=bool(getattr(row, "ldap_use_ssl", False)),
ldap_bind_dn=getattr(row, "ldap_bind_dn", "") or "",
ldap_bind_password=ldap_bind_password,
ldap_base_dn=getattr(row, "ldap_base_dn", "") or "",
ldap_user_filter=getattr(row, "ldap_user_filter", "(sAMAccountName={username})") or "(sAMAccountName={username})",
ldap_group_dn=getattr(row, "ldap_group_dn", "") or "",
git_repo_url=getattr(row, "git_repo_url", "") or "",
git_branch=getattr(row, "git_branch", "main") or "main",
git_token=git_token,
) )

View File

@@ -126,11 +126,49 @@ class SystemConfigUpdate(BaseModel):
branding_name: Optional[str] = Field(None, max_length=255) branding_name: Optional[str] = Field(None, max_length=255)
branding_subtitle: 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) default_language: Optional[str] = Field(None, max_length=10)
ssl_mode: Optional[str] = Field(None, max_length=20)
wildcard_cert_id: Optional[int] = Field(None, ge=0)
mfa_enabled: Optional[bool] = None mfa_enabled: Optional[bool] = None
azure_enabled: Optional[bool] = None azure_enabled: Optional[bool] = None
azure_tenant_id: Optional[str] = Field(None, max_length=255) azure_tenant_id: Optional[str] = Field(None, max_length=255)
azure_client_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 azure_client_secret: Optional[str] = None # encrypted before storage
azure_allowed_group_id: Optional[str] = Field(
None, max_length=255,
description="Azure AD group object ID. If set, only members of this group can log in."
)
# Windows DNS
dns_enabled: Optional[bool] = None
dns_server: Optional[str] = Field(None, max_length=255)
dns_username: Optional[str] = Field(None, max_length=255)
dns_password: Optional[str] = None # plaintext, encrypted before storage
dns_zone: Optional[str] = Field(None, max_length=255)
dns_record_ip: Optional[str] = Field(None, max_length=45)
# LDAP
ldap_enabled: Optional[bool] = None
ldap_server: Optional[str] = Field(None, max_length=255)
ldap_port: Optional[int] = Field(None, ge=1, le=65535)
ldap_use_ssl: Optional[bool] = None
ldap_bind_dn: Optional[str] = Field(None, max_length=500)
ldap_bind_password: Optional[str] = None # plaintext, encrypted before storage
ldap_base_dn: Optional[str] = Field(None, max_length=500)
ldap_user_filter: Optional[str] = Field(None, max_length=255)
ldap_group_dn: Optional[str] = Field(None, max_length=500)
# Update management
git_repo_url: Optional[str] = Field(None, max_length=500)
git_branch: Optional[str] = Field(None, max_length=100)
git_token: Optional[str] = None # plaintext, encrypted before storage
@field_validator("ssl_mode")
@classmethod
def validate_ssl_mode(cls, v: Optional[str]) -> Optional[str]:
"""SSL mode must be 'letsencrypt' or 'wildcard'."""
if v is None:
return v
allowed = {"letsencrypt", "wildcard"}
if v not in allowed:
raise ValueError(f"ssl_mode must be one of: {', '.join(sorted(allowed))}")
return v
@field_validator("base_domain") @field_validator("base_domain")
@classmethod @classmethod

View File

@@ -1,16 +1,67 @@
services: services:
# ---------------------------------------------------------------------------
# Docker Socket Proxy — limits Docker API access to only what is needed.
# The main app container no longer has direct access to /var/run/docker.sock.
# ---------------------------------------------------------------------------
docker-socket-proxy:
image: tecnativa/docker-socket-proxy:latest
container_name: docker-socket-proxy
restart: unless-stopped
environment:
# Read-only endpoints
CONTAINERS: 1
IMAGES: 1
NETWORKS: 1
INFO: 1
# Write endpoints (needed for compose up/down/start/stop)
POST: 1
DELETE: 1
# Volumes needed for docker compose (creates/removes volumes per customer)
VOLUMES: 1
# Explicitly deny dangerous endpoints
AUTH: 0
SECRETS: 0
SWARM: 0
NODES: 0
SERVICES: 0
TASKS: 0
CONFIGS: 0
PLUGINS: 0
BUILD: 0
COMMIT: 0
DISTRIBUTION: 0
EXEC: 1
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro,z
networks:
- npm-network
# Only accessible from within the Docker network — never expose port externally
netbird-msp-appliance: netbird-msp-appliance:
build: . build:
context: .
args:
GIT_COMMIT: ${GIT_COMMIT:-unknown}
GIT_BRANCH: ${GIT_BRANCH:-unknown}
GIT_COMMIT_DATE: ${GIT_COMMIT_DATE:-unknown}
GIT_TAG: ${GIT_TAG:-unknown}
container_name: netbird-msp-appliance container_name: netbird-msp-appliance
restart: unless-stopped restart: unless-stopped
security_opt:
- label:disable
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- docker-socket-proxy
ports: ports:
- "${WEB_UI_PORT:-8000}:8000" - "${WEB_UI_PORT:-8000}:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data:z
- ./logs:/app/logs - ./logs:/app/logs:z
- ./backups:/app/backups - ./backups:/app/backups:z
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock:z
- ${DATA_DIR:-/opt/netbird-instances}:${DATA_DIR:-/opt/netbird-instances} - ${DATA_DIR:-/opt/netbird-instances}:${DATA_DIR:-/opt/netbird-instances}:z
- .:/app-source:z
environment: environment:
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- DATABASE_PATH=/app/data/netbird_msp.db - DATABASE_PATH=/app/data/netbird_msp.db

50
dockerlogs.txt Normal file
View File

@@ -0,0 +1,50 @@
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:27:28,812 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:28,818 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:27:29,463 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:29,473 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:27:33,352 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:33,358 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:27:34,899 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:34,905 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/monitoring/resources HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/monitoring/customers HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/monitoring/customers HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/monitoring/resources HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK
2026-02-22 13:27:49,427 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:49,433 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET / HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/settings/branding HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/auth/azure/config HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/monitoring/status HTTP/1.1" 200 OK
INFO: 172.18.0.1:45440 - "GET /api/customers?page=1&per_page=25 HTTP/1.1" 200 OK
INFO: 127.0.0.1:35528 - "GET /api/health HTTP/1.1" 200 OK
INFO: 172.18.0.1:33288 - "GET /api/settings/system HTTP/1.1" 200 OK
INFO: 172.18.0.1:38946 - "GET /api/auth/mfa/status HTTP/1.1" 200 OK
2026-02-22 13:27:56,795 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:56,802 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:27:59,507 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:27:59,514 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:33288 - "GET /api/settings/version HTTP/1.1" 200 OK
2026-02-22 13:28:09,172 [INFO] app.services.update_service: Database backed up to /app/backups/netbird_msp_20260222_132809.db
2026-02-22 13:28:09,264 [INFO] app.services.update_service: git pull succeeded: Already up to date.
2026-02-22 13:28:09,265 [INFO] app.services.update_service: docker compose up --build -d triggered — container will restart shortly.
2026-02-22 13:28:09,265 [INFO] app.routers.settings: Update triggered by admin.
INFO: 172.18.0.1:57990 - "POST /api/settings/update HTTP/1.1" 200 OK
INFO: 127.0.0.1:51474 - "GET /api/health HTTP/1.1" 200 OK
2026-02-22 13:28:49,056 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/branches/unstable "HTTP/1.1 200 OK"
2026-02-22 13:28:49,062 [INFO] httpx: HTTP Request: GET https://git.0x26.ch/api/v1/repos/BurgerGames/NetBirdMSP-Appliance/tags?limit=1 "HTTP/1.1 200 OK"
INFO: 172.18.0.1:44506 - "GET /api/settings/version HTTP/1.1" 200 OK
INFO: 127.0.0.1:53966 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:35452 - "GET /api/health HTTP/1.1" 200 OK

View File

@@ -20,6 +20,9 @@ pyyaml==6.0.1
msal==1.28.0 msal==1.28.0
pyotp==2.9.0 pyotp==2.9.0
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
slowapi==0.1.9
pywinrm>=0.4.3
ldap3>=2.9.1
pytest==7.4.3 pytest==7.4.3
pytest-asyncio==0.23.2 pytest-asyncio==0.23.2
pytest-httpx==0.28.0 pytest-httpx==0.28.0

View File

@@ -18,6 +18,7 @@
<div id="login-logo"><i class="bi bi-hdd-network fs-1 text-primary"></i></div> <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> <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> <p class="text-muted" id="login-subtitle" data-i18n="login.subtitle">Multi-Tenant Management Platform</p>
<p class="text-muted small mb-0" style="opacity:0.6;"><i class="bi bi-tag me-1"></i>alpha-1.1</p>
</div> </div>
<div id="login-error" class="alert alert-danger d-none"></div> <div id="login-error" class="alert alert-danger d-none"></div>
<form id="login-form"> <form id="login-form">
@@ -311,6 +312,9 @@
<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-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-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-azure" data-i18n="settings.tabAzure">Azure AD</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-dns" data-i18n="settings.tabDns">Windows DNS</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-ldap" data-i18n="settings.tabLdap">LDAP / AD</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-update" onclick="loadVersionInfo()" data-i18n="settings.tabUpdate">Updates</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-security" data-i18n="settings.tabSecurity">Security</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> </ul>
@@ -381,6 +385,32 @@
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('cfg-npm-api-password')"><i class="bi bi-eye"></i></button> <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>
<!-- SSL Certificate Mode -->
<div class="col-12 mt-3">
<hr class="my-2">
<h6 class="mb-2" data-i18n="settings.sslModeTitle">SSL Certificate Mode</h6>
</div>
<div class="col-md-8">
<label class="form-label" data-i18n="settings.sslMode">SSL Mode</label>
<select class="form-select" id="cfg-ssl-mode" onchange="onSslModeChange()">
<option value="letsencrypt" data-i18n="settings.sslModeLetsencrypt">Per-Customer Let's Encrypt Certificate</option>
<option value="wildcard" data-i18n="settings.sslModeWildcard">Wildcard Certificate (pre-configured in NPM)</option>
</select>
<div class="form-text" data-i18n="settings.sslModeHint">Choose how SSL certificates are assigned to customer proxy hosts.</div>
</div>
<div class="col-md-8" id="wildcard-cert-section" style="display:none;">
<label class="form-label" data-i18n="settings.wildcardCertificate">Wildcard Certificate</label>
<div class="input-group">
<select class="form-select" id="cfg-wildcard-cert-id">
<option value="" data-i18n="settings.selectCertificate">-- Select a certificate --</option>
</select>
<button type="button" class="btn btn-outline-secondary" onclick="loadNpmCertificates()" title="Refresh">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="form-text" data-i18n="settings.wildcardCertHint">Select the wildcard certificate (e.g. *.example.com) already uploaded in NPM.</div>
<div id="wildcard-cert-status" class="mt-1"></div>
</div>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<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="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>
@@ -536,6 +566,11 @@
</div> </div>
<div class="form-text" id="azure-secret-status"></div> <div class="form-text" id="azure-secret-status"></div>
</div> </div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.azureGroupId">Allowed Group Object ID (optional)</label>
<input type="text" class="form-control" id="cfg-azure-group-id" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
<div class="form-text" data-i18n="settings.azureGroupIdHint">If set, only Azure AD members of this group can log in.</div>
</div>
</div> </div>
<div class="mt-4"> <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> <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>
@@ -545,6 +580,171 @@
</div> </div>
</div> </div>
<!-- Windows DNS -->
<div class="tab-pane fade" id="settings-dns">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3" data-i18n="settings.dnsTitle">Windows DNS Integration</h5>
<form id="settings-dns-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-dns-enabled">
<label class="form-check-label" for="cfg-dns-enabled" data-i18n="settings.enableDns">Enable Windows DNS Integration</label>
</div>
<div class="form-text" data-i18n="settings.dnsDescription">Automatically create/delete DNS A-records when deploying customers.</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.dnsServer">DNS Server Address</label>
<input type="text" class="form-control" id="cfg-dns-server" placeholder="192.168.1.10">
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.dnsZone">DNS Zone</label>
<input type="text" class="form-control" id="cfg-dns-zone" placeholder="example.com">
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.dnsUsername">Username (NTLM)</label>
<input type="text" class="form-control" id="cfg-dns-username" placeholder="DOMAIN\svcuser">
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.dnsPassword">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="cfg-dns-password" data-i18n-placeholder="settings.leaveEmptyToKeep" placeholder="Leave empty to keep current">
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('cfg-dns-password')"><i class="bi bi-eye"></i></button>
</div>
<div class="form-text" id="dns-password-status"></div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.dnsRecordIp">A-Record Target IP</label>
<input type="text" class="form-control" id="cfg-dns-record-ip" placeholder="1.2.3.4">
<div class="form-text" data-i18n="settings.dnsRecordIpHint">IP address that customer A-records will point to (usually your NPM server IP).</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary me-2"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveDnsSettings">Save DNS Settings</span></button>
<button type="button" class="btn btn-outline-info" onclick="testDnsConnection()">
<span class="spinner-border spinner-border-sm d-none me-1" id="dns-test-spinner"></span>
<i class="bi bi-plug me-1"></i><span data-i18n="settings.testConnection">Test Connection</span>
</button>
</div>
</form>
<div id="dns-test-result" class="mt-3 d-none"></div>
</div>
</div>
</div>
<!-- LDAP / Active Directory -->
<div class="tab-pane fade" id="settings-ldap">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3" data-i18n="settings.ldapTitle">LDAP / Active Directory Authentication</h5>
<form id="settings-ldap-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-ldap-enabled">
<label class="form-check-label" for="cfg-ldap-enabled" data-i18n="settings.enableLdap">Enable LDAP / AD Authentication</label>
</div>
<div class="form-text" data-i18n="settings.ldapDescription">Allow Active Directory users to log in. Local admin accounts always work as fallback.</div>
</div>
<div class="col-md-5">
<label class="form-label" data-i18n="settings.ldapServer">LDAP Server</label>
<input type="text" class="form-control" id="cfg-ldap-server" placeholder="192.168.1.10 or dc.example.com">
</div>
<div class="col-md-3">
<label class="form-label" data-i18n="settings.ldapPort">Port</label>
<input type="number" class="form-control" id="cfg-ldap-port" value="389" min="1" max="65535">
</div>
<div class="col-md-4 d-flex align-items-end pb-1">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cfg-ldap-use-ssl">
<label class="form-check-label" for="cfg-ldap-use-ssl" data-i18n="settings.ldapUseSsl">Use SSL/TLS (LDAPS)</label>
</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.ldapBindDn">Bind DN (Service Account)</label>
<input type="text" class="form-control" id="cfg-ldap-bind-dn" placeholder="CN=svcUser,OU=Service,DC=example,DC=com">
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.ldapBindPassword">Bind Password</label>
<div class="input-group">
<input type="password" class="form-control" id="cfg-ldap-bind-password" data-i18n-placeholder="settings.leaveEmptyToKeep" placeholder="Leave empty to keep current">
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('cfg-ldap-bind-password')"><i class="bi bi-eye"></i></button>
</div>
<div class="form-text" id="ldap-password-status"></div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.ldapBaseDn">Base DN</label>
<input type="text" class="form-control" id="cfg-ldap-base-dn" placeholder="DC=example,DC=com">
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.ldapUserFilter">User Filter</label>
<input type="text" class="form-control" id="cfg-ldap-user-filter" placeholder="(sAMAccountName={username})">
<div class="form-text" data-i18n="settings.ldapUserFilterHint">Use {username} as placeholder for the login name.</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.ldapGroupDn">Group Restriction DN (optional)</label>
<input type="text" class="form-control" id="cfg-ldap-group-dn" placeholder="CN=NetBirdAdmins,OU=Groups,DC=example,DC=com">
<div class="form-text" data-i18n="settings.ldapGroupDnHint">If set, only members of this group can log in via LDAP.</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary me-2"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveLdapSettings">Save LDAP Settings</span></button>
<button type="button" class="btn btn-outline-info" onclick="testLdapConnection()">
<span class="spinner-border spinner-border-sm d-none me-1" id="ldap-test-spinner"></span>
<i class="bi bi-plug me-1"></i><span data-i18n="settings.testConnection">Test Connection</span>
</button>
</div>
</form>
<div id="ldap-test-result" class="mt-3 d-none"></div>
</div>
</div>
</div>
<!-- Updates -->
<div class="tab-pane fade" id="settings-update">
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span data-i18n="settings.versionTitle">Version &amp; Updates</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadVersionInfo()">
<i class="bi bi-arrow-clockwise me-1"></i><span data-i18n="dashboard.refresh">Refresh</span>
</button>
</div>
<div class="card-body" id="version-info-content">
<div class="text-muted" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3" data-i18n="settings.gitTitle">Git Repository Settings</h5>
<form id="settings-git-form">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label" data-i18n="settings.gitRepoUrl">Repository URL</label>
<input type="text" class="form-control" id="cfg-git-repo-url" placeholder="https://git.example.com/owner/repo">
<div class="form-text" data-i18n="settings.gitRepoUrlHint">Used for version checks and one-click updates via Gitea API.</div>
</div>
<div class="col-md-4">
<label class="form-label" data-i18n="settings.gitBranch">Branch</label>
<input type="text" class="form-control" id="cfg-git-branch" placeholder="main">
</div>
<div class="col-md-8">
<label class="form-label" data-i18n="settings.gitToken">Access Token (optional)</label>
<div class="input-group">
<input type="password" class="form-control" id="cfg-git-token" data-i18n-placeholder="settings.leaveEmptyToKeep" placeholder="Leave empty to keep current">
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('cfg-git-token')"><i class="bi bi-eye"></i></button>
</div>
<div class="form-text" id="git-token-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.saveGitSettings">Save Git Settings</span></button>
</div>
</form>
</div>
</div>
</div>
<!-- Security --> <!-- Security -->
<div class="tab-pane fade" id="settings-security"> <div class="tab-pane fade" id="settings-security">
<!-- MFA Settings --> <!-- MFA Settings -->

View File

@@ -816,6 +816,14 @@ async function loadSettings() {
document.getElementById('cfg-dashboard-base-port').value = cfg.dashboard_base_port || 9000; 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('cfg-npm-api-url').value = cfg.npm_api_url || '';
document.getElementById('npm-credentials-status').textContent = cfg.npm_credentials_set ? t('settings.credentialsSet') : t('settings.noCredentials'); document.getElementById('npm-credentials-status').textContent = cfg.npm_credentials_set ? t('settings.credentialsSet') : t('settings.noCredentials');
// SSL mode
document.getElementById('cfg-ssl-mode').value = cfg.ssl_mode || 'letsencrypt';
onSslModeChange();
if (cfg.ssl_mode === 'wildcard') {
loadNpmCertificates(cfg.wildcard_cert_id);
}
document.getElementById('cfg-mgmt-image').value = cfg.netbird_management_image || ''; document.getElementById('cfg-mgmt-image').value = cfg.netbird_management_image || '';
document.getElementById('cfg-signal-image').value = cfg.netbird_signal_image || ''; document.getElementById('cfg-signal-image').value = cfg.netbird_signal_image || '';
document.getElementById('cfg-relay-image').value = cfg.netbird_relay_image || ''; document.getElementById('cfg-relay-image').value = cfg.netbird_relay_image || '';
@@ -836,6 +844,31 @@ async function loadSettings() {
document.getElementById('cfg-azure-tenant').value = cfg.azure_tenant_id || ''; document.getElementById('cfg-azure-tenant').value = cfg.azure_tenant_id || '';
document.getElementById('cfg-azure-client-id').value = cfg.azure_client_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'); document.getElementById('azure-secret-status').textContent = cfg.azure_client_secret_set ? t('settings.secretSet') : t('settings.noSecret');
document.getElementById('cfg-azure-group-id').value = cfg.azure_allowed_group_id || '';
// DNS tab
document.getElementById('cfg-dns-enabled').checked = cfg.dns_enabled || false;
document.getElementById('cfg-dns-server').value = cfg.dns_server || '';
document.getElementById('cfg-dns-zone').value = cfg.dns_zone || '';
document.getElementById('cfg-dns-username').value = cfg.dns_username || '';
document.getElementById('cfg-dns-record-ip').value = cfg.dns_record_ip || '';
document.getElementById('dns-password-status').textContent = cfg.dns_password_set ? t('settings.passwordSet') : t('settings.noPasswordSet');
// LDAP tab
document.getElementById('cfg-ldap-enabled').checked = cfg.ldap_enabled || false;
document.getElementById('cfg-ldap-server').value = cfg.ldap_server || '';
document.getElementById('cfg-ldap-port').value = cfg.ldap_port || 389;
document.getElementById('cfg-ldap-use-ssl').checked = cfg.ldap_use_ssl || false;
document.getElementById('cfg-ldap-bind-dn').value = cfg.ldap_bind_dn || '';
document.getElementById('cfg-ldap-base-dn').value = cfg.ldap_base_dn || '';
document.getElementById('cfg-ldap-user-filter').value = cfg.ldap_user_filter || '(sAMAccountName={username})';
document.getElementById('cfg-ldap-group-dn').value = cfg.ldap_group_dn || '';
document.getElementById('ldap-password-status').textContent = cfg.ldap_bind_password_set ? t('settings.passwordSet') : t('settings.noPasswordSet');
// Git/Update tab
document.getElementById('cfg-git-repo-url').value = cfg.git_repo_url || '';
document.getElementById('cfg-git-branch').value = cfg.git_branch || 'main';
document.getElementById('git-token-status').textContent = cfg.git_token_set ? t('settings.tokenSet') : t('settings.noToken');
} catch (err) { } catch (err) {
showSettingsAlert('danger', t('errors.failedToLoadSettings', { error: err.message })); showSettingsAlert('danger', t('errors.failedToLoadSettings', { error: err.message }));
} }
@@ -876,6 +909,14 @@ document.getElementById('settings-npm-form').addEventListener('submit', async (e
const password = document.getElementById('cfg-npm-api-password').value; const password = document.getElementById('cfg-npm-api-password').value;
if (email) payload.npm_api_email = email; if (email) payload.npm_api_email = email;
if (password) payload.npm_api_password = password; if (password) payload.npm_api_password = password;
// SSL mode
const sslMode = document.getElementById('cfg-ssl-mode').value;
payload.ssl_mode = sslMode;
if (sslMode === 'wildcard') {
const certId = document.getElementById('cfg-wildcard-cert-id').value;
if (certId) payload.wildcard_cert_id = parseInt(certId);
}
try { try {
await api('PUT', '/settings/system', payload); await api('PUT', '/settings/system', payload);
showSettingsAlert('success', t('messages.npmSettingsSaved')); showSettingsAlert('success', t('messages.npmSettingsSaved'));
@@ -924,6 +965,42 @@ async function testNpmConnection() {
} }
} }
// SSL mode toggle
function onSslModeChange() {
const mode = document.getElementById('cfg-ssl-mode').value;
const section = document.getElementById('wildcard-cert-section');
section.style.display = mode === 'wildcard' ? '' : 'none';
}
// Load NPM wildcard certificates into dropdown
async function loadNpmCertificates(preselectId) {
const select = document.getElementById('cfg-wildcard-cert-id');
const statusEl = document.getElementById('wildcard-cert-status');
select.innerHTML = `<option value="">${t('settings.selectCertificate')}</option>`;
statusEl.textContent = t('common.loading');
statusEl.className = 'mt-1 text-muted';
try {
const certs = await api('GET', '/settings/npm-certificates');
const wildcards = certs.filter(c => c.is_wildcard || (c.domain_names && c.domain_names.some(d => d.startsWith('*.'))));
wildcards.forEach(c => {
const domains = (c.domain_names || []).join(', ');
const expires = c.expires_on ? ` (${t('settings.expiresOn')}: ${new Date(c.expires_on).toLocaleDateString()})` : '';
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = `${domains}${expires}`;
select.appendChild(opt);
});
if (preselectId) select.value = preselectId;
statusEl.textContent = t('settings.certsLoaded', { count: wildcards.length });
statusEl.className = wildcards.length > 0 ? 'mt-1 text-success small' : 'mt-1 text-warning small';
if (wildcards.length === 0) statusEl.textContent = t('settings.noWildcardCerts');
} catch (err) {
statusEl.textContent = t('errors.failed', { error: err.message });
statusEl.className = 'mt-1 text-danger small';
}
}
// Change password form // Change password form
document.getElementById('change-password-form').addEventListener('submit', async (e) => { document.getElementById('change-password-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
@@ -1017,6 +1094,189 @@ async function deleteLogo() {
} }
} }
// ---------------------------------------------------------------------------
// DNS Settings
// ---------------------------------------------------------------------------
document.getElementById('settings-dns-form').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {
dns_enabled: document.getElementById('cfg-dns-enabled').checked,
dns_server: document.getElementById('cfg-dns-server').value,
dns_zone: document.getElementById('cfg-dns-zone').value,
dns_username: document.getElementById('cfg-dns-username').value,
dns_record_ip: document.getElementById('cfg-dns-record-ip').value,
};
const pw = document.getElementById('cfg-dns-password').value;
if (pw) payload.dns_password = pw;
try {
await api('PUT', '/settings/system', payload);
showSettingsAlert('success', t('messages.dnsSettingsSaved'));
document.getElementById('cfg-dns-password').value = '';
loadSettings();
} catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
async function testDnsConnection() {
const spinner = document.getElementById('dns-test-spinner');
const resultEl = document.getElementById('dns-test-result');
spinner.classList.remove('d-none');
resultEl.classList.add('d-none');
try {
const data = await api('GET', '/settings/test-dns');
resultEl.className = `mt-3 alert alert-${data.ok ? 'success' : 'danger'}`;
resultEl.textContent = data.message;
resultEl.classList.remove('d-none');
} catch (err) {
resultEl.className = 'mt-3 alert alert-danger';
resultEl.textContent = err.message;
resultEl.classList.remove('d-none');
} finally {
spinner.classList.add('d-none');
}
}
// ---------------------------------------------------------------------------
// LDAP Settings
// ---------------------------------------------------------------------------
document.getElementById('settings-ldap-form').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {
ldap_enabled: document.getElementById('cfg-ldap-enabled').checked,
ldap_server: document.getElementById('cfg-ldap-server').value,
ldap_port: parseInt(document.getElementById('cfg-ldap-port').value) || 389,
ldap_use_ssl: document.getElementById('cfg-ldap-use-ssl').checked,
ldap_bind_dn: document.getElementById('cfg-ldap-bind-dn').value,
ldap_base_dn: document.getElementById('cfg-ldap-base-dn').value,
ldap_user_filter: document.getElementById('cfg-ldap-user-filter').value,
ldap_group_dn: document.getElementById('cfg-ldap-group-dn').value,
};
const pw = document.getElementById('cfg-ldap-bind-password').value;
if (pw) payload.ldap_bind_password = pw;
try {
await api('PUT', '/settings/system', payload);
showSettingsAlert('success', t('messages.ldapSettingsSaved'));
document.getElementById('cfg-ldap-bind-password').value = '';
loadSettings();
} catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
async function testLdapConnection() {
const spinner = document.getElementById('ldap-test-spinner');
const resultEl = document.getElementById('ldap-test-result');
spinner.classList.remove('d-none');
resultEl.classList.add('d-none');
try {
const data = await api('GET', '/settings/test-ldap');
resultEl.className = `mt-3 alert alert-${data.ok ? 'success' : 'danger'}`;
resultEl.textContent = data.message;
resultEl.classList.remove('d-none');
} catch (err) {
resultEl.className = 'mt-3 alert alert-danger';
resultEl.textContent = err.message;
resultEl.classList.remove('d-none');
} finally {
spinner.classList.add('d-none');
}
}
// ---------------------------------------------------------------------------
// Update / Version Management
// ---------------------------------------------------------------------------
document.getElementById('settings-git-form').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {
git_repo_url: document.getElementById('cfg-git-repo-url').value,
git_branch: document.getElementById('cfg-git-branch').value || 'main',
};
const token = document.getElementById('cfg-git-token').value;
if (token) payload.git_token = token;
try {
await api('PUT', '/settings/system', payload);
showSettingsAlert('success', t('messages.gitSettingsSaved'));
document.getElementById('cfg-git-token').value = '';
loadSettings();
} catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
async function loadVersionInfo() {
const el = document.getElementById('version-info-content');
if (!el) return;
el.innerHTML = `<div class="text-muted">${t('common.loading')}</div>`;
try {
const data = await api('GET', '/settings/version');
const current = data.current || {};
const latest = data.latest;
const needsUpdate = data.needs_update;
const currentTag = current.tag && current.tag !== 'unknown' ? current.tag : null;
const currentCommit = current.commit || 'unknown';
let html = `<div class="row g-3">
<div class="col-md-6">
<div class="border rounded p-3">
<div class="text-muted small mb-1">${t('settings.currentVersion')}</div>
<div class="fw-bold fs-5">${esc(currentTag || currentCommit)}</div>
${currentTag ? `<div class="text-muted small font-monospace">${t('settings.commitHash')}: ${esc(currentCommit)}</div>` : ''}
<div class="text-muted small">${t('settings.branch')}: <strong>${esc(current.branch || 'unknown')}</strong></div>
<div class="text-muted small">${esc(current.date || '')}</div>
</div>
</div>`;
if (latest) {
const latestTag = latest.tag && latest.tag !== 'unknown' ? latest.tag : null;
const latestCommit = latest.commit || 'unknown';
const badge = needsUpdate
? `<span class="badge bg-warning text-dark ms-1">${t('settings.updateAvailable')}</span>`
: `<span class="badge bg-success ms-1">${t('settings.upToDate')}</span>`;
html += `<div class="col-md-6">
<div class="border rounded p-3 ${needsUpdate ? 'border-warning' : ''}">
<div class="text-muted small mb-1">${t('settings.latestVersion')} ${badge}</div>
<div class="fw-bold fs-5">${esc(latestTag || latestCommit)}</div>
${latestTag ? `<div class="text-muted small font-monospace">${t('settings.commitHash')}: ${esc(latestCommit)}</div>` : ''}
<div class="text-muted small">${t('settings.branch')}: <strong>${esc(latest.branch || 'unknown')}</strong></div>
<div class="text-muted small">${esc(latest.message || '')}</div>
<div class="text-muted small">${esc(latest.date || '')}</div>
</div>
</div>`;
} else if (data.error) {
html += `<div class="col-md-6"><div class="alert alert-warning mb-0">${esc(data.error)}</div></div>`;
}
html += '</div>';
if (needsUpdate) {
html += `<div class="mt-3">
<button class="btn btn-warning" onclick="triggerUpdate()">
<span class="spinner-border spinner-border-sm d-none me-1" id="update-spinner"></span>
<i class="bi bi-arrow-repeat me-1"></i>${t('settings.triggerUpdate')}
</button>
<div class="text-muted small mt-1">${t('settings.updateWarning')}</div>
</div>`;
}
el.innerHTML = html;
} catch (err) {
el.innerHTML = `<div class="text-danger">${esc(err.message)}</div>`;
}
}
async function triggerUpdate() {
if (!confirm(t('settings.confirmUpdate'))) return;
const spinner = document.getElementById('update-spinner');
if (spinner) spinner.classList.remove('d-none');
try {
const data = await api('POST', '/settings/update');
showSettingsAlert('success', data.message || t('messages.updateStarted'));
} catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
if (spinner) spinner.classList.add('d-none');
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// User Management // User Management
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1129,6 +1389,7 @@ document.getElementById('settings-azure-form').addEventListener('submit', async
azure_enabled: document.getElementById('cfg-azure-enabled').checked, azure_enabled: document.getElementById('cfg-azure-enabled').checked,
azure_tenant_id: document.getElementById('cfg-azure-tenant').value || null, azure_tenant_id: document.getElementById('cfg-azure-tenant').value || null,
azure_client_id: document.getElementById('cfg-azure-client-id').value || null, azure_client_id: document.getElementById('cfg-azure-client-id').value || null,
azure_allowed_group_id: document.getElementById('cfg-azure-group-id').value || null,
}; };
const secret = document.getElementById('cfg-azure-client-secret').value; const secret = document.getElementById('cfg-azure-client-secret').value;
if (secret) payload.azure_client_secret = secret; if (secret) payload.azure_client_secret = secret;

View File

@@ -29,7 +29,7 @@
"thSubdomain": "Subdomain", "thSubdomain": "Subdomain",
"thStatus": "Status", "thStatus": "Status",
"thDashboard": "Dashboard", "thDashboard": "Dashboard",
"thDevices": "Geraete", "thDevices": "Geräte",
"thCreated": "Erstellt", "thCreated": "Erstellt",
"thActions": "Aktionen", "thActions": "Aktionen",
"noCustomers": "Keine Kunden gefunden. Klicken Sie auf \"Neuer Kunde\" um einen anzulegen.", "noCustomers": "Keine Kunden gefunden. Klicken Sie auf \"Neuer Kunde\" um einen anzulegen.",
@@ -37,10 +37,10 @@
"showingEmpty": "Zeige 0 von 0" "showingEmpty": "Zeige 0 von 0"
}, },
"customer": { "customer": {
"back": "Zurueck", "back": "Zurück",
"customer": "Kunde", "customer": "Kunde",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"delete": "Loeschen", "delete": "Löschen",
"tabInfo": "Info", "tabInfo": "Info",
"tabDeployment": "Deployment", "tabDeployment": "Deployment",
"tabLogs": "Logs", "tabLogs": "Logs",
@@ -49,7 +49,7 @@
"company": "Firma:", "company": "Firma:",
"subdomain": "Subdomain:", "subdomain": "Subdomain:",
"email": "E-Mail:", "email": "E-Mail:",
"maxDevices": "Max. Geraete:", "maxDevices": "Max. Geräte:",
"status": "Status:", "status": "Status:",
"created": "Erstellt:", "created": "Erstellt:",
"updated": "Aktualisiert:", "updated": "Aktualisiert:",
@@ -57,18 +57,18 @@
"deploymentStatus": "Status:", "deploymentStatus": "Status:",
"relayUdpPort": "Relay UDP Port:", "relayUdpPort": "Relay UDP Port:",
"dashboardPort": "Dashboard Port:", "dashboardPort": "Dashboard Port:",
"containerPrefix": "Container-Praefix:", "containerPrefix": "Container-Präfix:",
"deployed": "Bereitgestellt:", "deployed": "Bereitgestellt:",
"setupUrl": "Setup URL:", "setupUrl": "Setup URL:",
"copy": "Kopieren", "copy": "Kopieren",
"open": "Oeffnen", "open": "Öffnen",
"netbirdLogin": "NetBird Login", "netbirdLogin": "NetBird Login",
"notAvailable": "Nicht verfuegbar", "notAvailable": "Nicht verfügbar",
"showCredentials": "Zugangsdaten anzeigen", "showCredentials": "Zugangsdaten anzeigen",
"credEmail": "E-Mail", "credEmail": "E-Mail",
"credPassword": "Passwort", "credPassword": "Passwort",
"showHide": "Anzeigen/Verbergen", "showHide": "Anzeigen/Verbergen",
"credentialsNotAvailable": "Zugangsdaten nicht verfuegbar. Der Admin muss das Setup manuell ueber die Setup URL abschliessen.", "credentialsNotAvailable": "Zugangsdaten nicht verfügbar. Der Admin muss das Setup manuell über die Setup URL abschließen.",
"start": "Starten", "start": "Starten",
"stop": "Stoppen", "stop": "Stoppen",
"restart": "Neustarten", "restart": "Neustarten",
@@ -76,11 +76,11 @@
"noDeployment": "Kein Deployment gefunden.", "noDeployment": "Kein Deployment gefunden.",
"deployNow": "Jetzt bereitstellen", "deployNow": "Jetzt bereitstellen",
"containerLogs": "Container Logs", "containerLogs": "Container Logs",
"noContainerLogs": "Keine Container-Logs verfuegbar.", "noContainerLogs": "Keine Container-Logs verfügbar.",
"noLogsLoaded": "Keine Logs geladen.", "noLogsLoaded": "Keine Logs geladen.",
"healthCheck": "Zustandspruefung", "healthCheck": "Zustandsprüfung",
"check": "Pruefen", "check": "Prüfen",
"clickCheck": "Klicken Sie auf \"Pruefen\" um eine Zustandspruefung durchzufuehren.", "clickCheck": "Klicken Sie auf \"Prüfen\" um eine Zustandsprüfung durchzuführen.",
"healthy": "Gesund", "healthy": "Gesund",
"unhealthy": "Fehlerhaft", "unhealthy": "Fehlerhaft",
"overall": "Gesamt:", "overall": "Gesamt:",
@@ -88,29 +88,8 @@
"thContainerStatus": "Status", "thContainerStatus": "Status",
"thHealth": "Zustand", "thHealth": "Zustand",
"thImage": "Image", "thImage": "Image",
"lastCheck": "Letzte Pruefung: {time}", "lastCheck": "Letzte Prüfung: {time}",
"openDashboard": "Dashboard oeffnen" "openDashboard": "Dashboard öffnen"
},
"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": { "settings": {
"title": "Systemeinstellungen", "title": "Systemeinstellungen",
@@ -120,6 +99,9 @@
"tabBranding": "Branding", "tabBranding": "Branding",
"tabUsers": "Benutzer", "tabUsers": "Benutzer",
"tabAzure": "Azure AD", "tabAzure": "Azure AD",
"tabDns": "Windows DNS",
"tabLdap": "LDAP / AD",
"tabUpdate": "Updates",
"tabSecurity": "Sicherheit", "tabSecurity": "Sicherheit",
"baseDomain": "Basis-Domain", "baseDomain": "Basis-Domain",
"baseDomainPlaceholder": "ihredomain.com", "baseDomainPlaceholder": "ihredomain.com",
@@ -128,25 +110,36 @@
"adminEmailPlaceholder": "admin@ihredomain.com", "adminEmailPlaceholder": "admin@ihredomain.com",
"dataDir": "Datenverzeichnis", "dataDir": "Datenverzeichnis",
"dataDirPlaceholder": "/opt/netbird-instances", "dataDirPlaceholder": "/opt/netbird-instances",
"dockerNetwork": "Docker Netzwerk", "dockerNetwork": "Docker-Netzwerk",
"dockerNetworkPlaceholder": "npm-network", "dockerNetworkPlaceholder": "npm-network",
"relayBasePort": "Relay Basis-Port", "relayBasePort": "Relay-Basisport",
"relayBasePortHint": "Erster UDP-Port fuer Relay. Bereich: Basis bis Basis+99", "relayBasePortHint": "Erster UDP-Port für Relay. Bereich: Basis bis Basis+99",
"dashboardBasePort": "Dashboard Basis-Port", "dashboardBasePort": "Dashboard-Basisport",
"dashboardBasePortHint": "Basis-Port fuer Kunden-Dashboards. Kunde N erhaelt Basis+N", "dashboardBasePortHint": "Basisport für Kunden-Dashboards. Kunde N erhält Basis+N",
"saveSystemSettings": "Systemeinstellungen speichern", "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.", "npmDescription": "NPM verwendet JWT-Authentifizierung. Geben Sie Ihre NPM-Zugangsdaten ein. Das System meldet sich automatisch an.",
"npmApiUrl": "NPM API URL", "npmApiUrl": "NPM API URL",
"npmApiUrlPlaceholder": "http://nginx-proxy-manager:81/api", "npmApiUrlPlaceholder": "http://nginx-proxy-manager:81/api",
"npmApiUrlHint": "http:// oder https:// - muss /api am Ende enthalten", "npmApiUrlHint": "http:// oder https:// - muss /api am Ende enthalten",
"npmLoginEmail": "NPM Login E-Mail", "npmLoginEmail": "NPM Login E-Mail",
"npmLoginEmailPlaceholder": "Leer lassen um aktuelle beizubehalten", "npmLoginEmailPlaceholder": "Leer lassen zum Beibehalten",
"npmLoginPassword": "NPM Login Passwort", "npmLoginPassword": "NPM Login Passwort",
"npmLoginPasswordPlaceholder": "Leer lassen um aktuelles beizubehalten", "npmLoginPasswordPlaceholder": "Leer lassen zum Beibehalten",
"credentialsSet": "Zugangsdaten sind gesetzt (leer lassen um aktuelle beizubehalten)", "credentialsSet": "Zugangsdaten gesetzt (leer lassen zum Beibehalten)",
"noCredentials": "Keine NPM-Zugangsdaten konfiguriert", "noCredentials": "Keine NPM-Zugangsdaten konfiguriert",
"saveNpmSettings": "NPM Einstellungen speichern", "saveNpmSettings": "NPM-Einstellungen speichern",
"testConnection": "Verbindung testen", "testConnection": "Verbindung testen",
"sslModeTitle": "SSL-Zertifikat Modus",
"sslMode": "SSL-Modus",
"sslModeLetsencrypt": "Let's Encrypt (pro Kunde)",
"sslModeWildcard": "Wildcard-Zertifikat",
"sslModeHint": "Wählen Sie ob jeder Kunde ein eigenes Let's Encrypt Zertifikat oder ein geteiltes Wildcard-Zertifikat erhält.",
"wildcardCertificate": "Wildcard-Zertifikat",
"selectCertificate": "-- Zertifikat auswählen --",
"wildcardCertHint": "Wählen Sie das Wildcard-Zertifikat (z.B. *.example.com) das in NPM hochgeladen ist.",
"noWildcardCerts": "Keine Wildcard-Zertifikate in NPM gefunden.",
"certsLoaded": "{count} Wildcard-Zertifikat(e) gefunden.",
"expiresOn": "Läuft ab",
"managementImage": "Management Image", "managementImage": "Management Image",
"managementImagePlaceholder": "netbirdio/management:latest", "managementImagePlaceholder": "netbirdio/management:latest",
"signalImage": "Signal Image", "signalImage": "Signal Image",
@@ -155,21 +148,21 @@
"relayImagePlaceholder": "netbirdio/relay:latest", "relayImagePlaceholder": "netbirdio/relay:latest",
"dashboardImage": "Dashboard Image", "dashboardImage": "Dashboard Image",
"dashboardImagePlaceholder": "netbirdio/dashboard:latest", "dashboardImagePlaceholder": "netbirdio/dashboard:latest",
"saveImageSettings": "Image Einstellungen speichern", "saveImageSettings": "Image-Einstellungen speichern",
"brandingTitle": "Branding Einstellungen", "brandingTitle": "Branding-Einstellungen",
"companyName": "Firmen- / Anwendungsname", "companyName": "Firmen- / Anwendungsname",
"companyNamePlaceholder": "NetBird MSP Appliance", "companyNamePlaceholder": "NetBird MSP Appliance",
"companyNameHint": "Wird auf der Login-Seite und in der Navbar angezeigt", "companyNameHint": "Wird auf der Anmeldeseite und in der Navigationsleiste angezeigt",
"logoPreview": "Logo-Vorschau", "logoPreview": "Logo-Vorschau",
"defaultIcon": "Standard-Icon (kein Logo hochgeladen)", "defaultIcon": "Standardsymbol (kein Logo hochgeladen)",
"uploadLogo": "Logo hochladen (PNG, JPG, SVG, max 500KB)", "uploadLogo": "Logo hochladen (PNG, JPG, SVG, max. 500 KB)",
"uploadBtn": "Hochladen", "uploadBtn": "Hochladen",
"removeLogo": "Logo entfernen", "removeLogo": "Logo entfernen",
"brandingSubtitle": "Untertitel", "brandingSubtitle": "Untertitel",
"brandingSubtitlePlaceholder": "Multi-Tenant Management Plattform", "brandingSubtitlePlaceholder": "Multi-Tenant Management Plattform",
"brandingSubtitleHint": "Wird unter dem Titel auf der Login-Seite angezeigt", "brandingSubtitleHint": "Wird unter dem Titel auf der Anmeldeseite angezeigt",
"defaultLanguage": "Standardsprache", "defaultLanguage": "Standardsprache",
"defaultLanguageHint": "Standardsprache fuer Benutzer ohne eigene Einstellung", "defaultLanguageHint": "Standardsprache für Benutzer ohne Präferenz",
"systemDefault": "Systemstandard", "systemDefault": "Systemstandard",
"saveBranding": "Branding speichern", "saveBranding": "Branding speichern",
"userManagement": "Benutzerverwaltung", "userManagement": "Benutzerverwaltung",
@@ -187,82 +180,101 @@
"tenantId": "Tenant ID", "tenantId": "Tenant ID",
"clientId": "Client ID (Anwendungs-ID)", "clientId": "Client ID (Anwendungs-ID)",
"clientSecret": "Client Secret", "clientSecret": "Client Secret",
"clientSecretPlaceholder": "Leer lassen um aktuelles beizubehalten", "clientSecretPlaceholder": "Leer lassen zum Beibehalten",
"secretSet": "Secret ist gesetzt (leer lassen um aktuelles beizubehalten)", "secretSet": "Secret gesetzt (leer lassen zum Beibehalten)",
"noSecret": "Kein Client Secret konfiguriert", "noSecret": "Kein Client-Secret konfiguriert",
"saveAzureSettings": "Azure AD Einstellungen speichern", "saveAzureSettings": "Azure AD-Einstellungen speichern",
"securityTitle": "Admin-Passwort aendern", "azureGroupId": "Erlaubte Gruppen-Objekt-ID (optional)",
"azureGroupIdHint": "Falls gesetzt, können sich nur Azure AD-Mitglieder dieser Gruppe anmelden.",
"dnsTitle": "Windows DNS Integration",
"enableDns": "Windows DNS Integration aktivieren",
"dnsDescription": "Automatisch DNS A-Records erstellen/löschen beim Bereitstellen von Kunden.",
"dnsServer": "DNS-Serveradresse",
"dnsZone": "DNS-Zone",
"dnsUsername": "Benutzername (NTLM)",
"dnsPassword": "Passwort",
"dnsRecordIp": "A-Record Ziel-IP",
"dnsRecordIpHint": "IP-Adresse, auf die Kunden-A-Records zeigen (normalerweise die NPM-Server-IP).",
"saveDnsSettings": "DNS-Einstellungen speichern",
"ldapTitle": "LDAP / Active Directory Authentifizierung",
"enableLdap": "LDAP / AD Authentifizierung aktivieren",
"ldapDescription": "Active Directory Benutzern die Anmeldung erlauben. Lokale Admin-Konten funktionieren immer als Fallback.",
"ldapServer": "LDAP-Server",
"ldapPort": "Port",
"ldapUseSsl": "SSL/TLS verwenden (LDAPS)",
"ldapBindDn": "Bind DN (Dienstkonto)",
"ldapBindPassword": "Bind-Passwort",
"ldapBaseDn": "Basis-DN",
"ldapUserFilter": "Benutzerfilter",
"ldapUserFilterHint": "Verwenden Sie {username} als Platzhalter für den Anmeldenamen.",
"ldapGroupDn": "Gruppen-DN (optional, zur Einschränkung)",
"ldapGroupDnHint": "Falls gesetzt, können sich nur Mitglieder dieser Gruppe per LDAP anmelden.",
"saveLdapSettings": "LDAP-Einstellungen speichern",
"versionTitle": "Version & Updates",
"currentVersion": "Installierte Version",
"latestVersion": "Neueste verfügbare Version",
"branch": "Branch",
"commitHash": "Commit",
"updateAvailable": "Update verfügbar",
"upToDate": "Aktuell",
"triggerUpdate": "Update starten",
"updateWarning": "Die App ist während des Rebuilds ca. 60 Sekunden nicht verfügbar.",
"confirmUpdate": "Update jetzt starten? Die Datenbank wird zuerst gesichert. Die App startet neu (~60 Sekunden Ausfallzeit).",
"gitTitle": "Git-Repository Einstellungen",
"gitRepoUrl": "Repository URL",
"gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.",
"gitBranch": "Branch",
"gitToken": "Zugriffstoken (optional)",
"saveGitSettings": "Git-Einstellungen speichern",
"leaveEmptyToKeep": "Leer lassen zum Beibehalten",
"passwordSet": "Passwort gesetzt (leer lassen zum Beibehalten)",
"noPasswordSet": "Kein Passwort konfiguriert",
"tokenSet": "Token gesetzt (leer lassen zum Beibehalten)",
"noToken": "Kein Zugriffstoken konfiguriert",
"securityTitle": "Admin-Passwort ändern",
"currentPassword": "Aktuelles Passwort", "currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort (min. 12 Zeichen)", "newPassword": "Neues Passwort (min. 12 Zeichen)",
"confirmPassword": "Neues Passwort bestaetigen", "confirmPassword": "Neues Passwort bestätigen",
"changePassword": "Passwort aendern" "changePassword": "Passwort ändern"
},
"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"
}, },
"mfa": { "mfa": {
"title": "Multi-Faktor-Authentifizierung (MFA)", "title": "Zwei-Faktor-Authentifizierung (MFA)",
"enableMfa": "MFA fuer alle lokalen Benutzer aktivieren", "enableMfa": "MFA für alle lokalen Benutzer aktivieren",
"mfaDescription": "Wenn aktiviert, muessen lokale Benutzer sich nach der Passworteingabe mit einer TOTP-Authenticator-App verifizieren. Azure AD Benutzer sind nicht betroffen.", "mfaDescription": "Bei Aktivierung müssen lokale Benutzer sich nach der Passworteingabe mit einer TOTP-Authentifikator-App verifizieren. Azure AD-Benutzer sind nicht betroffen.",
"saveMfaSettings": "MFA Einstellungen speichern", "saveMfaSettings": "MFA-Einstellungen speichern",
"yourTotpStatus": "Ihr TOTP Status", "yourTotpStatus": "Ihr TOTP-Status",
"totpActive": "Aktiv", "totpActive": "Aktiv",
"totpNotSetUp": "Nicht eingerichtet", "totpNotSetUp": "Nicht eingerichtet",
"disableMyTotp": "Mein TOTP deaktivieren", "disableMyTotp": "Mein TOTP deaktivieren",
"enterCode": "Geben Sie Ihren 6-stelligen Authenticator-Code ein", "enterCode": "Geben Sie Ihren 6-stelligen Authentifikator-Code ein",
"verify": "Verifizieren", "verify": "Bestätigen",
"backToLogin": "Zurueck zum Login", "backToLogin": "Zurück zur Anmeldung",
"scanQrCode": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App", "scanQrCode": "Scannen Sie diesen QR-Code mit Ihrer Authentifikator-App",
"orEnterManually": "Oder geben Sie diesen Schluessel manuell ein:", "orEnterManually": "Oder geben Sie diesen Schlüssel manuell ein:",
"verifyAndActivate": "Verifizieren & Aktivieren", "verifyAndActivate": "Bestätigen & Aktivieren",
"resetMfa": "MFA zuruecksetzen", "resetMfa": "MFA zurücksetzen",
"confirmResetMfa": "MFA fuer '{username}' zuruecksetzen? Der Benutzer muss seinen Authenticator beim naechsten Login neu einrichten.", "confirmResetMfa": "MFA für '{username}' zurücksetzen? Sie müssen bei der nächsten Anmeldung ihren Authentifikator neu einrichten.",
"mfaResetSuccess": "MFA fuer '{username}' zurueckgesetzt.", "mfaResetSuccess": "MFA für '{username}' zurückgesetzt.",
"mfaDisabled": "Ihr TOTP wurde deaktiviert.", "mfaDisabled": "Ihr TOTP wurde deaktiviert.",
"mfaSaved": "MFA Einstellungen gespeichert.", "mfaSaved": "MFA-Einstellungen gespeichert.",
"invalidCode": "Ungueltiger Code. Bitte versuchen Sie es erneut.", "invalidCode": "Ungültiger Code. Bitte versuchen Sie es erneut.",
"codeExpired": "Verifizierung abgelaufen. Bitte melden Sie sich erneut an." "codeExpired": "Verifizierung abgelaufen. Bitte melden Sie sich erneut an."
}, },
"common": { "common": {
"loading": "Laden...", "loading": "Laden...",
"back": "Zurueck", "back": "Zurück",
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"delete": "Loeschen", "delete": "Löschen",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"view": "Anzeigen", "view": "Ansehen",
"start": "Starten", "start": "Starten",
"stop": "Stoppen", "stop": "Stoppen",
"restart": "Neustarten", "restart": "Neustarten",
"disable": "Deaktivieren", "disable": "Deaktivieren",
"enable": "Aktivieren", "enable": "Aktivieren",
"resetPassword": "Passwort zuruecksetzen", "resetPassword": "Passwort zurücksetzen",
"open": "Oeffnen", "open": "Öffnen",
"active": "Aktiv", "active": "Aktiv",
"disabled": "Deaktiviert" "disabled": "Deaktiviert"
}, },
@@ -273,15 +285,15 @@
"serverError": "Serverfehler (HTTP {status}).", "serverError": "Serverfehler (HTTP {status}).",
"unknownError": "Ein unbekannter Fehler ist aufgetreten.", "unknownError": "Ein unbekannter Fehler ist aufgetreten.",
"uploadFailed": "Upload fehlgeschlagen.", "uploadFailed": "Upload fehlgeschlagen.",
"deleteFailed": "Loeschen fehlgeschlagen: {error}", "deleteFailed": "Löschen fehlgeschlagen: {error}",
"failedToLoadSettings": "Einstellungen konnten nicht geladen werden: {error}", "failedToLoadSettings": "Einstellungen konnten nicht geladen werden: {error}",
"failed": "Fehlgeschlagen: {error}", "failed": "Fehlgeschlagen: {error}",
"logoUploadFailed": "Logo-Upload fehlgeschlagen: {error}", "logoUploadFailed": "Logo-Upload fehlgeschlagen: {error}",
"failedToRemoveLogo": "Logo konnte nicht entfernt werden: {error}", "failedToRemoveLogo": "Logo konnte nicht entfernt werden: {error}",
"updateFailed": "Aktualisierung fehlgeschlagen: {error}", "updateFailed": "Aktualisierung fehlgeschlagen: {error}",
"passwordResetFailed": "Passwort-Zuruecksetzung fehlgeschlagen: {error}", "passwordResetFailed": "Passwort zurücksetzen fehlgeschlagen: {error}",
"selectFileFirst": "Bitte waehlen Sie zuerst eine Datei aus.", "selectFileFirst": "Bitte wählen Sie zuerst eine Datei aus.",
"passwordsDoNotMatch": "Passwoerter stimmen nicht ueberein.", "passwordsDoNotMatch": "Passwörter stimmen nicht überein.",
"failedToLoadCredentials": "Zugangsdaten konnten nicht geladen werden: {error}", "failedToLoadCredentials": "Zugangsdaten konnten nicht geladen werden: {error}",
"azureNotConfigured": "Azure AD ist nicht konfiguriert.", "azureNotConfigured": "Azure AD ist nicht konfiguriert.",
"azureLoginFailed": "Azure AD Anmeldung fehlgeschlagen: {error}", "azureLoginFailed": "Azure AD Anmeldung fehlgeschlagen: {error}",
@@ -289,20 +301,73 @@
}, },
"messages": { "messages": {
"systemSettingsSaved": "Systemeinstellungen gespeichert.", "systemSettingsSaved": "Systemeinstellungen gespeichert.",
"npmSettingsSaved": "NPM Einstellungen gespeichert.", "npmSettingsSaved": "NPM-Einstellungen gespeichert.",
"imageSettingsSaved": "Image Einstellungen gespeichert.", "imageSettingsSaved": "Image-Einstellungen gespeichert.",
"brandingNameSaved": "Branding-Einstellungen gespeichert.", "brandingNameSaved": "Branding-Einstellungen gespeichert.",
"logoUploaded": "Logo erfolgreich hochgeladen.", "logoUploaded": "Logo erfolgreich hochgeladen.",
"logoRemoved": "Logo entfernt.", "logoRemoved": "Logo entfernt.",
"azureSettingsSaved": "Azure AD Einstellungen gespeichert.", "azureSettingsSaved": "Azure AD-Einstellungen gespeichert.",
"passwordChanged": "Passwort erfolgreich geaendert.", "dnsSettingsSaved": "DNS-Einstellungen gespeichert.",
"setupUrlCopied": "Setup URL in die Zwischenablage kopiert.", "ldapSettingsSaved": "LDAP-Einstellungen gespeichert.",
"copiedToClipboard": "In die Zwischenablage kopiert.", "gitSettingsSaved": "Git-Einstellungen gespeichert.",
"updateStarted": "Update gestartet. Die App wird in Kürze neu starten.",
"passwordChanged": "Passwort erfolgreich geändert.",
"setupUrlCopied": "Setup-URL in Zwischenablage kopiert.",
"copiedToClipboard": "In Zwischenablage kopiert.",
"userCreated": "Benutzer '{username}' erstellt.", "userCreated": "Benutzer '{username}' erstellt.",
"userDeleted": "Benutzer '{username}' geloescht.", "userDeleted": "Benutzer '{username}' gelöscht.",
"passwordResetFor": "Passwort fuer '{username}' zurueckgesetzt.", "passwordResetFor": "Passwort zurückgesetzt für '{username}'.",
"newPasswordAlert": "Neues Passwort fuer '{username}':\n\n{password}\n\nBitte speichern Sie dieses Passwort jetzt. Es wird nicht erneut angezeigt.", "newPasswordAlert": "Neues Passwort für '{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.", "confirmDeleteUser": "Benutzer '{username}' löschen? Dies kann nicht rückgängig gemacht werden.",
"confirmResetPassword": "Passwort fuer '{username}' zuruecksetzen? Ein neues zufaelliges Passwort wird generiert." "confirmResetPassword": "Passwort für '{username}' zurücksetzen? Ein neues zufälliges Passwort wird generiert."
},
"userModal": {
"title": "Neuer Benutzer",
"usernameLabel": "Benutzername *",
"passwordLabel": "Passwort * (min. 8 Zeichen)",
"emailLabel": "E-Mail",
"languageLabel": "Standardsprache",
"cancel": "Abbrechen",
"createUser": "Benutzer erstellen"
},
"customerModal": {
"newCustomer": "Neuer Kunde",
"editCustomer": "Kunde bearbeiten",
"nameLabel": "Name *",
"companyLabel": "Firma",
"subdomainLabel": "Subdomain *",
"subdomainHint": "Kleinbuchstaben, alphanumerisch + Bindestriche",
"emailLabel": "E-Mail *",
"maxDevicesLabel": "Max. Geräte",
"notesLabel": "Notizen",
"cancel": "Abbrechen",
"saveAndDeploy": "Speichern & Bereitstellen",
"saveChanges": "Änderungen speichern"
},
"deleteModal": {
"title": "Löschen bestätigen",
"confirmText": "Möchten Sie den Kunden wirklich löschen:",
"warning": "Alle Container, NPM-Einträge und Daten werden entfernt. Diese Aktion kann nicht rückgängig gemacht werden.",
"cancel": "Abbrechen",
"delete": "Löschen"
},
"monitoring": {
"title": "System-Monitoring",
"refresh": "Aktualisieren",
"hostResources": "Host-Ressourcen",
"hostname": "Hostname",
"cpu": "CPU ({count} Kerne)",
"memory": "Arbeitsspeicher ({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."
} }
} }

View File

@@ -120,6 +120,9 @@
"tabBranding": "Branding", "tabBranding": "Branding",
"tabUsers": "Users", "tabUsers": "Users",
"tabAzure": "Azure AD", "tabAzure": "Azure AD",
"tabDns": "Windows DNS",
"tabLdap": "LDAP / AD",
"tabUpdate": "Updates",
"tabSecurity": "Security", "tabSecurity": "Security",
"baseDomain": "Base Domain", "baseDomain": "Base Domain",
"baseDomainPlaceholder": "yourdomain.com", "baseDomainPlaceholder": "yourdomain.com",
@@ -147,6 +150,17 @@
"noCredentials": "No NPM credentials configured", "noCredentials": "No NPM credentials configured",
"saveNpmSettings": "Save NPM Settings", "saveNpmSettings": "Save NPM Settings",
"testConnection": "Test Connection", "testConnection": "Test Connection",
"sslModeTitle": "SSL Certificate Mode",
"sslMode": "SSL Mode",
"sslModeLetsencrypt": "Let's Encrypt (per customer)",
"sslModeWildcard": "Wildcard Certificate",
"sslModeHint": "Choose whether each customer gets an individual Let's Encrypt certificate or uses a shared wildcard certificate.",
"wildcardCertificate": "Wildcard Certificate",
"selectCertificate": "-- Select certificate --",
"wildcardCertHint": "Select the wildcard certificate (e.g. *.example.com) already uploaded in NPM.",
"noWildcardCerts": "No wildcard certificates found in NPM.",
"certsLoaded": "{count} wildcard certificate(s) found.",
"expiresOn": "Expires",
"managementImage": "Management Image", "managementImage": "Management Image",
"managementImagePlaceholder": "netbirdio/management:latest", "managementImagePlaceholder": "netbirdio/management:latest",
"signalImage": "Signal Image", "signalImage": "Signal Image",
@@ -191,6 +205,53 @@
"secretSet": "Secret is set (leave empty to keep current)", "secretSet": "Secret is set (leave empty to keep current)",
"noSecret": "No client secret configured", "noSecret": "No client secret configured",
"saveAzureSettings": "Save Azure AD Settings", "saveAzureSettings": "Save Azure AD Settings",
"azureGroupId": "Allowed Group Object ID (optional)",
"azureGroupIdHint": "If set, only Azure AD members of this group can log in.",
"dnsTitle": "Windows DNS Integration",
"enableDns": "Enable Windows DNS Integration",
"dnsDescription": "Automatically create/delete DNS A-records when deploying customers.",
"dnsServer": "DNS Server Address",
"dnsZone": "DNS Zone",
"dnsUsername": "Username (NTLM)",
"dnsPassword": "Password",
"dnsRecordIp": "A-Record Target IP",
"dnsRecordIpHint": "IP address that customer A-records will point to (usually your NPM server IP).",
"saveDnsSettings": "Save DNS Settings",
"ldapTitle": "LDAP / Active Directory Authentication",
"enableLdap": "Enable LDAP / AD Authentication",
"ldapDescription": "Allow Active Directory users to log in. Local admin accounts always work as fallback.",
"ldapServer": "LDAP Server",
"ldapPort": "Port",
"ldapUseSsl": "Use SSL/TLS (LDAPS)",
"ldapBindDn": "Bind DN (Service Account)",
"ldapBindPassword": "Bind Password",
"ldapBaseDn": "Base DN",
"ldapUserFilter": "User Filter",
"ldapUserFilterHint": "Use {username} as placeholder for the login name.",
"ldapGroupDn": "Group Restriction DN (optional)",
"ldapGroupDnHint": "If set, only members of this group can log in via LDAP.",
"saveLdapSettings": "Save LDAP Settings",
"versionTitle": "Version & Updates",
"currentVersion": "Installed Version",
"latestVersion": "Latest Available",
"branch": "Branch",
"commitHash": "Commit",
"updateAvailable": "Update Available",
"upToDate": "Up to date",
"triggerUpdate": "Start Update",
"updateWarning": "The app will be unavailable for ~60 seconds during rebuild.",
"confirmUpdate": "Start the update now? The database will be backed up first. The app will restart (~60 seconds downtime).",
"gitTitle": "Git Repository Settings",
"gitRepoUrl": "Repository URL",
"gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.",
"gitBranch": "Branch",
"gitToken": "Access Token (optional)",
"saveGitSettings": "Save Git Settings",
"leaveEmptyToKeep": "Leave empty to keep current",
"passwordSet": "Password is set (leave empty to keep current)",
"noPasswordSet": "No password configured",
"tokenSet": "Token is set (leave empty to keep current)",
"noToken": "No access token configured",
"securityTitle": "Change Admin Password", "securityTitle": "Change Admin Password",
"currentPassword": "Current Password", "currentPassword": "Current Password",
"newPassword": "New Password (min 12 chars)", "newPassword": "New Password (min 12 chars)",
@@ -295,6 +356,10 @@
"logoUploaded": "Logo uploaded successfully.", "logoUploaded": "Logo uploaded successfully.",
"logoRemoved": "Logo removed.", "logoRemoved": "Logo removed.",
"azureSettingsSaved": "Azure AD settings saved.", "azureSettingsSaved": "Azure AD settings saved.",
"dnsSettingsSaved": "DNS settings saved.",
"ldapSettingsSaved": "LDAP settings saved.",
"gitSettingsSaved": "Git settings saved.",
"updateStarted": "Update started. The app will restart shortly.",
"passwordChanged": "Password changed successfully.", "passwordChanged": "Password changed successfully.",
"setupUrlCopied": "Setup URL copied to clipboard.", "setupUrlCopied": "Setup URL copied to clipboard.",
"copiedToClipboard": "Copied to clipboard.", "copiedToClipboard": "Copied to clipboard.",

72
update.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
# update.sh — SSH-based manual update for NetBird MSP Appliance
# Usage: bash update.sh [branch]
# Run from the host as root or the user that owns the install directory.
set -euo pipefail
INSTALL_DIR="/opt/netbird-msp"
BRANCH="${1:-main}"
cd "$INSTALL_DIR"
echo "=== NetBird MSP Appliance Update ==="
echo "Install dir : $INSTALL_DIR"
echo "Branch : $BRANCH"
echo "Current : $(git log --oneline -1 2>/dev/null || echo 'unknown')"
echo ""
# --- Backup database ---
BACKUP_DIR="$INSTALL_DIR/backups"
mkdir -p "$BACKUP_DIR"
DB_FILE="$INSTALL_DIR/data/netbird_msp.db"
if [ -f "$DB_FILE" ]; then
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/netbird_msp_${TIMESTAMP}.db"
cp "$DB_FILE" "$BACKUP_FILE"
echo "✓ Database backed up to $BACKUP_FILE"
else
echo "⚠ No database file found at $DB_FILE — skipping backup"
fi
# --- Pull latest code ---
git fetch origin "$BRANCH"
git checkout "$BRANCH"
git pull origin "$BRANCH"
echo "✓ Code updated to: $(git log --oneline -1)"
# --- Export build args ---
export GIT_COMMIT=$(git rev-parse HEAD)
export GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
export GIT_COMMIT_DATE=$(git log -1 --format=%cI)
export GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "unknown")
echo ""
echo "Building with:"
echo " GIT_TAG = $GIT_TAG"
echo " GIT_COMMIT = $GIT_COMMIT"
echo " GIT_BRANCH = $GIT_BRANCH"
echo " GIT_COMMIT_DATE = $GIT_COMMIT_DATE"
echo ""
# --- Rebuild and restart ---
docker compose up --build -d
echo "✓ Container rebuilt and restarted"
# --- Health check ---
echo "Waiting for app to start..."
for i in $(seq 1 12); do
sleep 5
if curl -sf http://localhost:8000/api/health > /dev/null 2>&1; then
echo ""
echo "✓ App is healthy!"
echo "=== Update complete ==="
echo "New version: $(git log --oneline -1)"
exit 0
fi
printf " Waiting... (%ds)\n" "$((i * 5))"
done
echo ""
echo "⚠ Health check timed out after 60s."
echo " Check logs with: docker logs netbird-msp-appliance"
exit 1