Compare commits
17 Commits
v1.0.0-alp
...
alpha-1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fe68cc6df | |||
| 314393d61a | |||
| a9fc549cec | |||
| 41bbd6676b | |||
| fc9589b6f9 | |||
| 6d2251bcf5 | |||
| fd79065519 | |||
| e9e2e67991 | |||
| f92cdfbbef | |||
| 7793ca3666 | |||
| bc9aa6624f | |||
| 1bbe4904a7 | |||
| 0ac15e4db9 | |||
| c00b52df83 | |||
| 72bad11129 | |||
| 40456bfaba | |||
| c7fc4758e3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -65,7 +65,6 @@ htmlcov/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
CLAUDE_CODE_SPEC.md
|
||||
PROJECT_SUMMARY.md
|
||||
QUICKSTART.md
|
||||
VS_CODE_SETUP.md
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -13,7 +13,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& 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 \
|
||||
&& 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/*
|
||||
|
||||
# Set working directory
|
||||
@@ -28,6 +28,16 @@ COPY app/ ./app/
|
||||
COPY templates/ ./templates/
|
||||
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
|
||||
RUN mkdir -p /app/data /app/logs /app/backups
|
||||
|
||||
|
||||
459
ProjectAISpec.md
Normal file
459
ProjectAISpec.md
Normal 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!
|
||||
25
README.md
25
README.md
@@ -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
|
||||
- **One-Click Deployment** — Deploy new customer instances in under 2 minutes
|
||||
- **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
|
||||
|
||||
### Dashboard
|
||||
@@ -269,7 +270,8 @@ Available under **Settings** in the web interface:
|
||||
|
||||
| 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 |
|
||||
| **Users** | Create/edit/delete admin users, per-user language preference, MFA reset |
|
||||
| **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 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
|
||||
@@ -376,6 +398,7 @@ GET /api/customers/{id}/logs # Get container logs
|
||||
GET /api/customers/{id}/health # Health check
|
||||
|
||||
GET /api/settings/branding # Get branding (public, no auth)
|
||||
GET /api/settings/npm-certificates # List NPM SSL certificates
|
||||
PUT /api/settings # Update system settings
|
||||
GET /api/users # List users
|
||||
POST /api/users # Create user
|
||||
|
||||
@@ -51,6 +51,22 @@ def init_db() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_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:
|
||||
"""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"),
|
||||
("users", "totp_secret_encrypted", "TEXT"),
|
||||
("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:
|
||||
if not _has_column(table, column):
|
||||
|
||||
5
app/limiter.py
Normal file
5
app/limiter.py
Normal 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)
|
||||
43
app/main.py
43
app/main.py
@@ -3,12 +3,15 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
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.limiter import limiter
|
||||
from app.routers import auth, customers, deployments, monitoring, settings, users
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -21,6 +24,9 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application
|
||||
# ---------------------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -33,15 +39,40 @@ app = FastAPI(
|
||||
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(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_origins=_allowed_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -161,11 +161,44 @@ class SystemConfig(Base):
|
||||
)
|
||||
branding_logo_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
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)
|
||||
azure_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
azure_tenant_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
azure_client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
azure_client_secret_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
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)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
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_logo_path": self.branding_logo_path,
|
||||
"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),
|
||||
"azure_enabled": bool(self.azure_enabled),
|
||||
"azure_tenant_id": self.azure_tenant_id or "",
|
||||
"azure_client_id": self.azure_client_id or "",
|
||||
"azure_client_secret_set": bool(self.azure_client_secret_encrypted),
|
||||
"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,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@ import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import create_access_token, create_mfa_token, get_current_user, verify_mfa_token
|
||||
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 (
|
||||
decrypt_value,
|
||||
encrypt_value,
|
||||
@@ -27,26 +29,102 @@ from app.utils.validators import ChangePasswordRequest, LoginRequest, MfaTokenRe
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
from app.limiter import limiter
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
||||
"""Authenticate with username/password. May require MFA as a second step."""
|
||||
user = db.query(User).filter(User.username == payload.username).first()
|
||||
if not user or not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password.",
|
||||
)
|
||||
@limiter.limit("10/minute")
|
||||
async def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)):
|
||||
"""Authenticate with username/password. May require MFA as a second step.
|
||||
|
||||
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(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
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":
|
||||
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||
if config and getattr(config, "mfa_enabled", False):
|
||||
sys_config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||
if sys_config and getattr(sys_config, "mfa_enabled", False):
|
||||
mfa_token = create_mfa_token(user.username)
|
||||
return {
|
||||
"mfa_required": True,
|
||||
@@ -55,7 +133,7 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
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 {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
@@ -129,8 +207,12 @@ async def mfa_setup_complete(payload: MfaVerifyRequest, db: Session = Depends(ge
|
||||
|
||||
|
||||
@router.post("/mfa/verify")
|
||||
async def mfa_verify(payload: MfaVerifyRequest, db: Session = Depends(get_db)):
|
||||
"""Verify a TOTP code for users who already have MFA set up."""
|
||||
@limiter.limit("10/minute")
|
||||
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)
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
@@ -262,17 +344,18 @@ async def azure_callback(
|
||||
|
||||
try:
|
||||
import msal
|
||||
import httpx as _httpx
|
||||
|
||||
client_secret = decrypt_value(config.azure_client_secret_encrypted)
|
||||
authority = f"https://login.microsoftonline.com/{config.azure_tenant_id}"
|
||||
|
||||
app = msal.ConfidentialClientApplication(
|
||||
msal_app = msal.ConfidentialClientApplication(
|
||||
config.azure_client_id,
|
||||
authority=authority,
|
||||
client_credential=client_secret,
|
||||
)
|
||||
|
||||
result = app.acquire_token_by_authorization_code(
|
||||
result = msal_app.acquire_token_by_authorization_code(
|
||||
payload.code,
|
||||
scopes=["User.Read"],
|
||||
redirect_uri=payload.redirect_uri,
|
||||
@@ -287,7 +370,8 @@ async def azure_callback(
|
||||
|
||||
id_token_claims = result.get("id_token_claims", {})
|
||||
email = id_token_claims.get("preferred_username") or id_token_claims.get("email", "")
|
||||
display_name = id_token_claims.get("name", email)
|
||||
display_name = id_token_claims.get("name", email) # noqa: F841
|
||||
user_access_token = result.get("access_token", "")
|
||||
|
||||
if not email:
|
||||
raise HTTPException(
|
||||
@@ -295,6 +379,54 @@ async def azure_callback(
|
||||
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
|
||||
user = db.query(User).filter(User.username == email).first()
|
||||
if not user:
|
||||
@@ -303,13 +435,13 @@ async def azure_callback(
|
||||
password_hash=hash_password(secrets.token_urlsafe(32)),
|
||||
email=email,
|
||||
is_active=True,
|
||||
role="admin",
|
||||
role="viewer", # New Azure users start as viewer; promote manually
|
||||
auth_provider="azure",
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -326,9 +458,9 @@ async def azure_callback(
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
logger.exception("Azure AD authentication error")
|
||||
raise HTTPException(
|
||||
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.",
|
||||
)
|
||||
|
||||
@@ -211,12 +211,14 @@ async def update_customer(
|
||||
@router.delete("/{customer_id}")
|
||||
async def delete_customer(
|
||||
customer_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete a customer and clean up all resources.
|
||||
|
||||
Removes containers, NPM proxy, instance directory, and database records.
|
||||
Cleanup runs in background so the response returns immediately.
|
||||
|
||||
Args:
|
||||
customer_id: Customer ID.
|
||||
@@ -231,15 +233,23 @@ async def delete_customer(
|
||||
detail="Customer not found.",
|
||||
)
|
||||
|
||||
# Undeploy first (containers, NPM, files)
|
||||
try:
|
||||
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)
|
||||
# Mark as deleting immediately so UI reflects the state
|
||||
customer.status = "inactive"
|
||||
db.commit()
|
||||
|
||||
logger.info("Customer %d deleted by %s.", customer_id, current_user.username)
|
||||
return {"message": f"Customer {customer_id} deleted successfully."}
|
||||
async def _delete_in_background(cid: int) -> None:
|
||||
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."}
|
||||
|
||||
@@ -72,7 +72,7 @@ async def start_customer(
|
||||
Result dict.
|
||||
"""
|
||||
_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"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -96,7 +96,7 @@ async def stop_customer(
|
||||
Result dict.
|
||||
"""
|
||||
_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"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -120,7 +120,7 @@ async def restart_customer(
|
||||
Result dict.
|
||||
"""
|
||||
_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"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -15,8 +15,8 @@ from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models import SystemConfig, User
|
||||
from app.services import npm_service
|
||||
from app.utils.config import get_system_config
|
||||
from app.services import dns_service, ldap_service, npm_service, update_service
|
||||
from app.utils.config import DATABASE_PATH, get_system_config
|
||||
from app.utils.security import encrypt_value
|
||||
from app.utils.validators import SystemConfigUpdate
|
||||
|
||||
@@ -86,6 +86,18 @@ async def update_settings(
|
||||
raw_secret = update_data.pop("azure_client_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():
|
||||
if hasattr(row, field):
|
||||
setattr(row, field, value)
|
||||
@@ -129,6 +141,99 @@ async def test_npm(
|
||||
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")
|
||||
async def get_branding(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns branding info for the login page (no auth required)."""
|
||||
@@ -209,3 +314,61 @@ async def delete_logo(
|
||||
db.commit()
|
||||
|
||||
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
|
||||
|
||||
@@ -33,6 +33,12 @@ async def create_user(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""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()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
@@ -120,7 +126,7 @@ async def reset_password(
|
||||
if user.auth_provider != "local":
|
||||
raise HTTPException(
|
||||
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)
|
||||
@@ -145,7 +151,7 @@ async def reset_mfa(
|
||||
if user.auth_provider != "local":
|
||||
raise HTTPException(
|
||||
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
|
||||
|
||||
153
app/services/dns_service.py
Normal file
153
app/services/dns_service.py
Normal 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}"}
|
||||
@@ -5,6 +5,7 @@ per-customer Docker Compose stacks. Also provides log retrieval and
|
||||
container health/status information.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
@@ -17,6 +18,15 @@ from docker.errors import DockerException, NotFound
|
||||
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:
|
||||
"""Return a Docker client connected via the Unix socket.
|
||||
|
||||
@@ -26,7 +36,7 @@ def _get_client() -> docker.DockerClient:
|
||||
return docker.from_env()
|
||||
|
||||
|
||||
def compose_up(
|
||||
async def compose_up(
|
||||
instance_dir: str,
|
||||
project_name: str,
|
||||
services: Optional[list[str]] = None,
|
||||
@@ -63,7 +73,7 @@ def compose_up(
|
||||
cmd.extend(services)
|
||||
|
||||
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:
|
||||
logger.error("docker compose up failed: %s", result.stderr)
|
||||
@@ -74,7 +84,7 @@ def compose_up(
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -96,14 +106,14 @@ def compose_down(instance_dir: str, project_name: str, remove_volumes: bool = Fa
|
||||
cmd.append("-v")
|
||||
|
||||
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:
|
||||
logger.warning("docker compose down returned non-zero: %s", result.stderr)
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -121,11 +131,11 @@ def compose_stop(instance_dir: str, project_name: str) -> bool:
|
||||
"stop",
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -143,11 +153,11 @@ def compose_start(instance_dir: str, project_name: str) -> bool:
|
||||
"start",
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -165,7 +175,7 @@ def compose_restart(instance_dir: str, project_name: str) -> bool:
|
||||
"restart",
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
|
||||
180
app/services/ldap_service.py
Normal file
180
app/services/ldap_service.py
Normal 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,
|
||||
)
|
||||
@@ -30,7 +30,7 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.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
|
||||
if existing_deployment:
|
||||
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",
|
||||
"Stopped existing containers for re-deployment.")
|
||||
except Exception as exc:
|
||||
logger.warning("Could not stop existing containers: %s", exc)
|
||||
|
||||
# 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.")
|
||||
|
||||
# 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_port=dashboard_port,
|
||||
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")
|
||||
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.",
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
try:
|
||||
docker_service.compose_down(
|
||||
await docker_service.compose_down(
|
||||
instance_dir or os.path.join(config.data_dir, f"kunde{customer_id}"),
|
||||
container_prefix,
|
||||
remove_volumes=True,
|
||||
@@ -412,7 +427,7 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||
|
||||
# Stop and remove containers
|
||||
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.")
|
||||
except Exception as 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:
|
||||
_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
|
||||
if os.path.isdir(instance_dir):
|
||||
try:
|
||||
@@ -455,7 +481,7 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||
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."""
|
||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
||||
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."}
|
||||
|
||||
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:
|
||||
deployment.deployment_status = "stopped"
|
||||
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}
|
||||
|
||||
|
||||
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."""
|
||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
||||
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."}
|
||||
|
||||
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:
|
||||
deployment.deployment_status = "running"
|
||||
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}
|
||||
|
||||
|
||||
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."""
|
||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
||||
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."}
|
||||
|
||||
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:
|
||||
deployment.deployment_status = "running"
|
||||
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||
|
||||
@@ -14,6 +14,7 @@ Also manages NPM streams for STUN/TURN relay UDP ports.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -41,7 +42,17 @@ def _get_forward_host() -> str:
|
||||
logger.info("Using HOST_IP from environment: %s", 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"
|
||||
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
|
||||
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(
|
||||
client: httpx.AsyncClient, api_url: str, headers: dict, domain: str
|
||||
) -> int | None:
|
||||
@@ -169,6 +219,8 @@ async def create_proxy_host(
|
||||
forward_host: str,
|
||||
forward_port: int = 80,
|
||||
admin_email: str = "",
|
||||
ssl_mode: str = "letsencrypt",
|
||||
wildcard_cert_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""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}
|
||||
|
||||
# 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}
|
||||
except RuntimeError as exc:
|
||||
@@ -283,13 +338,14 @@ async def _request_ssl(
|
||||
proxy_id: int,
|
||||
domain: str,
|
||||
admin_email: str,
|
||||
ssl_mode: str = "letsencrypt",
|
||||
wildcard_cert_id: int | None = None,
|
||||
) -> 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:
|
||||
1. Create LE certificate via NPM API (HTTP-01 validation, up to 120s)
|
||||
2. Assign certificate to the proxy host
|
||||
3. Enable ssl_forced + hsts on the proxy host
|
||||
Supports two modes:
|
||||
- ``letsencrypt``: Create a per-domain LE certificate (HTTP-01 validation).
|
||||
- ``wildcard``: Assign a pre-existing wildcard certificate from NPM.
|
||||
|
||||
Args:
|
||||
client: httpx client (already authenticated).
|
||||
@@ -298,10 +354,49 @@ async def _request_ssl(
|
||||
proxy_id: The proxy host ID.
|
||||
domain: The domain to certify.
|
||||
admin_email: Contact email for LE.
|
||||
ssl_mode: ``"letsencrypt"`` or ``"wildcard"``.
|
||||
wildcard_cert_id: NPM certificate ID for wildcard mode.
|
||||
|
||||
Returns:
|
||||
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:
|
||||
logger.warning("No admin email set — skipping SSL certificate for %s", domain)
|
||||
return False
|
||||
|
||||
347
app/services/update_service.py
Normal file
347
app/services/update_service.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""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. Two-phase rebuild: Build image first, then swap container.
|
||||
# The swap will kill this process (we ARE the container), so we must
|
||||
# ensure the compose-up runs detached on the Docker host via a wrapper.
|
||||
log_path = Path(BACKUP_DIR) / "update_rebuild.log"
|
||||
|
||||
# Phase A — build the new image (does NOT stop anything)
|
||||
build_cmd = [
|
||||
"docker", "compose",
|
||||
"-p", "netbirdmsp-appliance",
|
||||
"-f", f"{SOURCE_DIR}/docker-compose.yml",
|
||||
"build", "--no-cache",
|
||||
"netbird-msp-appliance",
|
||||
]
|
||||
logger.info("Phase A: building new image …")
|
||||
try:
|
||||
build_result = subprocess.run(
|
||||
build_cmd,
|
||||
capture_output=True, text=True,
|
||||
timeout=600,
|
||||
env=build_env,
|
||||
)
|
||||
with open(log_path, "w") as f:
|
||||
f.write(build_result.stdout)
|
||||
f.write(build_result.stderr)
|
||||
if build_result.returncode != 0:
|
||||
logger.error("Image build failed: %s", build_result.stderr[:500])
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Image build failed: {build_result.stderr[:300]}",
|
||||
"backup": backup_path,
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "message": "Image build timed out after 600s.", "backup": backup_path}
|
||||
|
||||
logger.info("Phase A complete — image built successfully.")
|
||||
|
||||
# Phase B — swap the container using a helper container.
|
||||
# When compose recreates our container, ALL processes inside die (PID namespace
|
||||
# is destroyed). So we launch a *separate* helper container via 'docker run -d'
|
||||
# that has access to the Docker socket and runs 'docker compose up -d'.
|
||||
# This helper lives outside our container and survives our restart.
|
||||
|
||||
# Discover the host-side path of /app-source (docker volumes use host paths)
|
||||
try:
|
||||
inspect_result = subprocess.run(
|
||||
["docker", "inspect", "netbird-msp-appliance",
|
||||
"--format", '{{range .Mounts}}{{if eq .Destination "/app-source"}}{{.Source}}{{end}}{{end}}'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
host_source_dir = inspect_result.stdout.strip()
|
||||
if not host_source_dir:
|
||||
raise ValueError("Could not find /app-source mount")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to discover host source path: %s", exc)
|
||||
return {"ok": False, "message": f"Could not find host source path: {exc}", "backup": backup_path}
|
||||
|
||||
logger.info("Host source directory: %s", host_source_dir)
|
||||
|
||||
env_flags = []
|
||||
for key in ("GIT_TAG", "GIT_COMMIT", "GIT_BRANCH", "GIT_COMMIT_DATE"):
|
||||
val = build_env.get(key, "unknown")
|
||||
env_flags.extend(["-e", f"{key}={val}"])
|
||||
|
||||
# Use the same image we're already running (it has docker CLI + compose plugin)
|
||||
own_image = "netbirdmsp-appliance-netbird-msp-appliance:latest"
|
||||
|
||||
helper_cmd = [
|
||||
"docker", "run", "--rm", "-d",
|
||||
"--name", "msp-updater",
|
||||
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
||||
"-v", f"{host_source_dir}:{host_source_dir}:ro",
|
||||
*env_flags,
|
||||
own_image,
|
||||
"sh", "-c",
|
||||
(
|
||||
"sleep 3 && "
|
||||
"docker compose -p netbirdmsp-appliance "
|
||||
f"-f {host_source_dir}/docker-compose.yml "
|
||||
"up --no-deps -d netbird-msp-appliance"
|
||||
),
|
||||
]
|
||||
try:
|
||||
# Remove stale updater container if any
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", "msp-updater"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
result = subprocess.run(
|
||||
helper_cmd,
|
||||
capture_output=True, text=True,
|
||||
timeout=30,
|
||||
env=build_env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("Failed to start updater container: %s", result.stderr.strip())
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Update-Container konnte nicht gestartet werden: {result.stderr.strip()[:200]}",
|
||||
"backup": backup_path,
|
||||
}
|
||||
logger.info("Phase B: updater container started — this container will restart in ~5s.")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to launch updater: %s", exc)
|
||||
return {"ok": False, "message": f"Updater launch failed: {exc}", "backup": backup_path}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -31,10 +31,59 @@ class AppConfig:
|
||||
docker_network: str
|
||||
relay_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)
|
||||
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")
|
||||
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
|
||||
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)
|
||||
except Exception:
|
||||
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(
|
||||
base_domain=row.base_domain,
|
||||
@@ -79,4 +140,24 @@ def get_system_config(db: Session) -> Optional[AppConfig]:
|
||||
docker_network=row.docker_network,
|
||||
relay_base_port=row.relay_base_port,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -126,11 +126,49 @@ class SystemConfigUpdate(BaseModel):
|
||||
branding_name: Optional[str] = Field(None, max_length=255)
|
||||
branding_subtitle: Optional[str] = Field(None, max_length=255)
|
||||
default_language: Optional[str] = Field(None, max_length=10)
|
||||
ssl_mode: Optional[str] = Field(None, max_length=20)
|
||||
wildcard_cert_id: Optional[int] = Field(None, ge=0)
|
||||
mfa_enabled: Optional[bool] = None
|
||||
azure_enabled: Optional[bool] = None
|
||||
azure_tenant_id: Optional[str] = Field(None, max_length=255)
|
||||
azure_client_id: Optional[str] = Field(None, max_length=255)
|
||||
azure_client_secret: Optional[str] = None # encrypted before storage
|
||||
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")
|
||||
@classmethod
|
||||
|
||||
8
containers.txt
Normal file
8
containers.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
f9fa39b8080d_netbird-msp-appliance Created netbirdmsp-appliance-netbird-msp-appliance
|
||||
netbird-msp-appliance Exited (0) 2 minutes ago 345ba59d123e
|
||||
netbird-kunde1-caddy Up About an hour caddy:2-alpine
|
||||
netbird-kunde1-signal Up About an hour netbirdio/signal:latest
|
||||
netbird-kunde1-dashboard Up About an hour netbirdio/dashboard:latest
|
||||
netbird-kunde1-relay Up About an hour netbirdio/relay:latest
|
||||
netbird-kunde1-management Up About an hour netbirdio/management:latest
|
||||
docker-socket-proxy Up About an hour tecnativa/docker-socket-proxy:latest
|
||||
@@ -1,16 +1,67 @@
|
||||
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:
|
||||
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
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- label:disable
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
- docker-socket-proxy
|
||||
ports:
|
||||
- "${WEB_UI_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./backups:/app/backups
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${DATA_DIR:-/opt/netbird-instances}:${DATA_DIR:-/opt/netbird-instances}
|
||||
- ./data:/app/data:z
|
||||
- ./logs:/app/logs:z
|
||||
- ./backups:/app/backups:z
|
||||
- /var/run/docker.sock:/var/run/docker.sock:z
|
||||
- ${DATA_DIR:-/opt/netbird-instances}:${DATA_DIR:-/opt/netbird-instances}:z
|
||||
- .:/app-source:z
|
||||
environment:
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- DATABASE_PATH=/app/data/netbird_msp.db
|
||||
@@ -21,7 +72,7 @@ services:
|
||||
networks:
|
||||
- npm-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:8000/api/health" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -20,6 +20,9 @@ pyyaml==6.0.1
|
||||
msal==1.28.0
|
||||
pyotp==2.9.0
|
||||
qrcode[pil]==7.4.2
|
||||
slowapi==0.1.9
|
||||
pywinrm>=0.4.3
|
||||
ldap3>=2.9.1
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.23.2
|
||||
pytest-httpx==0.28.0
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<div id="login-logo"><i class="bi bi-hdd-network fs-1 text-primary"></i></div>
|
||||
<h3 class="mt-2" id="login-title">NetBird MSP Appliance</h3>
|
||||
<p class="text-muted" id="login-subtitle" data-i18n="login.subtitle">Multi-Tenant Management Platform</p>
|
||||
<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 id="login-error" class="alert alert-danger d-none"></div>
|
||||
<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-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-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>
|
||||
</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>
|
||||
</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 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>
|
||||
@@ -536,6 +566,11 @@
|
||||
</div>
|
||||
<div class="form-text" id="azure-secret-status"></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 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>
|
||||
@@ -545,6 +580,171 @@
|
||||
</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 & 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 -->
|
||||
<div class="tab-pane fade" id="settings-security">
|
||||
<!-- MFA Settings -->
|
||||
|
||||
261
static/js/app.js
261
static/js/app.js
@@ -816,6 +816,14 @@ async function loadSettings() {
|
||||
document.getElementById('cfg-dashboard-base-port').value = cfg.dashboard_base_port || 9000;
|
||||
document.getElementById('cfg-npm-api-url').value = cfg.npm_api_url || '';
|
||||
document.getElementById('npm-credentials-status').textContent = cfg.npm_credentials_set ? 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-signal-image').value = cfg.netbird_signal_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-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('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) {
|
||||
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;
|
||||
if (email) payload.npm_api_email = email;
|
||||
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 {
|
||||
await api('PUT', '/settings/system', payload);
|
||||
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
|
||||
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1129,6 +1389,7 @@ document.getElementById('settings-azure-form').addEventListener('submit', async
|
||||
azure_enabled: document.getElementById('cfg-azure-enabled').checked,
|
||||
azure_tenant_id: document.getElementById('cfg-azure-tenant').value || null,
|
||||
azure_client_id: document.getElementById('cfg-azure-client-id').value || null,
|
||||
azure_allowed_group_id: document.getElementById('cfg-azure-group-id').value || null,
|
||||
};
|
||||
const secret = document.getElementById('cfg-azure-client-secret').value;
|
||||
if (secret) payload.azure_client_secret = secret;
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"thSubdomain": "Subdomain",
|
||||
"thStatus": "Status",
|
||||
"thDashboard": "Dashboard",
|
||||
"thDevices": "Geraete",
|
||||
"thDevices": "Geräte",
|
||||
"thCreated": "Erstellt",
|
||||
"thActions": "Aktionen",
|
||||
"noCustomers": "Keine Kunden gefunden. Klicken Sie auf \"Neuer Kunde\" um einen anzulegen.",
|
||||
@@ -37,10 +37,10 @@
|
||||
"showingEmpty": "Zeige 0 von 0"
|
||||
},
|
||||
"customer": {
|
||||
"back": "Zurueck",
|
||||
"back": "Zurück",
|
||||
"customer": "Kunde",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Loeschen",
|
||||
"delete": "Löschen",
|
||||
"tabInfo": "Info",
|
||||
"tabDeployment": "Deployment",
|
||||
"tabLogs": "Logs",
|
||||
@@ -49,7 +49,7 @@
|
||||
"company": "Firma:",
|
||||
"subdomain": "Subdomain:",
|
||||
"email": "E-Mail:",
|
||||
"maxDevices": "Max. Geraete:",
|
||||
"maxDevices": "Max. Geräte:",
|
||||
"status": "Status:",
|
||||
"created": "Erstellt:",
|
||||
"updated": "Aktualisiert:",
|
||||
@@ -57,18 +57,18 @@
|
||||
"deploymentStatus": "Status:",
|
||||
"relayUdpPort": "Relay UDP Port:",
|
||||
"dashboardPort": "Dashboard Port:",
|
||||
"containerPrefix": "Container-Praefix:",
|
||||
"containerPrefix": "Container-Präfix:",
|
||||
"deployed": "Bereitgestellt:",
|
||||
"setupUrl": "Setup URL:",
|
||||
"copy": "Kopieren",
|
||||
"open": "Oeffnen",
|
||||
"open": "Öffnen",
|
||||
"netbirdLogin": "NetBird Login",
|
||||
"notAvailable": "Nicht verfuegbar",
|
||||
"notAvailable": "Nicht verfügbar",
|
||||
"showCredentials": "Zugangsdaten anzeigen",
|
||||
"credEmail": "E-Mail",
|
||||
"credPassword": "Passwort",
|
||||
"showHide": "Anzeigen/Verbergen",
|
||||
"credentialsNotAvailable": "Zugangsdaten nicht verfuegbar. Der Admin muss das Setup manuell ueber die Setup URL abschliessen.",
|
||||
"credentialsNotAvailable": "Zugangsdaten nicht verfügbar. Der Admin muss das Setup manuell über die Setup URL abschließen.",
|
||||
"start": "Starten",
|
||||
"stop": "Stoppen",
|
||||
"restart": "Neustarten",
|
||||
@@ -76,11 +76,11 @@
|
||||
"noDeployment": "Kein Deployment gefunden.",
|
||||
"deployNow": "Jetzt bereitstellen",
|
||||
"containerLogs": "Container Logs",
|
||||
"noContainerLogs": "Keine Container-Logs verfuegbar.",
|
||||
"noContainerLogs": "Keine Container-Logs verfügbar.",
|
||||
"noLogsLoaded": "Keine Logs geladen.",
|
||||
"healthCheck": "Zustandspruefung",
|
||||
"check": "Pruefen",
|
||||
"clickCheck": "Klicken Sie auf \"Pruefen\" um eine Zustandspruefung durchzufuehren.",
|
||||
"healthCheck": "Zustandsprüfung",
|
||||
"check": "Prüfen",
|
||||
"clickCheck": "Klicken Sie auf \"Prüfen\" um eine Zustandsprüfung durchzuführen.",
|
||||
"healthy": "Gesund",
|
||||
"unhealthy": "Fehlerhaft",
|
||||
"overall": "Gesamt:",
|
||||
@@ -88,29 +88,8 @@
|
||||
"thContainerStatus": "Status",
|
||||
"thHealth": "Zustand",
|
||||
"thImage": "Image",
|
||||
"lastCheck": "Letzte Pruefung: {time}",
|
||||
"openDashboard": "Dashboard oeffnen"
|
||||
},
|
||||
"customerModal": {
|
||||
"newCustomer": "Neuer Kunde",
|
||||
"editCustomer": "Kunde bearbeiten",
|
||||
"nameLabel": "Name *",
|
||||
"companyLabel": "Firma",
|
||||
"subdomainLabel": "Subdomain *",
|
||||
"subdomainHint": "Kleinbuchstaben, alphanumerisch + Bindestriche",
|
||||
"emailLabel": "E-Mail *",
|
||||
"maxDevicesLabel": "Max. Geraete",
|
||||
"notesLabel": "Notizen",
|
||||
"cancel": "Abbrechen",
|
||||
"saveAndDeploy": "Speichern & Bereitstellen",
|
||||
"saveChanges": "Aenderungen speichern"
|
||||
},
|
||||
"deleteModal": {
|
||||
"title": "Loeschung bestaetigen",
|
||||
"confirmText": "Sind Sie sicher, dass Sie den Kunden loeschen moechten",
|
||||
"warning": "Alle Container, NPM-Eintraege und Daten werden entfernt. Diese Aktion kann nicht rueckgaengig gemacht werden.",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Loeschen"
|
||||
"lastCheck": "Letzte Prüfung: {time}",
|
||||
"openDashboard": "Dashboard öffnen"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Systemeinstellungen",
|
||||
@@ -120,6 +99,9 @@
|
||||
"tabBranding": "Branding",
|
||||
"tabUsers": "Benutzer",
|
||||
"tabAzure": "Azure AD",
|
||||
"tabDns": "Windows DNS",
|
||||
"tabLdap": "LDAP / AD",
|
||||
"tabUpdate": "Updates",
|
||||
"tabSecurity": "Sicherheit",
|
||||
"baseDomain": "Basis-Domain",
|
||||
"baseDomainPlaceholder": "ihredomain.com",
|
||||
@@ -128,25 +110,36 @@
|
||||
"adminEmailPlaceholder": "admin@ihredomain.com",
|
||||
"dataDir": "Datenverzeichnis",
|
||||
"dataDirPlaceholder": "/opt/netbird-instances",
|
||||
"dockerNetwork": "Docker Netzwerk",
|
||||
"dockerNetwork": "Docker-Netzwerk",
|
||||
"dockerNetworkPlaceholder": "npm-network",
|
||||
"relayBasePort": "Relay Basis-Port",
|
||||
"relayBasePortHint": "Erster UDP-Port fuer Relay. Bereich: Basis bis Basis+99",
|
||||
"dashboardBasePort": "Dashboard Basis-Port",
|
||||
"dashboardBasePortHint": "Basis-Port fuer Kunden-Dashboards. Kunde N erhaelt Basis+N",
|
||||
"relayBasePort": "Relay-Basisport",
|
||||
"relayBasePortHint": "Erster UDP-Port für Relay. Bereich: Basis bis Basis+99",
|
||||
"dashboardBasePort": "Dashboard-Basisport",
|
||||
"dashboardBasePortHint": "Basisport für Kunden-Dashboards. Kunde N erhält Basis+N",
|
||||
"saveSystemSettings": "Systemeinstellungen speichern",
|
||||
"npmDescription": "NPM verwendet JWT-Authentifizierung. Geben Sie Ihre NPM-Anmeldedaten (E-Mail + Passwort) ein. Das System meldet sich automatisch an und bezieht Tokens fuer API-Aufrufe.",
|
||||
"npmDescription": "NPM verwendet JWT-Authentifizierung. Geben Sie Ihre NPM-Zugangsdaten ein. Das System meldet sich automatisch an.",
|
||||
"npmApiUrl": "NPM API URL",
|
||||
"npmApiUrlPlaceholder": "http://nginx-proxy-manager:81/api",
|
||||
"npmApiUrlHint": "http:// oder https:// - muss /api am Ende enthalten",
|
||||
"npmLoginEmail": "NPM Login E-Mail",
|
||||
"npmLoginEmailPlaceholder": "Leer lassen um aktuelle beizubehalten",
|
||||
"npmLoginEmailPlaceholder": "Leer lassen zum Beibehalten",
|
||||
"npmLoginPassword": "NPM Login Passwort",
|
||||
"npmLoginPasswordPlaceholder": "Leer lassen um aktuelles beizubehalten",
|
||||
"credentialsSet": "Zugangsdaten sind gesetzt (leer lassen um aktuelle beizubehalten)",
|
||||
"npmLoginPasswordPlaceholder": "Leer lassen zum Beibehalten",
|
||||
"credentialsSet": "Zugangsdaten gesetzt (leer lassen zum Beibehalten)",
|
||||
"noCredentials": "Keine NPM-Zugangsdaten konfiguriert",
|
||||
"saveNpmSettings": "NPM Einstellungen speichern",
|
||||
"saveNpmSettings": "NPM-Einstellungen speichern",
|
||||
"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",
|
||||
"managementImagePlaceholder": "netbirdio/management:latest",
|
||||
"signalImage": "Signal Image",
|
||||
@@ -155,21 +148,21 @@
|
||||
"relayImagePlaceholder": "netbirdio/relay:latest",
|
||||
"dashboardImage": "Dashboard Image",
|
||||
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
|
||||
"saveImageSettings": "Image Einstellungen speichern",
|
||||
"brandingTitle": "Branding Einstellungen",
|
||||
"saveImageSettings": "Image-Einstellungen speichern",
|
||||
"brandingTitle": "Branding-Einstellungen",
|
||||
"companyName": "Firmen- / Anwendungsname",
|
||||
"companyNamePlaceholder": "NetBird MSP Appliance",
|
||||
"companyNameHint": "Wird auf der Login-Seite und in der Navbar angezeigt",
|
||||
"companyNameHint": "Wird auf der Anmeldeseite und in der Navigationsleiste angezeigt",
|
||||
"logoPreview": "Logo-Vorschau",
|
||||
"defaultIcon": "Standard-Icon (kein Logo hochgeladen)",
|
||||
"uploadLogo": "Logo hochladen (PNG, JPG, SVG, max 500KB)",
|
||||
"defaultIcon": "Standardsymbol (kein Logo hochgeladen)",
|
||||
"uploadLogo": "Logo hochladen (PNG, JPG, SVG, max. 500 KB)",
|
||||
"uploadBtn": "Hochladen",
|
||||
"removeLogo": "Logo entfernen",
|
||||
"brandingSubtitle": "Untertitel",
|
||||
"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",
|
||||
"defaultLanguageHint": "Standardsprache fuer Benutzer ohne eigene Einstellung",
|
||||
"defaultLanguageHint": "Standardsprache für Benutzer ohne Präferenz",
|
||||
"systemDefault": "Systemstandard",
|
||||
"saveBranding": "Branding speichern",
|
||||
"userManagement": "Benutzerverwaltung",
|
||||
@@ -187,82 +180,101 @@
|
||||
"tenantId": "Tenant ID",
|
||||
"clientId": "Client ID (Anwendungs-ID)",
|
||||
"clientSecret": "Client Secret",
|
||||
"clientSecretPlaceholder": "Leer lassen um aktuelles beizubehalten",
|
||||
"secretSet": "Secret ist gesetzt (leer lassen um aktuelles beizubehalten)",
|
||||
"noSecret": "Kein Client Secret konfiguriert",
|
||||
"saveAzureSettings": "Azure AD Einstellungen speichern",
|
||||
"securityTitle": "Admin-Passwort aendern",
|
||||
"clientSecretPlaceholder": "Leer lassen zum Beibehalten",
|
||||
"secretSet": "Secret gesetzt (leer lassen zum Beibehalten)",
|
||||
"noSecret": "Kein Client-Secret konfiguriert",
|
||||
"saveAzureSettings": "Azure AD-Einstellungen speichern",
|
||||
"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",
|
||||
"newPassword": "Neues Passwort (min. 12 Zeichen)",
|
||||
"confirmPassword": "Neues Passwort bestaetigen",
|
||||
"changePassword": "Passwort aendern"
|
||||
},
|
||||
"monitoring": {
|
||||
"title": "System Monitoring",
|
||||
"refresh": "Aktualisieren",
|
||||
"hostResources": "Host-Ressourcen",
|
||||
"hostname": "Hostname",
|
||||
"cpu": "CPU ({count} Kerne)",
|
||||
"memory": "Speicher ({used}/{total} GB)",
|
||||
"disk": "Festplatte ({used}/{total} GB)",
|
||||
"allCustomerDeployments": "Alle Kunden-Deployments",
|
||||
"thId": "ID",
|
||||
"thName": "Name",
|
||||
"thSubdomain": "Subdomain",
|
||||
"thStatus": "Status",
|
||||
"thDeployment": "Deployment",
|
||||
"thDashboard": "Dashboard",
|
||||
"thRelayPort": "Relay Port",
|
||||
"thContainers": "Container",
|
||||
"noCustomers": "Keine Kunden."
|
||||
},
|
||||
"userModal": {
|
||||
"title": "Neuer Benutzer",
|
||||
"usernameLabel": "Benutzername *",
|
||||
"passwordLabel": "Passwort * (min. 8 Zeichen)",
|
||||
"emailLabel": "E-Mail",
|
||||
"languageLabel": "Standardsprache",
|
||||
"cancel": "Abbrechen",
|
||||
"createUser": "Benutzer erstellen"
|
||||
"confirmPassword": "Neues Passwort bestätigen",
|
||||
"changePassword": "Passwort ändern"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Multi-Faktor-Authentifizierung (MFA)",
|
||||
"enableMfa": "MFA fuer 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.",
|
||||
"saveMfaSettings": "MFA Einstellungen speichern",
|
||||
"yourTotpStatus": "Ihr TOTP Status",
|
||||
"title": "Zwei-Faktor-Authentifizierung (MFA)",
|
||||
"enableMfa": "MFA für alle lokalen Benutzer aktivieren",
|
||||
"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",
|
||||
"yourTotpStatus": "Ihr TOTP-Status",
|
||||
"totpActive": "Aktiv",
|
||||
"totpNotSetUp": "Nicht eingerichtet",
|
||||
"disableMyTotp": "Mein TOTP deaktivieren",
|
||||
"enterCode": "Geben Sie Ihren 6-stelligen Authenticator-Code ein",
|
||||
"verify": "Verifizieren",
|
||||
"backToLogin": "Zurueck zum Login",
|
||||
"scanQrCode": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App",
|
||||
"orEnterManually": "Oder geben Sie diesen Schluessel manuell ein:",
|
||||
"verifyAndActivate": "Verifizieren & Aktivieren",
|
||||
"resetMfa": "MFA zuruecksetzen",
|
||||
"confirmResetMfa": "MFA fuer '{username}' zuruecksetzen? Der Benutzer muss seinen Authenticator beim naechsten Login neu einrichten.",
|
||||
"mfaResetSuccess": "MFA fuer '{username}' zurueckgesetzt.",
|
||||
"enterCode": "Geben Sie Ihren 6-stelligen Authentifikator-Code ein",
|
||||
"verify": "Bestätigen",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"scanQrCode": "Scannen Sie diesen QR-Code mit Ihrer Authentifikator-App",
|
||||
"orEnterManually": "Oder geben Sie diesen Schlüssel manuell ein:",
|
||||
"verifyAndActivate": "Bestätigen & Aktivieren",
|
||||
"resetMfa": "MFA zurücksetzen",
|
||||
"confirmResetMfa": "MFA für '{username}' zurücksetzen? Sie müssen bei der nächsten Anmeldung ihren Authentifikator neu einrichten.",
|
||||
"mfaResetSuccess": "MFA für '{username}' zurückgesetzt.",
|
||||
"mfaDisabled": "Ihr TOTP wurde deaktiviert.",
|
||||
"mfaSaved": "MFA Einstellungen gespeichert.",
|
||||
"invalidCode": "Ungueltiger Code. Bitte versuchen Sie es erneut.",
|
||||
"mfaSaved": "MFA-Einstellungen gespeichert.",
|
||||
"invalidCode": "Ungültiger Code. Bitte versuchen Sie es erneut.",
|
||||
"codeExpired": "Verifizierung abgelaufen. Bitte melden Sie sich erneut an."
|
||||
},
|
||||
"common": {
|
||||
"loading": "Laden...",
|
||||
"back": "Zurueck",
|
||||
"back": "Zurück",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Loeschen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"view": "Anzeigen",
|
||||
"view": "Ansehen",
|
||||
"start": "Starten",
|
||||
"stop": "Stoppen",
|
||||
"restart": "Neustarten",
|
||||
"disable": "Deaktivieren",
|
||||
"enable": "Aktivieren",
|
||||
"resetPassword": "Passwort zuruecksetzen",
|
||||
"open": "Oeffnen",
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
"open": "Öffnen",
|
||||
"active": "Aktiv",
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
@@ -273,15 +285,15 @@
|
||||
"serverError": "Serverfehler (HTTP {status}).",
|
||||
"unknownError": "Ein unbekannter Fehler ist aufgetreten.",
|
||||
"uploadFailed": "Upload fehlgeschlagen.",
|
||||
"deleteFailed": "Loeschen fehlgeschlagen: {error}",
|
||||
"deleteFailed": "Löschen fehlgeschlagen: {error}",
|
||||
"failedToLoadSettings": "Einstellungen konnten nicht geladen werden: {error}",
|
||||
"failed": "Fehlgeschlagen: {error}",
|
||||
"logoUploadFailed": "Logo-Upload fehlgeschlagen: {error}",
|
||||
"failedToRemoveLogo": "Logo konnte nicht entfernt werden: {error}",
|
||||
"updateFailed": "Aktualisierung fehlgeschlagen: {error}",
|
||||
"passwordResetFailed": "Passwort-Zuruecksetzung fehlgeschlagen: {error}",
|
||||
"selectFileFirst": "Bitte waehlen Sie zuerst eine Datei aus.",
|
||||
"passwordsDoNotMatch": "Passwoerter stimmen nicht ueberein.",
|
||||
"passwordResetFailed": "Passwort zurücksetzen fehlgeschlagen: {error}",
|
||||
"selectFileFirst": "Bitte wählen Sie zuerst eine Datei aus.",
|
||||
"passwordsDoNotMatch": "Passwörter stimmen nicht überein.",
|
||||
"failedToLoadCredentials": "Zugangsdaten konnten nicht geladen werden: {error}",
|
||||
"azureNotConfigured": "Azure AD ist nicht konfiguriert.",
|
||||
"azureLoginFailed": "Azure AD Anmeldung fehlgeschlagen: {error}",
|
||||
@@ -289,20 +301,73 @@
|
||||
},
|
||||
"messages": {
|
||||
"systemSettingsSaved": "Systemeinstellungen gespeichert.",
|
||||
"npmSettingsSaved": "NPM Einstellungen gespeichert.",
|
||||
"imageSettingsSaved": "Image Einstellungen gespeichert.",
|
||||
"npmSettingsSaved": "NPM-Einstellungen gespeichert.",
|
||||
"imageSettingsSaved": "Image-Einstellungen gespeichert.",
|
||||
"brandingNameSaved": "Branding-Einstellungen gespeichert.",
|
||||
"logoUploaded": "Logo erfolgreich hochgeladen.",
|
||||
"logoRemoved": "Logo entfernt.",
|
||||
"azureSettingsSaved": "Azure AD Einstellungen gespeichert.",
|
||||
"passwordChanged": "Passwort erfolgreich geaendert.",
|
||||
"setupUrlCopied": "Setup URL in die Zwischenablage kopiert.",
|
||||
"copiedToClipboard": "In die Zwischenablage kopiert.",
|
||||
"azureSettingsSaved": "Azure AD-Einstellungen gespeichert.",
|
||||
"dnsSettingsSaved": "DNS-Einstellungen gespeichert.",
|
||||
"ldapSettingsSaved": "LDAP-Einstellungen gespeichert.",
|
||||
"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.",
|
||||
"userDeleted": "Benutzer '{username}' geloescht.",
|
||||
"passwordResetFor": "Passwort fuer '{username}' zurueckgesetzt.",
|
||||
"newPasswordAlert": "Neues Passwort fuer '{username}':\n\n{password}\n\nBitte speichern Sie dieses Passwort jetzt. Es wird nicht erneut angezeigt.",
|
||||
"confirmDeleteUser": "Benutzer '{username}' loeschen? Dies kann nicht rueckgaengig gemacht werden.",
|
||||
"confirmResetPassword": "Passwort fuer '{username}' zuruecksetzen? Ein neues zufaelliges Passwort wird generiert."
|
||||
"userDeleted": "Benutzer '{username}' gelöscht.",
|
||||
"passwordResetFor": "Passwort zurückgesetzt für '{username}'.",
|
||||
"newPasswordAlert": "Neues Passwort für '{username}':\n\n{password}\n\nBitte speichern Sie dieses Passwort jetzt. Es wird nicht erneut angezeigt.",
|
||||
"confirmDeleteUser": "Benutzer '{username}' löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,9 @@
|
||||
"tabBranding": "Branding",
|
||||
"tabUsers": "Users",
|
||||
"tabAzure": "Azure AD",
|
||||
"tabDns": "Windows DNS",
|
||||
"tabLdap": "LDAP / AD",
|
||||
"tabUpdate": "Updates",
|
||||
"tabSecurity": "Security",
|
||||
"baseDomain": "Base Domain",
|
||||
"baseDomainPlaceholder": "yourdomain.com",
|
||||
@@ -147,6 +150,17 @@
|
||||
"noCredentials": "No NPM credentials configured",
|
||||
"saveNpmSettings": "Save NPM Settings",
|
||||
"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",
|
||||
"managementImagePlaceholder": "netbirdio/management:latest",
|
||||
"signalImage": "Signal Image",
|
||||
@@ -191,6 +205,53 @@
|
||||
"secretSet": "Secret is set (leave empty to keep current)",
|
||||
"noSecret": "No client secret configured",
|
||||
"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",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password (min 12 chars)",
|
||||
@@ -295,6 +356,10 @@
|
||||
"logoUploaded": "Logo uploaded successfully.",
|
||||
"logoRemoved": "Logo removed.",
|
||||
"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.",
|
||||
"setupUrlCopied": "Setup URL copied to clipboard.",
|
||||
"copiedToClipboard": "Copied to clipboard.",
|
||||
|
||||
72
update.sh
Executable file
72
update.sh
Executable 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
|
||||
Reference in New Issue
Block a user