first commit

This commit is contained in:
Stefan Hacker 2026-02-02 09:46:35 +01:00
commit 6901dc369b
98 changed files with 13030 additions and 0 deletions

536
README.md Normal file
View File

@ -0,0 +1,536 @@
# mGuard VPN Endpoint Server
VPN-Management-System für Phoenix Contact mGuard Router mit Web-Oberfläche.
## Übersicht
Dieses System ermöglicht die zentrale Verwaltung von mGuard VPN-Gateways und den sicheren Fernzugriff auf Endpunkte (SPS, HMI, etc.) hinter diesen Gateways.
### Komponenten
| Komponente | Beschreibung |
|------------|--------------|
| **Server + Web-UI** | Docker-basiert mit FastAPI, MariaDB, OpenVPN und Web-Dashboard |
| **Desktop Client** | PyQt6 GUI-Anwendung für Techniker (Windows/Linux) |
| **Provisioning Tool** | CLI-Tool für Gateway-Provisionierung |
### Unterstützte Router
| Router | Firmware | Provisionierung |
|--------|----------|-----------------|
| FL MGUARD 2000/4000 | 10.5.x | REST API |
| FL MGUARD RS4000 | 8.9 | SSH / ATV-Datei |
| FL MGUARD 1000 | 1.x | REST API |
---
## Architektur
```
┌────────────────────────────────────────────────────────────────────────────┐
│ DOCKER COMPOSE STACK │
│ │
│ ┌──────────────────────┐ ┌───────────────────────┐ │
│ │ FastAPI Server │ │ MariaDB │ │
│ │ :8000 │ │ :3306 │ │
│ │ ├── REST API │ │ │ │
│ │ ├── Web UI (HTMX) │ │ ├── CAs │ │
│ │ └── PKI-Verwaltung │ │ ├── VPN Servers │ │
│ └──────────┬───────────┘ │ ├── VPN Profiles │ │
│ │ │ ├── Gateways │ │
│ └──────────────┴──┴── Users │ │
└─────────────────────────────────────────────────────────────────────────────┘
Interne API (localhost)
┌─────────────────────────────────────────────────────────────────────────────┐
│ OpenVPN Container (Host-Netzwerk) │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ supervisord │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ openvpn-1 │ │ openvpn-2 │ │ openvpn-N │ │ │
│ │ │ UDP:1194 │ │ TCP:443 │ │ UDP:1195 │ │ │
│ │ │ (Produktion) │ │ (Firewall) │ │ (Backup) │ │ │
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ Polling alle 30s: Neue Server automatisch starten/stoppen │
└─────────────────────────────────────────────────────────────────────────────┘
Host-Ports direkt (kein Docker NAT)
┌─────────────┴─────────────┐
▼ ▼
┌───────────────────┐ ┌─────────────────┐
│ mGuard Gateway │ │ Web Browser │
│ (beim Kunden) │ │ (Admin) │
└───────────────────┘ └─────────────────┘
```
### Multi-Server Architektur
Der OpenVPN-Container verwaltet **automatisch mehrere VPN-Server-Instanzen**:
- Läuft im **Host-Netzwerk-Modus** für direkte Port-Bindung
- Verwendet **supervisord** für Prozess-Management
- **Pollt alle 30 Sekunden** die API für neue/geänderte Server
- **Keine docker-compose.yml Änderungen** nötig beim Hinzufügen neuer VPN-Server!
```
Datenbank OpenVPN Container
┌─────────────────────┐ ┌─────────────────────┐
│ VPN-Server │ │ supervisord │
│ ├── ID: 1 (aktiv) │ ──Poll──▶ │ ├── openvpn-1 │
│ ├── ID: 2 (aktiv) │ │ ├── openvpn-2 │
│ └── ID: 3 (inaktiv)│ │ └── (kein 3) │
└─────────────────────┘ └─────────────────────┘
```
### PKI-Verwaltung über Web-UI
Die gesamte PKI (Zertifizierungsstellen, Zertifikate, VPN-Server) wird über die Web-Oberfläche verwaltet:
| Bereich | Beschreibung |
|---------|--------------
| **Zertifizierungsstellen (CA)** | Eigene CA erstellen oder importieren |
| **VPN-Server** | Mehrere Server mit unterschiedlichen Ports/Protokollen |
| **VPN-Profile** | Pro Gateway mehrere Profile für Failover/Migration |
---
## Schnellstart
### Voraussetzungen
- Docker & Docker Compose
- Mindestens 2 GB RAM für die Container
- Port 8000 (Web/API) und gewünschte VPN-Ports frei
### 1. Installation
```bash
# Repository klonen oder Dateien kopieren
cd endpoint-server-openvpn
# Konfiguration erstellen
cp .env.example .env
# .env bearbeiten und Passwörter anpassen!
nano .env
```
### 2. Container starten
```bash
# Container bauen und starten
docker-compose up -d
# Logs prüfen
docker-compose logs -f
```
### 3. Ersteinrichtung (Web-UI)
Nach dem Start unter **http://localhost:8000/** einloggen.
Standard-Login:
- **Benutzername:** `admin`
- **Passwort:** `changeme` (in .env ändern!)
#### Schritt 1: CA erstellen
1. Navigation: **VPN → Zertifizierungsstellen**
2. **Neue CA erstellen** klicken
3. Formular ausfüllen:
- Name: z.B. "Produktion CA"
- Organisation, Land, Stadt
- Schlüsselgröße: 4096 (empfohlen) oder 2048
- Gültigkeit: 3650 Tage (10 Jahre)
4. **Erstellen** - DH-Parameter werden im Hintergrund generiert
#### Schritt 2: VPN-Server erstellen
1. Navigation: **VPN → VPN-Server**
2. **Neuer VPN-Server** klicken
3. Formular ausfüllen:
- Name: z.B. "Produktion UDP"
- CA auswählen (aus Schritt 1)
- Hostname: Öffentliche IP oder Domain
- Port: 1194
- Protokoll: UDP (empfohlen) oder TCP
4. **Erstellen** - Server-Zertifikat wird automatisch generiert
#### Schritt 3: Automatischer Start
Der OpenVPN-Container erkennt neue Server automatisch (Polling alle 30s):
```bash
# Logs prüfen - Server sollte automatisch starten
docker-compose logs -f openvpn
# Ausgabe sollte zeigen:
# [2024-01-15 10:30:00] New server detected: 1
# [2024-01-15 10:30:00] Setting up server 1 (Produktion UDP) on port 1194/udp
# [2024-01-15 10:30:01] Server 1 configured successfully
```
**Kein Container-Neustart nötig!**
---
## Mehrere VPN-Server betreiben
### Automatische Verwaltung
Einfach weitere VPN-Server über die Web-UI erstellen:
| Server | Port | Protokoll | Verwendung |
|--------|------|-----------|------------|
| Produktion UDP | 1194 | UDP | Standard, schnell |
| Produktion TCP | 443 | TCP | Firewall-Bypass |
| Backup | 1195 | UDP | Fallback-Server |
Der Container startet diese automatisch - **keine Konfigurationsänderungen nötig!**
### Firewall-Regeln
Da der Container im Host-Netzwerk läuft, müssen die VPN-Ports auf dem Host freigegeben sein:
```bash
# Beispiel für iptables
sudo iptables -A INPUT -p udp --dport 1194 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT
sudo iptables -A INPUT -p udp --dport 1195 -j ACCEPT
# Oder via ufw
sudo ufw allow 1194/udp
sudo ufw allow 443/tcp
sudo ufw allow 1195/udp
```
---
## Gateway einrichten
### 1. Gateway anlegen
1. Navigation: **Gateways → Neues Gateway**
2. Formular ausfüllen:
- Name: z.B. "Kunde ABC - Türsteuerung Halle 1"
- Router-Typ: FL MGUARD 2000
- Standort: Halle 1, Raum 102
### 2. VPN-Profil erstellen
1. Gateway öffnen → **VPN-Profile** Tab
2. **Neues Profil** klicken
3. Formular:
- Name: z.B. "Produktion"
- VPN-Server: Server auswählen
- Priorität: 1 (primär)
4. **Erstellen** - Client-Zertifikat wird generiert
### 3. Provisioning-Download
1. VPN-Profil öffnen
2. **Konfiguration herunterladen** klicken
3. `.ovpn` Datei auf mGuard Router übertragen
### Failover mit mehreren Profilen
Für Ausfallsicherheit mehrere Profile mit unterschiedlichen Prioritäten:
| Profil | VPN-Server | Priorität | Verwendung |
|--------|------------|-----------|------------|
| Produktion | Server 1 (UDP) | 1 | Primärer Server |
| Fallback | Server 2 (TCP) | 2 | Bei Ausfall von Server 1 |
---
## Endpunkte definieren
Endpunkte sind Geräte hinter dem Gateway (SPS, HMI, etc.):
1. Gateway öffnen → **Endpunkte** Tab
2. **Endpunkt hinzufügen**
3. Ausfüllen:
- Name: HMI Türsteuerung
- IP-Adresse: 10.0.0.3
- Port: 11740
- Protokoll: TCP
- Anwendung: CoDeSys
---
## Web-Oberfläche
### Navigation
| Bereich | Beschreibung |
|---------|--------------
| **Dashboard** | Gateway-Status, aktive Verbindungen, Statistiken |
| **Gateways** | Gateway-Verwaltung, Endpunkte, VPN-Profile |
| **VPN → VPN-Server** | VPN-Instanzen verwalten, Status, Clients |
| **VPN → Zertifizierungsstellen** | CA erstellen/importieren, Zertifikate |
| **Benutzer** | Techniker anlegen, Rollen zuweisen |
| **Verbindungen** | Live-Anzeige aktiver Verbindungen, Historie |
---
## Benutzer & Berechtigungen
### Rollen
| Rolle | Rechte |
|-------|--------|
| **super_admin** | Alle Mandanten, alle Gateways, CAs, VPN-Server |
| **admin** | Eigener Mandant: Gateways, Benutzer, Endpunkte |
| **technician** | Nur zugewiesene Gateways sehen und verbinden |
| **viewer** | Nur lesen, keine Verbindungen |
---
## Wartung & Betrieb
### Logs ansehen
```bash
# Alle Logs
docker-compose logs -f
# Nur API-Server
docker-compose logs -f api
# Nur OpenVPN (alle Server)
docker-compose logs -f openvpn
# Einzelner VPN-Server im Container
docker exec mguard-openvpn cat /var/log/openvpn/server-1.log
```
### Server-Status prüfen
```bash
# Alle laufenden OpenVPN-Prozesse
docker exec mguard-openvpn supervisorctl status
# Ausgabe:
# openvpn-1 RUNNING pid 123, uptime 0:05:30
# openvpn-2 RUNNING pid 456, uptime 0:05:28
```
### Einzelnen Server neustarten
```bash
# Via supervisorctl
docker exec mguard-openvpn supervisorctl restart openvpn-1
```
### Backup
```bash
# Datenbank-Backup (enthält CAs, Zertifikate, Konfiguration)
docker-compose exec db mysqldump -u root -p mguard_vpn > backup.sql
# Volumes auflisten
docker volume ls | grep mguard
```
**Wichtig:** Die PKI (CAs, Zertifikate, Private Keys) ist in der Datenbank gespeichert. Ein Datenbank-Backup sichert alles.
### Update
```bash
# Container stoppen
docker-compose down
# Neue Version pullen/kopieren
git pull
# Neu bauen und starten
docker-compose up -d --build
```
---
## Fehlerbehebung
### OpenVPN startet nicht
```bash
# Logs prüfen
docker-compose logs openvpn
```
Mögliche Ursachen:
- **"API not ready"**: API-Container noch nicht gestartet
- **"No active VPN servers found"**: VPN-Server in Web-UI erstellen
- **"Failed to fetch server config"**: Server nicht bereit (CA/Zertifikat fehlt)
### Neuer VPN-Server wird nicht gestartet
1. Prüfe ob Server in Web-UI als "Bereit" markiert ist
2. Warte 30 Sekunden (Polling-Intervall)
3. Prüfe Logs: `docker-compose logs -f openvpn`
### Port bereits belegt
```bash
# Prüfen welcher Prozess den Port nutzt
sudo netstat -tlnp | grep 1194
sudo lsof -i :1194
# Falls nötig: Prozess beenden oder anderen Port in Web-UI wählen
```
### VPN-Server zeigt "Ausstehend"
DH-Parameter werden noch generiert. Status in der VPN-Server-Übersicht prüfen.
```bash
# CA-Status in Datenbank prüfen
docker-compose exec db mysql -u mguard -p -e \
"SELECT name, status FROM mguard_vpn.certificate_authorities"
```
### Gateway verbindet nicht
1. VPN-Profil für Gateway erstellt?
2. Provisioning-Datei heruntergeladen und importiert?
3. Firewall-Ports offen?
4. VPN-Server Status prüfen: **VPN → VPN-Server → Details**
---
## Verzeichnisstruktur
```
endpoint-server-openvpn/
├── docker-compose.yml # Container-Orchestrierung
├── .env.example # Konfigurationsvorlage
├── README.md # Diese Datei
├── server/ # FastAPI Backend + Web-UI
│ ├── Dockerfile
│ ├── requirements.txt
│ └── app/
│ ├── main.py # Einstiegspunkt
│ ├── api/ # REST API Routes
│ │ └── internal.py # Interne API für OpenVPN
│ ├── web/ # Web-UI Routes
│ │ ├── ca.py # CA-Verwaltung
│ │ ├── vpn_servers.py # VPN-Server
│ │ └── vpn_profiles.py # VPN-Profile
│ ├── templates/ # Jinja2 HTML-Templates
│ ├── models/ # Datenbank-Modelle
│ │ ├── certificate_authority.py
│ │ ├── vpn_server.py
│ │ └── vpn_profile.py
│ └── services/ # Business-Logik
│ ├── certificate_service.py
│ ├── vpn_server_service.py
│ └── vpn_profile_service.py
├── openvpn/ # OpenVPN Multi-Server Container
│ ├── Dockerfile
│ ├── entrypoint.sh # Multi-Server Management Script
│ └── supervisord.conf # Prozess-Manager Konfiguration
├── client/ # PyQt Desktop-Client
│ └── ...
└── provisioning-tool/ # CLI Provisioning
└── ...
```
---
## Sicherheit
- **TLS 1.2+** für VPN-Verbindungen
- **AES-256-GCM** Verschlüsselung (konfigurierbar)
- **JWT-Token** für API-Authentifizierung
- **Session-Cookies** für Web-UI
- **bcrypt** Password-Hashing
- **Multi-Tenant** Datenisolation
- **CRL** für Zertifikatswiderruf
### Produktiv-Empfehlungen
1. `.env` Passwörter ändern!
2. HTTPS-Reverse-Proxy (nginx/traefik) vorschalten
3. Firewall: Nur VPN-Ports und HTTPS nach außen
4. Regelmäßige Datenbank-Backups
5. Schlüsselgröße 4096-bit für CAs
### Host-Netzwerk Hinweis
Der OpenVPN-Container läuft im Host-Netzwerk-Modus. Das bedeutet:
- Direkte Port-Bindung (kein Docker NAT)
- Weniger Netzwerk-Overhead
- Container kann auf localhost:8000 zugreifen
- Firewall-Regeln auf Host-Ebene erforderlich
---
## REST API
Die API ist dokumentiert unter: **http://localhost:8000/api/docs**
### Wichtige Endpoints
| Methode | Endpoint | Beschreibung |
|---------|----------|--------------|
| POST | `/api/auth/login` | Login |
| GET | `/api/gateways` | Gateway-Liste |
| GET | `/api/gateways/{id}/profiles` | VPN-Profile eines Gateways |
| GET | `/api/gateways/{id}/profiles/{pid}/provision` | Provisioning-Download |
### Interne API (für OpenVPN-Container)
| Endpoint | Beschreibung |
|----------|--------------|
| `/api/internal/health` | Health Check |
| `/api/internal/vpn-servers/active` | Liste aller aktiven Server |
| `/api/internal/vpn-servers/{id}/config` | Server-Konfiguration |
| `/api/internal/vpn-servers/{id}/ca` | CA-Zertifikat |
| `/api/internal/vpn-servers/{id}/cert` | Server-Zertifikat |
| `/api/internal/vpn-servers/{id}/key` | Server Private Key |
| `/api/internal/vpn-servers/{id}/dh` | DH-Parameter |
| `/api/internal/vpn-servers/{id}/crl` | Revocation List |
| `/api/internal/vpn-servers/{id}/started` | Server-Start melden |
| `/api/internal/vpn-servers/{id}/stopped` | Server-Stop melden |
---
## Umgebungsvariablen
### OpenVPN Container
| Variable | Standard | Beschreibung |
|----------|----------|--------------|
| `API_URL` | `http://127.0.0.1:8000/api/internal` | API-Endpunkt |
| `API_TIMEOUT` | `120` | Timeout beim Warten auf API (Sekunden) |
| `API_RETRY_INTERVAL` | `5` | Wiederholungsintervall (Sekunden) |
| `POLL_INTERVAL` | `30` | Polling-Intervall für neue Server (Sekunden) |
---
## Container neu bauen
```bash
# Stoppen
docker-compose down
# Komplett neu bauen
docker-compose build --no-cache
# Starten
docker-compose up -d
```
---
## Lizenz
Proprietär - Nur für internen Gebrauch.

29
client/config.py Normal file
View File

@ -0,0 +1,29 @@
"""Client configuration."""
import os
from pathlib import Path
# Application info
APP_NAME = "mGuard VPN Client"
APP_VERSION = "1.0.0"
# Default server settings
DEFAULT_SERVER_URL = "http://localhost:8000"
# OpenVPN paths
if os.name == 'nt': # Windows
OPENVPN_EXE = r"C:\Program Files\OpenVPN\bin\openvpn.exe"
OPENVPN_CONFIG_DIR = Path.home() / "OpenVPN" / "config"
else: # Linux/Mac
OPENVPN_EXE = "/usr/sbin/openvpn"
OPENVPN_CONFIG_DIR = Path.home() / ".openvpn"
# Ensure config directory exists
OPENVPN_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# Local storage
APP_DATA_DIR = Path.home() / ".mguard-vpn"
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
# Settings file
SETTINGS_FILE = APP_DATA_DIR / "settings.json"

31
client/main.py Normal file
View File

@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""mGuard VPN Client - Main Entry Point."""
import sys
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import Qt
from config import APP_NAME
from ui.main_window import MainWindow
def main():
"""Main entry point."""
# Enable high DPI scaling
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setStyle("Fusion")
# Create and show main window
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

14
client/requirements.txt Normal file
View File

@ -0,0 +1,14 @@
# PyQt GUI
PyQt6==6.6.1
# HTTP Client
httpx==0.26.0
# Configuration
python-dotenv==1.0.0
# Keyring for secure credential storage
keyring==24.3.0
# For Windows OpenVPN management
pywin32==306; sys_platform == 'win32'

View File

@ -0,0 +1,6 @@
"""Client services."""
from .api_client import APIClient
from .vpn_manager import VPNManager
__all__ = ["APIClient", "VPNManager"]

View File

@ -0,0 +1,175 @@
"""REST API client for server communication."""
import httpx
from typing import Optional
from dataclasses import dataclass
@dataclass
class Gateway:
"""Gateway data class."""
id: int
name: str
description: Optional[str]
location: Optional[str]
router_type: str
is_online: bool
vpn_ip: Optional[str]
@dataclass
class Endpoint:
"""Endpoint data class."""
id: int
gateway_id: int
name: str
description: Optional[str]
internal_ip: str
port: int
protocol: str
application_name: Optional[str]
class APIClient:
"""REST API client for mGuard VPN Server."""
def __init__(self, base_url: str):
self.base_url = base_url.rstrip('/')
self.access_token: Optional[str] = None
self.refresh_token: Optional[str] = None
self.client = httpx.Client(timeout=30.0)
def _headers(self) -> dict:
"""Get request headers with auth token."""
headers = {"Content-Type": "application/json"}
if self.access_token:
headers["Authorization"] = f"Bearer {self.access_token}"
return headers
def _request(self, method: str, endpoint: str, **kwargs) -> dict:
"""Make authenticated request."""
url = f"{self.base_url}{endpoint}"
response = self.client.request(method, url, headers=self._headers(), **kwargs)
response.raise_for_status()
return response.json() if response.text else {}
def login(self, username: str, password: str) -> bool:
"""Login and store tokens."""
try:
response = self.client.post(
f"{self.base_url}/api/auth/login",
json={"username": username, "password": password}
)
response.raise_for_status()
data = response.json()
self.access_token = data["access_token"]
self.refresh_token = data["refresh_token"]
return True
except httpx.HTTPError:
return False
def logout(self):
"""Clear stored tokens."""
self.access_token = None
self.refresh_token = None
def refresh_access_token(self) -> bool:
"""Refresh access token using refresh token."""
if not self.refresh_token:
return False
try:
response = self.client.post(
f"{self.base_url}/api/auth/refresh",
params={"refresh_token": self.refresh_token}
)
response.raise_for_status()
data = response.json()
self.access_token = data["access_token"]
self.refresh_token = data["refresh_token"]
return True
except httpx.HTTPError:
return False
def get_current_user(self) -> Optional[dict]:
"""Get current user information."""
try:
return self._request("GET", "/api/auth/me")
except httpx.HTTPError:
return None
def get_gateways(self) -> list[Gateway]:
"""Get list of accessible gateways."""
try:
data = self._request("GET", "/api/gateways")
return [
Gateway(
id=g["id"],
name=g["name"],
description=g.get("description"),
location=g.get("location"),
router_type=g["router_type"],
is_online=g["is_online"],
vpn_ip=g.get("vpn_ip")
)
for g in data
]
except httpx.HTTPError:
return []
def get_gateways_status(self) -> list[dict]:
"""Get online status of all gateways."""
try:
return self._request("GET", "/api/gateways/status")
except httpx.HTTPError:
return []
def get_endpoints(self, gateway_id: int) -> list[Endpoint]:
"""Get endpoints for a gateway."""
try:
data = self._request("GET", f"/api/endpoints/gateway/{gateway_id}")
return [
Endpoint(
id=e["id"],
gateway_id=e["gateway_id"],
name=e["name"],
description=e.get("description"),
internal_ip=e["internal_ip"],
port=e["port"],
protocol=e["protocol"],
application_name=e.get("application_name")
)
for e in data
]
except httpx.HTTPError:
return []
def connect(self, gateway_id: int, endpoint_id: int) -> dict:
"""Request connection to endpoint."""
try:
return self._request(
"POST", "/api/connections/connect",
json={"gateway_id": gateway_id, "endpoint_id": endpoint_id}
)
except httpx.HTTPError as e:
return {"success": False, "message": str(e)}
def disconnect(self, connection_id: int) -> dict:
"""Disconnect from endpoint."""
try:
return self._request(
"POST", "/api/connections/disconnect",
json={"connection_id": connection_id}
)
except httpx.HTTPError as e:
return {"message": str(e)}
def get_active_connections(self) -> list[dict]:
"""Get list of active connections."""
try:
return self._request("GET", "/api/connections/active")
except httpx.HTTPError:
return []
def close(self):
"""Close HTTP client."""
self.client.close()

View File

@ -0,0 +1,144 @@
"""OpenVPN process management."""
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from dataclasses import dataclass
from config import OPENVPN_EXE, OPENVPN_CONFIG_DIR
@dataclass
class VPNStatus:
"""VPN connection status."""
connected: bool
vpn_ip: Optional[str] = None
error: Optional[str] = None
class VPNManager:
"""Manages OpenVPN connections."""
def __init__(self):
self.process: Optional[subprocess.Popen] = None
self.config_file: Optional[Path] = None
self.log_file: Optional[Path] = None
def check_openvpn_installed(self) -> bool:
"""Check if OpenVPN is installed."""
if os.name == 'nt':
return Path(OPENVPN_EXE).exists()
else:
try:
subprocess.run(["which", "openvpn"], capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def connect(self, config_content: str) -> VPNStatus:
"""Connect using provided OpenVPN config."""
if self.process and self.process.poll() is None:
return VPNStatus(connected=False, error="Already connected")
if not self.check_openvpn_installed():
return VPNStatus(
connected=False,
error="OpenVPN is not installed. Please install OpenVPN first."
)
# Write config to temp file
self.config_file = OPENVPN_CONFIG_DIR / "mguard-temp.ovpn"
self.config_file.write_text(config_content)
# Log file
self.log_file = OPENVPN_CONFIG_DIR / "mguard.log"
try:
if os.name == 'nt':
# Windows: Use OpenVPN GUI or direct call
# Note: Requires admin privileges
self.process = subprocess.Popen(
[OPENVPN_EXE, "--config", str(self.config_file), "--log", str(self.log_file)],
creationflags=subprocess.CREATE_NO_WINDOW
)
else:
# Linux: Use sudo openvpn
self.process = subprocess.Popen(
["sudo", "openvpn", "--config", str(self.config_file), "--log", str(self.log_file)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
# Wait a bit for connection
import time
time.sleep(3)
if self.process.poll() is not None:
# Process ended, check for errors
if self.log_file.exists():
log_content = self.log_file.read_text()
return VPNStatus(connected=False, error=f"Connection failed: {log_content[-500:]}")
return VPNStatus(connected=False, error="Connection failed")
return VPNStatus(connected=True)
except PermissionError:
return VPNStatus(
connected=False,
error="Permission denied. Run as administrator/root."
)
except Exception as e:
return VPNStatus(connected=False, error=str(e))
def disconnect(self) -> VPNStatus:
"""Disconnect VPN."""
if not self.process:
return VPNStatus(connected=False)
try:
self.process.terminate()
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
self.process = None
# Clean up temp config
if self.config_file and self.config_file.exists():
self.config_file.unlink()
return VPNStatus(connected=False)
def get_status(self) -> VPNStatus:
"""Get current VPN status."""
if not self.process:
return VPNStatus(connected=False)
if self.process.poll() is not None:
# Process has ended
self.process = None
return VPNStatus(connected=False)
# Try to get VPN IP from log
vpn_ip = None
if self.log_file and self.log_file.exists():
try:
log_content = self.log_file.read_text()
# Parse for IP assignment
for line in log_content.split('\n'):
if 'ifconfig' in line.lower() and 'netmask' in line.lower():
parts = line.split()
for i, part in enumerate(parts):
if part == 'ifconfig' and i + 1 < len(parts):
vpn_ip = parts[i + 1]
break
except Exception:
pass
return VPNStatus(connected=True, vpn_ip=vpn_ip)
def is_connected(self) -> bool:
"""Check if VPN is connected."""
return self.process is not None and self.process.poll() is None

6
client/ui/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""UI components."""
from .main_window import MainWindow
from .login_dialog import LoginDialog
__all__ = ["MainWindow", "LoginDialog"]

95
client/ui/login_dialog.py Normal file
View File

@ -0,0 +1,95 @@
"""Login dialog for server authentication."""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLineEdit, QPushButton, QLabel, QMessageBox, QComboBox
)
from PyQt6.QtCore import Qt
from config import DEFAULT_SERVER_URL, APP_NAME
class LoginDialog(QDialog):
"""Login dialog for user authentication."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle(f"{APP_NAME} - Login")
self.setMinimumWidth(400)
self.setModal(True)
self._setup_ui()
def _setup_ui(self):
"""Setup UI components."""
layout = QVBoxLayout(self)
# Title
title = QLabel(APP_NAME)
title.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title)
# Form
form = QFormLayout()
# Server URL
self.server_input = QComboBox()
self.server_input.setEditable(True)
self.server_input.addItem(DEFAULT_SERVER_URL)
self.server_input.setCurrentText(DEFAULT_SERVER_URL)
form.addRow("Server:", self.server_input)
# Username
self.username_input = QLineEdit()
self.username_input.setPlaceholderText("Enter username")
form.addRow("Username:", self.username_input)
# Password
self.password_input = QLineEdit()
self.password_input.setPlaceholderText("Enter password")
self.password_input.setEchoMode(QLineEdit.EchoMode.Password)
form.addRow("Password:", self.password_input)
layout.addLayout(form)
# Error label
self.error_label = QLabel()
self.error_label.setStyleSheet("color: red;")
self.error_label.setVisible(False)
layout.addWidget(self.error_label)
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
self.login_button = QPushButton("Login")
self.login_button.setDefault(True)
self.login_button.clicked.connect(self.accept)
button_layout.addWidget(self.login_button)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# Enter key handling
self.password_input.returnPressed.connect(self.login_button.click)
def get_credentials(self) -> tuple[str, str, str]:
"""Get entered credentials."""
return (
self.server_input.currentText().strip(),
self.username_input.text().strip(),
self.password_input.text()
)
def show_error(self, message: str):
"""Show error message."""
self.error_label.setText(message)
self.error_label.setVisible(True)
def clear_error(self):
"""Clear error message."""
self.error_label.setVisible(False)

387
client/ui/main_window.py Normal file
View File

@ -0,0 +1,387 @@
"""Main application window."""
from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QSplitter, QListWidget, QListWidgetItem, QLabel,
QPushButton, QGroupBox, QTextEdit, QStatusBar,
QMessageBox, QProgressDialog
)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QIcon, QColor
from config import APP_NAME, APP_VERSION
from services.api_client import APIClient, Gateway, Endpoint
from services.vpn_manager import VPNManager
from .login_dialog import LoginDialog
class MainWindow(QMainWindow):
"""Main application window."""
def __init__(self):
super().__init__()
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
self.setMinimumSize(900, 600)
self.api_client: APIClient | None = None
self.vpn_manager = VPNManager()
self.current_gateway: Gateway | None = None
self.current_endpoint: Endpoint | None = None
self.current_connection_id: int | None = None
self._setup_ui()
self._setup_timers()
# Show login dialog on start
QTimer.singleShot(100, self._show_login)
def _setup_ui(self):
"""Setup UI components."""
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
# Toolbar
toolbar = QHBoxLayout()
self.user_label = QLabel("Not logged in")
toolbar.addWidget(self.user_label)
toolbar.addStretch()
self.refresh_button = QPushButton("Refresh")
self.refresh_button.clicked.connect(self._refresh_data)
self.refresh_button.setEnabled(False)
toolbar.addWidget(self.refresh_button)
self.logout_button = QPushButton("Logout")
self.logout_button.clicked.connect(self._logout)
self.logout_button.setEnabled(False)
toolbar.addWidget(self.logout_button)
layout.addLayout(toolbar)
# Main content splitter
splitter = QSplitter(Qt.Orientation.Horizontal)
# Gateway list
gateway_group = QGroupBox("Gateways")
gateway_layout = QVBoxLayout(gateway_group)
self.gateway_list = QListWidget()
self.gateway_list.currentItemChanged.connect(self._on_gateway_selected)
gateway_layout.addWidget(self.gateway_list)
splitter.addWidget(gateway_group)
# Endpoint list
endpoint_group = QGroupBox("Endpoints")
endpoint_layout = QVBoxLayout(endpoint_group)
self.endpoint_list = QListWidget()
self.endpoint_list.currentItemChanged.connect(self._on_endpoint_selected)
endpoint_layout.addWidget(self.endpoint_list)
splitter.addWidget(endpoint_group)
# Connection panel
connection_group = QGroupBox("Connection")
connection_layout = QVBoxLayout(connection_group)
self.gateway_info = QLabel("Select a gateway")
connection_layout.addWidget(self.gateway_info)
self.endpoint_info = QLabel("Select an endpoint")
connection_layout.addWidget(self.endpoint_info)
self.status_label = QLabel("Status: Disconnected")
self.status_label.setStyleSheet("font-weight: bold;")
connection_layout.addWidget(self.status_label)
connection_layout.addStretch()
# Connect button
button_layout = QHBoxLayout()
self.connect_button = QPushButton("Connect")
self.connect_button.setEnabled(False)
self.connect_button.clicked.connect(self._toggle_connection)
self.connect_button.setMinimumHeight(40)
button_layout.addWidget(self.connect_button)
connection_layout.addLayout(button_layout)
# Log area
self.log_area = QTextEdit()
self.log_area.setReadOnly(True)
self.log_area.setMaximumHeight(150)
connection_layout.addWidget(self.log_area)
splitter.addWidget(connection_group)
# Set splitter sizes
splitter.setSizes([200, 200, 400])
layout.addWidget(splitter)
# Status bar
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("Ready")
def _setup_timers(self):
"""Setup periodic timers."""
# Refresh gateway status every 30 seconds
self.status_timer = QTimer()
self.status_timer.timeout.connect(self._refresh_gateway_status)
self.status_timer.setInterval(30000)
def _show_login(self):
"""Show login dialog."""
dialog = LoginDialog(self)
while True:
if dialog.exec() != LoginDialog.DialogCode.Accepted:
self.close()
return
server, username, password = dialog.get_credentials()
if not all([server, username, password]):
dialog.show_error("Please fill all fields")
continue
# Try to connect
self.api_client = APIClient(server)
if self.api_client.login(username, password):
dialog.accept()
self._on_login_success(username)
break
else:
dialog.show_error("Login failed. Check credentials.")
def _on_login_success(self, username: str):
"""Handle successful login."""
self.user_label.setText(f"Logged in as: {username}")
self.refresh_button.setEnabled(True)
self.logout_button.setEnabled(True)
self.status_bar.showMessage("Connected to server")
self._log("Connected to server")
# Load data
self._refresh_data()
# Start status timer
self.status_timer.start()
def _logout(self):
"""Logout and show login dialog."""
# Disconnect VPN if connected
if self.vpn_manager.is_connected():
self.vpn_manager.disconnect()
if self.api_client:
self.api_client.logout()
self.api_client.close()
self.api_client = None
# Clear UI
self.gateway_list.clear()
self.endpoint_list.clear()
self.user_label.setText("Not logged in")
self.refresh_button.setEnabled(False)
self.logout_button.setEnabled(False)
self.connect_button.setEnabled(False)
self.status_timer.stop()
self._log("Logged out")
# Show login dialog
QTimer.singleShot(100, self._show_login)
def _refresh_data(self):
"""Refresh all data from server."""
if not self.api_client:
return
self._log("Refreshing data...")
self.status_bar.showMessage("Refreshing...")
# Load gateways
gateways = self.api_client.get_gateways()
self.gateway_list.clear()
for gateway in gateways:
item = QListWidgetItem()
item.setText(f"{gateway.name}")
item.setData(Qt.ItemDataRole.UserRole, gateway)
# Set color based on status
if gateway.is_online:
item.setForeground(QColor("green"))
item.setToolTip(f"Online - {gateway.location or 'No location'}")
else:
item.setForeground(QColor("gray"))
item.setToolTip("Offline")
self.gateway_list.addItem(item)
self.status_bar.showMessage(f"Loaded {len(gateways)} gateways")
self._log(f"Loaded {len(gateways)} gateways")
def _refresh_gateway_status(self):
"""Refresh only gateway online status."""
if not self.api_client:
return
status_list = self.api_client.get_gateways_status()
status_map = {s["id"]: s["is_online"] for s in status_list}
for i in range(self.gateway_list.count()):
item = self.gateway_list.item(i)
gateway: Gateway = item.data(Qt.ItemDataRole.UserRole)
if gateway.id in status_map:
is_online = status_map[gateway.id]
if is_online:
item.setForeground(QColor("green"))
else:
item.setForeground(QColor("gray"))
def _on_gateway_selected(self, current, previous):
"""Handle gateway selection."""
if not current:
self.endpoint_list.clear()
self.current_gateway = None
self.gateway_info.setText("Select a gateway")
return
gateway: Gateway = current.data(Qt.ItemDataRole.UserRole)
self.current_gateway = gateway
self.gateway_info.setText(
f"Gateway: {gateway.name}\n"
f"Type: {gateway.router_type}\n"
f"Status: {'Online' if gateway.is_online else 'Offline'}\n"
f"Location: {gateway.location or 'N/A'}"
)
# Load endpoints
self._load_endpoints(gateway.id)
def _load_endpoints(self, gateway_id: int):
"""Load endpoints for gateway."""
if not self.api_client:
return
endpoints = self.api_client.get_endpoints(gateway_id)
self.endpoint_list.clear()
for endpoint in endpoints:
item = QListWidgetItem()
app_name = endpoint.application_name or "Custom"
item.setText(f"{endpoint.name} ({app_name})")
item.setToolTip(f"{endpoint.internal_ip}:{endpoint.port} ({endpoint.protocol})")
item.setData(Qt.ItemDataRole.UserRole, endpoint)
self.endpoint_list.addItem(item)
def _on_endpoint_selected(self, current, previous):
"""Handle endpoint selection."""
if not current:
self.current_endpoint = None
self.endpoint_info.setText("Select an endpoint")
self.connect_button.setEnabled(False)
return
endpoint: Endpoint = current.data(Qt.ItemDataRole.UserRole)
self.current_endpoint = endpoint
self.endpoint_info.setText(
f"Endpoint: {endpoint.name}\n"
f"Address: {endpoint.internal_ip}:{endpoint.port}\n"
f"Protocol: {endpoint.protocol.upper()}\n"
f"Application: {endpoint.application_name or 'N/A'}"
)
# Enable connect button if gateway is online
if self.current_gateway and self.current_gateway.is_online:
self.connect_button.setEnabled(True)
else:
self.connect_button.setEnabled(False)
def _toggle_connection(self):
"""Toggle VPN connection."""
if self.vpn_manager.is_connected():
self._disconnect()
else:
self._connect()
def _connect(self):
"""Establish VPN connection."""
if not self.api_client or not self.current_gateway or not self.current_endpoint:
return
self._log(f"Connecting to {self.current_endpoint.name}...")
self.status_label.setText("Status: Connecting...")
self.connect_button.setEnabled(False)
# Request connection from server
result = self.api_client.connect(
self.current_gateway.id,
self.current_endpoint.id
)
if not result.get("success"):
self._log(f"Error: {result.get('message')}")
self.status_label.setText("Status: Connection Failed")
self.connect_button.setEnabled(True)
return
self.current_connection_id = result.get("connection_id")
vpn_config = result.get("vpn_config")
if not vpn_config:
self._log("Error: No VPN config received")
self.status_label.setText("Status: Error")
self.connect_button.setEnabled(True)
return
# Start VPN connection
status = self.vpn_manager.connect(vpn_config)
if status.connected:
self._log("VPN connected!")
self.status_label.setText("Status: Connected")
self.status_label.setStyleSheet("font-weight: bold; color: green;")
self.connect_button.setText("Disconnect")
self.connect_button.setEnabled(True)
self._log(f"Target: {result.get('target_ip')}:{result.get('target_port')}")
else:
self._log(f"VPN Error: {status.error}")
self.status_label.setText("Status: VPN Failed")
self.connect_button.setEnabled(True)
def _disconnect(self):
"""Disconnect VPN."""
self._log("Disconnecting...")
# Disconnect VPN
self.vpn_manager.disconnect()
# Notify server
if self.api_client and self.current_connection_id:
self.api_client.disconnect(self.current_connection_id)
self.current_connection_id = None
self.status_label.setText("Status: Disconnected")
self.status_label.setStyleSheet("font-weight: bold; color: black;")
self.connect_button.setText("Connect")
self._log("Disconnected")
def _log(self, message: str):
"""Add message to log area."""
from datetime import datetime
timestamp = datetime.now().strftime("%H:%M:%S")
self.log_area.append(f"[{timestamp}] {message}")
def closeEvent(self, event):
"""Handle window close."""
# Disconnect VPN if connected
if self.vpn_manager.is_connected():
self.vpn_manager.disconnect()
if self.api_client:
self.api_client.close()
event.accept()

94
docker-compose.yml Normal file
View File

@ -0,0 +1,94 @@
version: '3.8'
services:
# MariaDB Database
db:
image: mariadb:10.11
container_name: mguard-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-changeme_root}
MYSQL_DATABASE: ${DB_NAME:-mguard_vpn}
MYSQL_USER: ${DB_USER:-mguard}
MYSQL_PASSWORD: ${DB_PASSWORD:-changeme_db}
volumes:
- db_data:/var/lib/mysql
- ./server/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- mguard-network
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
# FastAPI Server + Web UI
api:
build:
context: ./server
dockerfile: Dockerfile
container_name: mguard-api
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- DATABASE_URL=mysql+pymysql://${DB_USER:-mguard}:${DB_PASSWORD:-changeme_db}@db:3306/${DB_NAME:-mguard_vpn}
- SECRET_KEY=${SECRET_KEY:-change_me_in_production_use_openssl_rand_hex_32}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
- OPENVPN_MANAGEMENT_HOST=openvpn
- OPENVPN_MANAGEMENT_PORT=7505
- VPN_SERVER_ADDRESS=${VPN_SERVER_ADDRESS:-vpn.example.com}
ports:
- "8000:8000"
volumes:
- ./server/app:/server/app
- openvpn_logs:/var/log/openvpn:ro
depends_on:
db:
condition: service_healthy
networks:
- mguard-network
# OpenVPN Multi-Server Container (Host Network)
# Manages multiple VPN server instances dynamically based on database configuration.
# No need to edit docker-compose.yml when adding new VPN servers - just create them
# via the web UI and the container will automatically start them.
openvpn:
build:
context: ./openvpn
dockerfile: Dockerfile
container_name: mguard-openvpn
restart: unless-stopped
network_mode: host
privileged: true
cap_add:
- NET_ADMIN
environment:
# API runs on localhost:8000 from container's perspective (host network)
- API_URL=http://127.0.0.1:8000/api/internal
- API_TIMEOUT=120
- API_RETRY_INTERVAL=5
- POLL_INTERVAL=30
volumes:
- openvpn_config:/etc/openvpn
- openvpn_logs:/var/log/openvpn
depends_on:
- api
volumes:
db_data:
name: mguard_db_data
openvpn_config:
name: mguard_openvpn_config
openvpn_logs:
name: mguard_openvpn_logs
networks:
mguard-network:
name: mguard_network
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16

19
mguard_router_typen.txt Normal file
View File

@ -0,0 +1,19 @@
Dieser leigt vor mir
FL MGUARD 2000/4000
Web-based Management
mGuard 10.5.x
Dieser wird auf den Schiffen verbaut:
2025-06-05
PHOENIX CONTACT 105661_de_19
Konfigurieren der mGuard Security-Appliances (Referenzhandbuch)
Firmware 8.9
UM DE MGUARD 8.9
19
Dieses Handbuch ist gültig für das mGuard Software-Release 8.9 bei Verwendung mit den folgenden
Geräten der mGuard-Familie (siehe „mGuard Firmware Version 8.9.x - Release Notes“ für weitere
Informationen):
FL MGUARD RS4000 TX/TX VPN-M

49
openvpn/Dockerfile Normal file
View File

@ -0,0 +1,49 @@
FROM alpine:3.19
LABEL maintainer="mGuard VPN Manager"
LABEL description="OpenVPN multi-server container with dynamic configuration"
# Install packages
RUN apk add --no-cache \
openvpn \
bash \
curl \
iptables \
ip6tables \
supervisor \
jq
# Create directories
RUN mkdir -p \
/etc/openvpn/servers \
/etc/openvpn/scripts \
/etc/openvpn/supervisor.d \
/var/log/openvpn \
/var/run/openvpn
# Copy configuration and scripts
COPY supervisord.conf /etc/supervisord.conf
COPY entrypoint.sh /entrypoint.sh
COPY scripts/ /etc/openvpn/scripts/
# Make scripts executable and create log file with proper permissions
RUN chmod +x /entrypoint.sh /etc/openvpn/scripts/*.sh && \
touch /var/log/openvpn/clients.log && \
chmod 666 /var/log/openvpn/clients.log
# Expose common VPN ports (actual ports depend on server configs)
# These are just defaults, actual binding happens via host network
EXPOSE 1194/udp
EXPOSE 1194/tcp
EXPOSE 443/tcp
# Volumes for persistent data
VOLUME ["/etc/openvpn", "/var/log/openvpn"]
# Environment variables
ENV API_URL="http://127.0.0.1:8000/api/internal"
ENV API_TIMEOUT="120"
ENV API_RETRY_INTERVAL="5"
ENV POLL_INTERVAL="30"
ENTRYPOINT ["/entrypoint.sh"]

406
openvpn/entrypoint.sh Normal file
View File

@ -0,0 +1,406 @@
#!/bin/bash
# =============================================================================
# mGuard VPN - Multi-Server OpenVPN Entrypoint
# =============================================================================
# This script manages multiple OpenVPN server instances dynamically.
# It polls the API for active servers and starts/stops them as needed.
# =============================================================================
set -e
# Configuration
API_URL="${API_URL:-http://127.0.0.1:8000/api/internal}"
API_TIMEOUT="${API_TIMEOUT:-120}"
API_RETRY_INTERVAL="${API_RETRY_INTERVAL:-5}"
POLL_INTERVAL="${POLL_INTERVAL:-30}"
# Directories
SERVERS_DIR="/etc/openvpn/servers"
SUPERVISOR_DIR="/etc/openvpn/supervisor.d"
LOG_DIR="/var/log/openvpn"
RUN_DIR="/var/run/openvpn"
# Ensure directories exist (volumes may override Dockerfile-created dirs)
mkdir -p "$SERVERS_DIR" "$SUPERVISOR_DIR" "$LOG_DIR" "$RUN_DIR"
# State tracking
RUNNING_SERVERS=""
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
log_error() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >&2
}
# =============================================================================
# Wait for API to be ready
# =============================================================================
wait_for_api() {
log "Waiting for API at $API_URL..."
local attempts=0
local max_attempts=$((API_TIMEOUT / API_RETRY_INTERVAL))
while [ $attempts -lt $max_attempts ]; do
if curl -sf "$API_URL/health" > /dev/null 2>&1; then
log "API is ready"
return 0
fi
attempts=$((attempts + 1))
log "API not ready, retrying in ${API_RETRY_INTERVAL}s... ($attempts/$max_attempts)"
sleep $API_RETRY_INTERVAL
done
log_error "API not available after ${API_TIMEOUT}s"
return 1
}
# =============================================================================
# Fetch active servers from API
# =============================================================================
fetch_active_servers() {
curl -sf "$API_URL/vpn-servers/active" 2>/dev/null || echo "[]"
}
# =============================================================================
# Setup a single VPN server
# =============================================================================
setup_server() {
local server_id="$1"
local server_name="$2"
local port="$3"
local protocol="$4"
local mgmt_port="$5"
local server_dir="$SERVERS_DIR/$server_id"
log "Setting up server $server_id ($server_name) on port $port/$protocol"
# Create server directory
mkdir -p "$server_dir"
# Fetch all required files
log " Fetching configuration files..."
if ! curl -sf "$API_URL/vpn-servers/$server_id/config" > "$server_dir/server.conf"; then
log_error "Failed to fetch server config for $server_id"
return 1
fi
if ! curl -sf "$API_URL/vpn-servers/$server_id/ca" > "$server_dir/ca.crt"; then
log_error "Failed to fetch CA for $server_id"
return 1
fi
if ! curl -sf "$API_URL/vpn-servers/$server_id/cert" > "$server_dir/server.crt"; then
log_error "Failed to fetch server cert for $server_id"
return 1
fi
if ! curl -sf "$API_URL/vpn-servers/$server_id/key" > "$server_dir/server.key"; then
log_error "Failed to fetch server key for $server_id"
return 1
fi
chmod 600 "$server_dir/server.key"
if ! curl -sf "$API_URL/vpn-servers/$server_id/dh" > "$server_dir/dh.pem"; then
log_error "Failed to fetch DH params for $server_id"
return 1
fi
# TA key is optional
curl -sf "$API_URL/vpn-servers/$server_id/ta" > "$server_dir/ta.key" 2>/dev/null || true
if [ -f "$server_dir/ta.key" ] && [ -s "$server_dir/ta.key" ]; then
chmod 600 "$server_dir/ta.key"
else
rm -f "$server_dir/ta.key"
fi
# CRL
curl -sf "$API_URL/vpn-servers/$server_id/crl" > "$server_dir/crl.pem" 2>/dev/null || true
# Create client-config directory
mkdir -p "$server_dir/ccd"
# Create status file location
touch "$RUN_DIR/openvpn-$server_id.status"
# Create supervisor config for this server
cat > "$SUPERVISOR_DIR/openvpn-$server_id.conf" << EOF
[program:openvpn-$server_id]
command=/usr/sbin/openvpn --config $server_dir/server.conf
directory=$server_dir
autostart=true
autorestart=true
startsecs=5
startretries=3
exitcodes=0
stopsignal=TERM
stopwaitsecs=10
stdout_logfile=$LOG_DIR/server-$server_id.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=3
stderr_logfile=$LOG_DIR/server-$server_id-error.log
stderr_logfile_maxbytes=5MB
stderr_logfile_backups=2
EOF
log " Server $server_id configured successfully"
return 0
}
# =============================================================================
# Remove a VPN server
# =============================================================================
remove_server() {
local server_id="$1"
log "Removing server $server_id..."
# Stop the process via supervisorctl if supervisor is running
if [ -S /var/run/supervisor.sock ]; then
supervisorctl stop "openvpn-$server_id" 2>/dev/null || true
supervisorctl remove "openvpn-$server_id" 2>/dev/null || true
fi
# Remove supervisor config
rm -f "$SUPERVISOR_DIR/openvpn-$server_id.conf"
# Keep server directory for logs, but mark as inactive
# rm -rf "$SERVERS_DIR/$server_id"
log " Server $server_id removed"
}
# =============================================================================
# Notify API that server started
# =============================================================================
notify_started() {
local server_id="$1"
curl -sf -X POST "$API_URL/vpn-servers/$server_id/started" > /dev/null 2>&1 || true
}
# =============================================================================
# Notify API that server stopped
# =============================================================================
notify_stopped() {
local server_id="$1"
curl -sf -X POST "$API_URL/vpn-servers/$server_id/stopped" > /dev/null 2>&1 || true
}
# =============================================================================
# Initial setup - configure all active servers
# =============================================================================
initial_setup() {
log "Performing initial server setup..."
local servers_json
servers_json=$(fetch_active_servers)
if [ "$servers_json" = "[]" ]; then
log "No active VPN servers found. Waiting for configuration via web UI..."
return 0
fi
# Parse JSON and setup each ready server
echo "$servers_json" | jq -c '.[] | select(.is_ready == true and .has_ca == true and .has_cert == true)' | while read -r server; do
local id=$(echo "$server" | jq -r '.id')
local name=$(echo "$server" | jq -r '.name')
local port=$(echo "$server" | jq -r '.port')
local protocol=$(echo "$server" | jq -r '.protocol')
local mgmt_port=$(echo "$server" | jq -r '.management_port')
if setup_server "$id" "$name" "$port" "$protocol" "$mgmt_port"; then
RUNNING_SERVERS="$RUNNING_SERVERS $id"
fi
done
log "Initial setup complete. Running servers:$RUNNING_SERVERS"
}
# =============================================================================
# Poll for changes and update servers
# =============================================================================
poll_for_changes() {
local servers_json
servers_json=$(fetch_active_servers)
# Get list of ready server IDs from API
local api_server_ids
api_server_ids=$(echo "$servers_json" | jq -r '.[] | select(.is_ready == true and .has_ca == true and .has_cert == true) | .id' | sort)
# Get list of currently configured servers
local current_server_ids
current_server_ids=$(ls -1 "$SUPERVISOR_DIR" 2>/dev/null | grep "^openvpn-" | sed 's/openvpn-\([0-9]*\)\.conf/\1/' | sort)
# Find new servers to add
for id in $api_server_ids; do
if ! echo "$current_server_ids" | grep -q "^${id}$"; then
log "New server detected: $id"
local server
server=$(echo "$servers_json" | jq -c ".[] | select(.id == $id)")
local name=$(echo "$server" | jq -r '.name')
local port=$(echo "$server" | jq -r '.port')
local protocol=$(echo "$server" | jq -r '.protocol')
local mgmt_port=$(echo "$server" | jq -r '.management_port')
if setup_server "$id" "$name" "$port" "$protocol" "$mgmt_port"; then
# Add to supervisor
if [ -S /var/run/supervisor.sock ]; then
supervisorctl reread > /dev/null 2>&1 || true
supervisorctl add "openvpn-$id" > /dev/null 2>&1 || true
fi
fi
fi
done
# Find servers to remove (no longer active)
for id in $current_server_ids; do
if ! echo "$api_server_ids" | grep -q "^${id}$"; then
log "Server $id no longer active"
remove_server "$id"
fi
done
# Update CRL for all running servers
for id in $api_server_ids; do
if [ -d "$SERVERS_DIR/$id" ]; then
curl -sf "$API_URL/vpn-servers/$id/crl" > "$SERVERS_DIR/$id/crl.pem.new" 2>/dev/null && \
mv "$SERVERS_DIR/$id/crl.pem.new" "$SERVERS_DIR/$id/crl.pem" || \
rm -f "$SERVERS_DIR/$id/crl.pem.new"
fi
done
}
# =============================================================================
# Setup iptables for NAT
# =============================================================================
setup_iptables() {
log "Setting up iptables NAT rules..."
# Enable IP forwarding (may fail in container, that's OK if host has it enabled)
if [ -w /proc/sys/net/ipv4/ip_forward ]; then
echo 1 > /proc/sys/net/ipv4/ip_forward
log " IP forwarding enabled"
else
log " IP forwarding: /proc/sys not writable (ensure host has net.ipv4.ip_forward=1)"
fi
# Basic NAT masquerade (adjust interface as needed)
if iptables -t nat -C POSTROUTING -s 10.0.0.0/8 -j MASQUERADE 2>/dev/null; then
log " NAT masquerade rule already exists"
elif iptables -t nat -A POSTROUTING -s 10.0.0.0/8 -j MASQUERADE 2>/dev/null; then
log " NAT masquerade rule added"
else
log " WARNING: Could not add NAT masquerade rule"
fi
log "iptables setup complete"
}
# =============================================================================
# Cleanup on exit
# =============================================================================
cleanup() {
log "Shutting down..."
# Notify API about all servers stopping
for conf in "$SUPERVISOR_DIR"/openvpn-*.conf; do
if [ -f "$conf" ]; then
local id=$(basename "$conf" | sed 's/openvpn-\([0-9]*\)\.conf/\1/')
notify_stopped "$id"
fi
done
# Stop supervisor
if [ -S /var/run/supervisor.sock ]; then
supervisorctl shutdown 2>/dev/null || true
fi
exit 0
}
# =============================================================================
# Main
# =============================================================================
main() {
log "=== mGuard VPN Multi-Server Container Starting ==="
log "API URL: $API_URL"
log "Poll Interval: ${POLL_INTERVAL}s"
# Setup signal handlers
trap cleanup SIGTERM SIGINT
# Wait for API
if ! wait_for_api; then
log_error "Cannot start without API"
exit 1
fi
# Setup iptables
setup_iptables
# Initial setup of all servers
initial_setup
# Check if any servers were configured
if [ -z "$(ls -A $SUPERVISOR_DIR 2>/dev/null)" ]; then
log "No VPN servers configured yet."
log "Please create a CA and VPN server via the web UI."
log "Container will poll every ${POLL_INTERVAL}s for new servers..."
# Start supervisor anyway (will have no programs)
/usr/bin/supervisord -c /etc/supervisord.conf &
SUPERVISOR_PID=$!
# Polling loop waiting for first server
while true; do
sleep $POLL_INTERVAL
poll_for_changes
# Check if supervisor is still running
if ! kill -0 $SUPERVISOR_PID 2>/dev/null; then
log_error "Supervisor died unexpectedly"
exit 1
fi
done
else
log "Starting supervisord with configured servers..."
# Start supervisor in background
/usr/bin/supervisord -c /etc/supervisord.conf &
SUPERVISOR_PID=$!
# Wait a moment for supervisor to start
sleep 3
# Notify API about started servers
for conf in "$SUPERVISOR_DIR"/openvpn-*.conf; do
if [ -f "$conf" ]; then
local id=$(basename "$conf" | sed 's/openvpn-\([0-9]*\)\.conf/\1/')
# Check if actually running
if supervisorctl status "openvpn-$id" 2>/dev/null | grep -q RUNNING; then
notify_started "$id"
fi
fi
done
log "All servers started. Entering polling mode..."
# Polling loop for changes
while true; do
sleep $POLL_INTERVAL
poll_for_changes
# Check if supervisor is still running
if ! kill -0 $SUPERVISOR_PID 2>/dev/null; then
log_error "Supervisor died unexpectedly"
exit 1
fi
done
fi
}
# Run main
main

View File

@ -0,0 +1,23 @@
#!/bin/bash
# OpenVPN client-connect script
# Called when a client connects successfully
# Environment variables provided by OpenVPN:
# - common_name: Client certificate CN
# - trusted_ip / untrusted_ip: Client's real IP
# - ifconfig_pool_remote_ip: Assigned VPN IP
# - dev: TUN/TAP device
# - time_unix: Connection timestamp
# Log connection (optional - log file might not be writable)
echo "$(date '+%Y-%m-%d %H:%M:%S') CONNECT: CN=$common_name IP=$trusted_ip VPN_IP=$ifconfig_pool_remote_ip" >> /var/log/openvpn/clients.log 2>/dev/null || true
# Notify API about connection (optional)
if [ -n "$API_URL" ]; then
curl -s -X POST "$API_URL/vpn-servers/${VPN_SERVER_ID:-1}/client-connected" \
-H "Content-Type: application/json" \
-d "{\"common_name\": \"$common_name\", \"real_ip\": \"$trusted_ip\", \"vpn_ip\": \"$ifconfig_pool_remote_ip\"}" \
2>/dev/null || true
fi
exit 0

View File

@ -0,0 +1,24 @@
#!/bin/bash
# OpenVPN client-disconnect script
# Called when a client disconnects
# Environment variables provided by OpenVPN:
# - common_name: Client certificate CN
# - trusted_ip / untrusted_ip: Client's real IP
# - ifconfig_pool_remote_ip: Assigned VPN IP
# - bytes_received: Total bytes received from client
# - bytes_sent: Total bytes sent to client
# - time_duration: Connection duration in seconds
# Log disconnection (optional - log file might not be writable)
echo "$(date '+%Y-%m-%d %H:%M:%S') DISCONNECT: CN=$common_name Duration=${time_duration}s RX=$bytes_received TX=$bytes_sent" >> /var/log/openvpn/clients.log 2>/dev/null || true
# Notify API about disconnection (optional)
if [ -n "$API_URL" ]; then
curl -s -X POST "$API_URL/vpn-servers/${VPN_SERVER_ID:-1}/client-disconnected" \
-H "Content-Type: application/json" \
-d "{\"common_name\": \"$common_name\", \"bytes_received\": $bytes_received, \"bytes_sent\": $bytes_sent, \"duration\": $time_duration}" \
2>/dev/null || true
fi
exit 0

22
openvpn/supervisord.conf Normal file
View File

@ -0,0 +1,22 @@
[supervisord]
nodaemon=true
logfile=/var/log/openvpn/supervisord.log
logfile_maxbytes=10MB
logfile_backups=3
loglevel=info
pidfile=/var/run/supervisord.pid
user=root
[unix_http_server]
file=/var/run/supervisor.sock
chmod=0700
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
; Include dynamically generated OpenVPN server configs
[include]
files = /etc/openvpn/supervisor.d/*.conf

View File

@ -0,0 +1,206 @@
"""Configuration generator for mGuard routers."""
from dataclasses import dataclass
from typing import Optional
import json
@dataclass
class GatewayConfig:
"""Gateway configuration data."""
name: str
vpn_server: str
vpn_port: int
ca_cert: str
client_cert: str
client_key: str
ta_key: Optional[str] = None
class ConfigGenerator:
"""Generate configuration files for mGuard routers."""
@staticmethod
def generate_openvpn_config(config: GatewayConfig) -> str:
"""Generate OpenVPN client configuration.
Args:
config: Gateway configuration data
Returns:
OpenVPN config file content
"""
ovpn = f"""# OpenVPN Client Configuration
# Generated for: {config.name}
client
dev tun
proto udp
remote {config.vpn_server} {config.vpn_port}
resolv-retry infinite
nobind
persist-key
persist-tun
remote-cert-tls server
cipher AES-256-GCM
auth SHA256
verb 3
<ca>
{config.ca_cert}
</ca>
<cert>
{config.client_cert}
</cert>
<key>
{config.client_key}
</key>
"""
if config.ta_key:
ovpn += f"""
<tls-auth>
{config.ta_key}
</tls-auth>
key-direction 1
"""
return ovpn
@staticmethod
def generate_atv_vpn_section(config: GatewayConfig) -> str:
"""Generate ATV configuration section for VPN.
Note: This is a simplified version. Real ATV files have
a more complex structure that should be merged with existing config.
Args:
config: Gateway configuration data
Returns:
ATV config section
"""
# ATV is essentially a key-value format
# This is a simplified representation
atv_section = f"""
[vpn_client_1]
enabled = 1
name = {config.name}
type = openvpn
remote = {config.vpn_server}
port = {config.vpn_port}
protocol = udp
cipher = AES-256-GCM
"""
return atv_section
@staticmethod
def generate_mguard_script(
gateway_name: str,
endpoints: list[dict]
) -> str:
"""Generate mGuard CLI script for firewall configuration.
Args:
gateway_name: Name of the gateway
endpoints: List of endpoint configurations
Returns:
Shell script for mGuard CLI
"""
script = f"""#!/bin/bash
# Firewall configuration script for {gateway_name}
# Run this on the mGuard via SSH
MBIN="/Packages/mguard-api_0/mbin"
echo "Configuring firewall rules for {gateway_name}..."
"""
for i, ep in enumerate(endpoints):
rule_name = f"endpoint_{i}_{ep['name'].replace(' ', '_')}"
script += f"""
# Rule for {ep['name']}
$MBIN/action fwrules/add \\
--name "{rule_name}" \\
--source "any" \\
--destination "{ep['internal_ip']}" \\
--port "{ep['port']}" \\
--protocol "{ep['protocol']}" \\
--action "accept"
"""
script += """
echo "Firewall rules configured."
$MBIN/action config/save
echo "Configuration saved."
"""
return script
def create_provisioning_package(
gateway_name: str,
vpn_server: str,
vpn_port: int,
ca_cert: str,
client_cert: str,
client_key: str,
endpoints: list[dict],
output_dir: str = "."
) -> dict:
"""Create a complete provisioning package.
Args:
gateway_name: Name of the gateway
vpn_server: VPN server address
vpn_port: VPN server port
ca_cert: CA certificate content
client_cert: Client certificate content
client_key: Client private key content
endpoints: List of endpoint configurations
output_dir: Output directory for files
Returns:
Dictionary with file paths
"""
from pathlib import Path
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
config = GatewayConfig(
name=gateway_name,
vpn_server=vpn_server,
vpn_port=vpn_port,
ca_cert=ca_cert,
client_cert=client_cert,
client_key=client_key
)
# Generate OpenVPN config
ovpn_content = ConfigGenerator.generate_openvpn_config(config)
ovpn_file = output_path / f"{gateway_name}.ovpn"
ovpn_file.write_text(ovpn_content)
# Generate firewall script
fw_script = ConfigGenerator.generate_mguard_script(gateway_name, endpoints)
fw_file = output_path / f"{gateway_name}_firewall.sh"
fw_file.write_text(fw_script)
# Generate info JSON
info = {
"gateway_name": gateway_name,
"vpn_server": vpn_server,
"vpn_port": vpn_port,
"endpoints": endpoints
}
info_file = output_path / f"{gateway_name}_info.json"
info_file.write_text(json.dumps(info, indent=2))
return {
"ovpn": str(ovpn_file),
"firewall_script": str(fw_file),
"info": str(info_file)
}

314
provisioning-tool/main.py Normal file
View File

@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""mGuard Provisioning Tool - CLI for gateway provisioning."""
import click
from rich.console import Console
from rich.table import Table
from mguard_api import MGuardAPIClient
from config_generator import ConfigGenerator
console = Console()
@click.group()
@click.option('--server', '-s', default='http://localhost:8000', help='API server URL')
@click.option('--username', '-u', prompt=True, help='Admin username')
@click.option('--password', '-p', prompt=True, hide_input=True, help='Admin password')
@click.pass_context
def cli(ctx, server, username, password):
"""mGuard Gateway Provisioning Tool.
Use this tool to provision and configure mGuard routers.
"""
ctx.ensure_object(dict)
# Login to API
import httpx
client = httpx.Client(timeout=30.0)
try:
response = client.post(
f"{server}/api/auth/login",
json={"username": username, "password": password}
)
response.raise_for_status()
tokens = response.json()
ctx.obj['server'] = server
ctx.obj['token'] = tokens['access_token']
ctx.obj['client'] = client
console.print("[green]Successfully authenticated[/green]")
except httpx.HTTPError as e:
console.print(f"[red]Authentication failed: {e}[/red]")
raise click.Abort()
@cli.command()
@click.pass_context
def list_gateways(ctx):
"""List all gateways."""
client = ctx.obj['client']
server = ctx.obj['server']
token = ctx.obj['token']
response = client.get(
f"{server}/api/gateways",
headers={"Authorization": f"Bearer {token}"}
)
gateways = response.json()
table = Table(title="Gateways")
table.add_column("ID", style="cyan")
table.add_column("Name", style="green")
table.add_column("Type")
table.add_column("Status")
table.add_column("Provisioned")
for gw in gateways:
status = "[green]Online[/green]" if gw['is_online'] else "[red]Offline[/red]"
prov = "[green]Yes[/green]" if gw['is_provisioned'] else "[yellow]No[/yellow]"
table.add_row(
str(gw['id']),
gw['name'],
gw['router_type'],
status,
prov
)
console.print(table)
@cli.command()
@click.argument('gateway_id', type=int)
@click.option('--output', '-o', default=None, help='Output file path')
@click.pass_context
def download_config(ctx, gateway_id, output):
"""Download provisioning config for a gateway."""
client = ctx.obj['client']
server = ctx.obj['server']
token = ctx.obj['token']
response = client.get(
f"{server}/api/gateways/{gateway_id}/provision",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code != 200:
console.print(f"[red]Error: {response.text}[/red]")
return
config = response.text
if output:
with open(output, 'w') as f:
f.write(config)
console.print(f"[green]Config saved to {output}[/green]")
else:
output_file = f"gateway-{gateway_id}.ovpn"
with open(output_file, 'w') as f:
f.write(config)
console.print(f"[green]Config saved to {output_file}[/green]")
@cli.command()
@click.argument('gateway_id', type=int)
@click.argument('router_ip')
@click.option('--router-user', '-u', default='admin', help='Router username')
@click.option('--router-pass', '-p', prompt=True, hide_input=True, help='Router password')
@click.pass_context
def provision_online(ctx, gateway_id, router_ip, router_user, router_pass):
"""Provision a gateway via network (REST API or SSH).
GATEWAY_ID: ID of the gateway in the server database
ROUTER_IP: IP address of the mGuard router
"""
client = ctx.obj['client']
server = ctx.obj['server']
token = ctx.obj['token']
console.print(f"[yellow]Connecting to router at {router_ip}...[/yellow]")
# Get gateway info
response = client.get(
f"{server}/api/gateways/{gateway_id}",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code != 200:
console.print(f"[red]Error: Gateway not found[/red]")
return
gateway = response.json()
firmware = gateway.get('firmware_version', '')
# Determine provisioning method
if firmware and firmware.startswith('10.'):
console.print("[cyan]Using REST API provisioning (Firmware 10.x)[/cyan]")
_provision_rest_api(ctx, gateway, router_ip, router_user, router_pass)
else:
console.print("[cyan]Using SSH provisioning (Legacy firmware)[/cyan]")
_provision_ssh(ctx, gateway, router_ip, router_user, router_pass)
def _provision_rest_api(ctx, gateway, router_ip, router_user, router_pass):
"""Provision via mGuard REST API."""
mguard = MGuardAPIClient(router_ip, router_user, router_pass)
try:
# Test connection
if not mguard.test_connection():
console.print("[red]Cannot connect to router REST API[/red]")
return
# Download VPN config from server
client = ctx.obj['client']
server = ctx.obj['server']
token = ctx.obj['token']
response = client.get(
f"{server}/api/gateways/{gateway['id']}/provision",
headers={"Authorization": f"Bearer {token}"}
)
vpn_config = response.text
# Apply VPN configuration
console.print("[yellow]Applying VPN configuration...[/yellow]")
if mguard.configure_vpn(vpn_config):
console.print("[green]VPN configuration applied successfully![/green]")
else:
console.print("[red]Failed to apply VPN configuration[/red]")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
def _provision_ssh(ctx, gateway, router_ip, router_user, router_pass):
"""Provision via SSH (legacy routers)."""
import paramiko
try:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(router_ip, username=router_user, password=router_pass, timeout=10)
console.print("[green]SSH connection established[/green]")
# Download VPN config from server
client = ctx.obj['client']
server = ctx.obj['server']
token = ctx.obj['token']
response = client.get(
f"{server}/api/gateways/{gateway['id']}/provision",
headers={"Authorization": f"Bearer {token}"}
)
vpn_config = response.text
# Upload config file
sftp = ssh.open_sftp()
with sftp.file('/tmp/vpn.ovpn', 'w') as f:
f.write(vpn_config)
console.print("[yellow]VPN config uploaded[/yellow]")
# Apply configuration (mGuard-specific commands)
stdin, stdout, stderr = ssh.exec_command(
'/Packages/mguard-api_0/mbin/action vpn/import /tmp/vpn.ovpn'
)
result = stdout.read().decode()
if 'error' in result.lower():
console.print(f"[red]Error: {result}[/red]")
else:
console.print("[green]VPN configuration applied![/green]")
ssh.close()
except paramiko.AuthenticationException:
console.print("[red]SSH authentication failed[/red]")
except paramiko.SSHException as e:
console.print(f"[red]SSH error: {e}[/red]")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
@cli.command()
@click.argument('config_file')
@click.argument('router_ip')
@click.option('--router-user', '-u', default='admin', help='Router username')
@click.option('--router-pass', '-p', prompt=True, hide_input=True, help='Router password')
def provision_offline(config_file, router_ip, router_user, router_pass):
"""Provision a gateway using a downloaded config file.
CONFIG_FILE: Path to the .ovpn or .atv config file
ROUTER_IP: IP address of the mGuard router (must be on same network)
"""
import os
from pathlib import Path
config_path = Path(config_file)
if not config_path.exists():
console.print(f"[red]Config file not found: {config_file}[/red]")
return
console.print(f"[yellow]Loading config from {config_file}...[/yellow]")
config_content = config_path.read_text()
# Determine file type
if config_file.endswith('.ovpn'):
console.print("[cyan]OpenVPN config detected[/cyan]")
elif config_file.endswith('.atv'):
console.print("[cyan]mGuard ATV config detected[/cyan]")
else:
console.print("[yellow]Unknown config format, attempting generic upload[/yellow]")
# Try REST API first, then SSH
mguard = MGuardAPIClient(router_ip, router_user, router_pass)
if mguard.test_connection():
console.print("[cyan]Using REST API...[/cyan]")
if mguard.upload_config(config_content):
console.print("[green]Configuration uploaded successfully![/green]")
else:
console.print("[red]REST API upload failed[/red]")
else:
console.print("[cyan]REST API not available, trying SSH...[/cyan]")
import paramiko
try:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(router_ip, username=router_user, password=router_pass, timeout=10)
sftp = ssh.open_sftp()
remote_path = f'/tmp/{config_path.name}'
with sftp.file(remote_path, 'w') as f:
f.write(config_content)
console.print(f"[green]Config uploaded to {remote_path}[/green]")
# Apply based on file type
if config_file.endswith('.atv'):
stdin, stdout, stderr = ssh.exec_command(
f'/Packages/mguard-api_0/mbin/action config/restore {remote_path}'
)
else:
stdin, stdout, stderr = ssh.exec_command(
f'/Packages/mguard-api_0/mbin/action vpn/import {remote_path}'
)
result = stdout.read().decode()
console.print(f"Result: {result}")
ssh.close()
console.print("[green]Provisioning complete![/green]")
except Exception as e:
console.print(f"[red]SSH provisioning failed: {e}[/red]")
if __name__ == '__main__':
cli(obj={})

View File

@ -0,0 +1,175 @@
"""mGuard REST API client for router configuration."""
import httpx
from typing import Optional
import urllib3
# Disable SSL warnings for self-signed certificates
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class MGuardAPIClient:
"""Client for mGuard REST API (Firmware 10.x+)."""
def __init__(self, host: str, username: str, password: str, port: int = 443):
"""Initialize mGuard API client.
Args:
host: Router IP address or hostname
username: Admin username
password: Admin password
port: HTTPS port (default 443)
"""
self.base_url = f"https://{host}:{port}"
self.username = username
self.password = password
self.client = httpx.Client(
timeout=30.0,
verify=False, # mGuard uses self-signed certs
auth=(username, password)
)
def test_connection(self) -> bool:
"""Test connection to mGuard REST API."""
try:
response = self.client.get(f"{self.base_url}/api/v1/info")
return response.status_code == 200
except httpx.HTTPError:
return False
def get_system_info(self) -> Optional[dict]:
"""Get system information."""
try:
response = self.client.get(f"{self.base_url}/api/v1/info")
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
def get_configuration(self) -> Optional[dict]:
"""Get current configuration."""
try:
response = self.client.get(f"{self.base_url}/api/v1/configuration")
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
def configure_vpn(self, vpn_config: str) -> bool:
"""Configure OpenVPN client.
Args:
vpn_config: OpenVPN configuration content
Returns:
True if successful
"""
try:
# The actual API endpoint depends on mGuard firmware version
# This is an example - actual implementation may vary
response = self.client.post(
f"{self.base_url}/api/v1/vpn/openvpn/client",
json={
"enabled": True,
"config": vpn_config
}
)
return response.status_code in (200, 201, 204)
except httpx.HTTPError:
return False
def upload_config(self, config_content: str) -> bool:
"""Upload configuration file.
Args:
config_content: Configuration file content
Returns:
True if successful
"""
try:
response = self.client.post(
f"{self.base_url}/api/v1/configuration",
content=config_content,
headers={"Content-Type": "application/octet-stream"}
)
return response.status_code in (200, 201, 204)
except httpx.HTTPError:
return False
def add_firewall_rule(
self,
name: str,
source: str,
destination: str,
port: int,
protocol: str = "tcp",
action: str = "accept"
) -> bool:
"""Add a firewall rule.
Args:
name: Rule name
source: Source IP/network
destination: Destination IP/network
port: Destination port
protocol: tcp or udp
action: accept or drop
Returns:
True if successful
"""
try:
response = self.client.post(
f"{self.base_url}/api/v1/firewall/rules",
json={
"name": name,
"source": source,
"destination": destination,
"port": port,
"protocol": protocol,
"action": action,
"enabled": True
}
)
return response.status_code in (200, 201, 204)
except httpx.HTTPError:
return False
def remove_firewall_rule(self, rule_id: str) -> bool:
"""Remove a firewall rule.
Args:
rule_id: Rule ID to remove
Returns:
True if successful
"""
try:
response = self.client.delete(
f"{self.base_url}/api/v1/firewall/rules/{rule_id}"
)
return response.status_code in (200, 204)
except httpx.HTTPError:
return False
def reboot(self) -> bool:
"""Reboot the router."""
try:
response = self.client.post(f"{self.base_url}/api/v1/actions/reboot")
return response.status_code in (200, 202, 204)
except httpx.HTTPError:
return False
def get_vpn_status(self) -> Optional[dict]:
"""Get VPN connection status."""
try:
response = self.client.get(f"{self.base_url}/api/v1/vpn/status")
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
def close(self):
"""Close the HTTP client."""
self.client.close()

View File

@ -0,0 +1,14 @@
# HTTP Client
httpx==0.26.0
requests==2.31.0
# SSH for legacy routers
paramiko==3.4.0
# CLI Interface
click==8.1.7
rich==13.7.0
# Configuration
python-dotenv==1.0.0
pyyaml==6.0.1

28
server/Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM python:3.11-slim
WORKDIR /server
# Install system dependencies including Easy-RSA for certificate generation
RUN apt-get update && apt-get install -y \
gcc \
libmariadb-dev \
pkg-config \
iptables \
easy-rsa \
&& rm -rf /var/lib/apt/lists/* \
&& ln -s /usr/share/easy-rsa/easyrsa /usr/local/bin/easyrsa
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code as package
COPY app/ /server/app/
# Create non-root user
RUN useradd -m -u 1000 appuser
# Note: Running as root for iptables access, but API endpoints are protected
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

2
server/app/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# mGuard VPN Endpoint Server
__version__ = "1.0.0"

View File

@ -0,0 +1,15 @@
"""API routes."""
from fastapi import APIRouter
from . import auth, users, tenants, gateways, endpoints, connections
# Create main API router
api_router = APIRouter(prefix="/api")
# Include all route modules
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(tenants.router, prefix="/tenants", tags=["Tenants"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(gateways.router, prefix="/gateways", tags=["Gateways"])
api_router.include_router(endpoints.router, prefix="/endpoints", tags=["Endpoints"])
api_router.include_router(connections.router, prefix="/connections", tags=["Connections"])

57
server/app/api/auth.py Normal file
View File

@ -0,0 +1,57 @@
"""Authentication API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..schemas.user import UserLogin, Token, UserResponse
from ..services.auth_service import AuthService
from .deps import get_current_user
from ..models.user import User
router = APIRouter()
@router.post("/login", response_model=Token)
def login(
credentials: UserLogin,
db: Session = Depends(get_db)
):
"""Authenticate user and return JWT tokens."""
auth_service = AuthService(db)
user = auth_service.authenticate_user(credentials.username, credentials.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"}
)
return auth_service.create_tokens(user)
@router.post("/refresh", response_model=Token)
def refresh_token(
refresh_token: str,
db: Session = Depends(get_db)
):
"""Refresh access token using refresh token."""
auth_service = AuthService(db)
tokens = auth_service.refresh_tokens(refresh_token)
if not tokens:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
headers={"WWW-Authenticate": "Bearer"}
)
return tokens
@router.get("/me", response_model=UserResponse)
def get_current_user_info(
current_user: User = Depends(get_current_user)
):
"""Get current authenticated user information."""
return current_user

View File

@ -0,0 +1,236 @@
"""Connection management API routes."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.endpoint import Endpoint
from ..models.gateway import Gateway
from ..models.user import User, UserRole
from ..models.access import UserGatewayAccess, ConnectionLog
from ..services.vpn_service import VPNService
from ..services.firewall_service import FirewallService
from .deps import get_current_user
router = APIRouter()
class ConnectRequest(BaseModel):
"""Request to establish connection to endpoint."""
gateway_id: int
endpoint_id: int
class ConnectResponse(BaseModel):
"""Response with connection details."""
success: bool
message: str
vpn_config: str | None = None
target_ip: str | None = None
target_port: int | None = None
connection_id: int | None = None
class DisconnectRequest(BaseModel):
"""Request to disconnect from endpoint."""
connection_id: int
def user_has_gateway_access(db: Session, user: User, gateway_id: int) -> bool:
"""Check if user has access to gateway."""
if user.role in (UserRole.SUPER_ADMIN, UserRole.ADMIN):
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if user.role == UserRole.SUPER_ADMIN:
return gateway is not None
return gateway and gateway.tenant_id == user.tenant_id
access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.user_id == user.id,
UserGatewayAccess.gateway_id == gateway_id
).first()
return access is not None
@router.post("/connect", response_model=ConnectResponse)
def connect_to_endpoint(
request: ConnectRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Request connection to a specific endpoint through a gateway."""
# Check gateway access
if not user_has_gateway_access(db, current_user, request.gateway_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No access to this gateway"
)
# Get gateway
gateway = db.query(Gateway).filter(Gateway.id == request.gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found"
)
if not gateway.is_online:
return ConnectResponse(
success=False,
message="Gateway is offline"
)
# Get endpoint
endpoint = db.query(Endpoint).filter(
Endpoint.id == request.endpoint_id,
Endpoint.gateway_id == request.gateway_id
).first()
if not endpoint:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Endpoint not found"
)
# NOTE: Dynamic VPN config generation has been replaced by VPN profiles.
# Gateways should have pre-provisioned VPN profiles.
# This endpoint now just logs the connection intent.
# Log connection
connection = ConnectionLog(
user_id=current_user.id,
gateway_id=gateway.id,
endpoint_id=endpoint.id,
client_ip=None # Will be updated when VPN connects
)
db.add(connection)
db.commit()
db.refresh(connection)
return ConnectResponse(
success=True,
message="Connection logged. Use the gateway's VPN profile configuration to connect.",
vpn_config=None, # VPN config is now obtained through gateway VPN profiles
target_ip=endpoint.internal_ip,
target_port=endpoint.port,
connection_id=connection.id
)
@router.post("/disconnect")
def disconnect_from_endpoint(
request: DisconnectRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Disconnect from endpoint."""
connection = db.query(ConnectionLog).filter(
ConnectionLog.id == request.connection_id,
ConnectionLog.user_id == current_user.id
).first()
if not connection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Connection not found"
)
if connection.disconnected_at:
return {"message": "Already disconnected"}
# Revoke firewall rules if VPN IP is known
if connection.vpn_ip:
endpoint = db.query(Endpoint).filter(Endpoint.id == connection.endpoint_id).first()
gateway = db.query(Gateway).filter(Gateway.id == connection.gateway_id).first()
if endpoint and gateway:
firewall = FirewallService()
firewall.revoke_connection(
client_vpn_ip=connection.vpn_ip,
gateway_vpn_ip=gateway.vpn_ip,
target_ip=endpoint.internal_ip,
target_port=endpoint.port,
protocol=endpoint.protocol.value
)
# Disconnect VPN client
vpn_service = VPNService()
client_cn = f"client-{current_user.username}-{current_user.id}"
vpn_service.disconnect_client(client_cn)
# Update log
connection.disconnected_at = datetime.utcnow()
db.commit()
return {"message": "Disconnected successfully"}
@router.get("/active")
def list_active_connections(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List active connections for current user."""
connections = db.query(ConnectionLog).filter(
ConnectionLog.user_id == current_user.id,
ConnectionLog.disconnected_at.is_(None)
).all()
result = []
for conn in connections:
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first()
result.append({
"connection_id": conn.id,
"gateway_name": gateway.name if gateway else None,
"endpoint_name": endpoint.name if endpoint else None,
"endpoint_address": endpoint.address if endpoint else None,
"connected_at": conn.connected_at.isoformat(),
"vpn_ip": conn.vpn_ip
})
return result
@router.get("/history")
def get_connection_history(
skip: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get connection history for current user."""
query = db.query(ConnectionLog).filter(ConnectionLog.user_id == current_user.id)
# Admins can see all connections in their tenant
if current_user.is_admin:
if current_user.role == UserRole.SUPER_ADMIN:
query = db.query(ConnectionLog)
else:
# Filter by tenant's gateways
query = db.query(ConnectionLog).join(
Gateway,
Gateway.id == ConnectionLog.gateway_id
).filter(Gateway.tenant_id == current_user.tenant_id)
connections = query.order_by(ConnectionLog.connected_at.desc()).offset(skip).limit(limit).all()
result = []
for conn in connections:
user = db.query(User).filter(User.id == conn.user_id).first()
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first()
result.append({
"connection_id": conn.id,
"username": user.username if user else None,
"gateway_name": gateway.name if gateway else None,
"endpoint_name": endpoint.name if endpoint else None,
"connected_at": conn.connected_at.isoformat(),
"disconnected_at": conn.disconnected_at.isoformat() if conn.disconnected_at else None,
"duration_seconds": conn.duration_seconds,
"client_ip": conn.client_ip
})
return result

87
server/app/api/deps.py Normal file
View File

@ -0,0 +1,87 @@
"""API dependencies for authentication and authorization."""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
from ..utils.security import decode_token
# Bearer token security scheme
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""Get current authenticated user from JWT token."""
token = credentials.credentials
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"}
)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
headers={"WWW-Authenticate": "Bearer"}
)
user_id = payload.get("sub")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated"
)
return user
def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""Ensure user is active."""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)
return current_user
def require_admin(
current_user: User = Depends(get_current_user)
) -> User:
"""Require admin role."""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required"
)
return current_user
def require_super_admin(
current_user: User = Depends(get_current_user)
) -> User:
"""Require super admin role."""
if current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Super admin privileges required"
)
return current_user

231
server/app/api/endpoints.py Normal file
View File

@ -0,0 +1,231 @@
"""Endpoint management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.endpoint import Endpoint, ApplicationTemplate
from ..models.gateway import Gateway
from ..models.user import User, UserRole
from ..models.access import UserGatewayAccess, UserEndpointAccess
from ..schemas.endpoint import (
EndpointCreate, EndpointUpdate, EndpointResponse,
ApplicationTemplateResponse
)
from .deps import get_current_user, require_admin
router = APIRouter()
def user_has_gateway_access(db: Session, user: User, gateway_id: int) -> bool:
"""Check if user has access to gateway."""
if user.role == UserRole.SUPER_ADMIN:
return True
if user.role == UserRole.ADMIN:
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
return gateway and gateway.tenant_id == user.tenant_id
access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.user_id == user.id,
UserGatewayAccess.gateway_id == gateway_id
).first()
return access is not None
@router.get("/templates", response_model=list[ApplicationTemplateResponse])
def list_application_templates(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List available application templates."""
templates = db.query(ApplicationTemplate).all()
return templates
@router.get("/gateway/{gateway_id}", response_model=list[EndpointResponse])
def list_endpoints_for_gateway(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List endpoints for a gateway."""
if not user_has_gateway_access(db, current_user, gateway_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No access to this gateway"
)
endpoints = db.query(Endpoint).filter(Endpoint.gateway_id == gateway_id).all()
return endpoints
@router.post("/gateway/{gateway_id}", response_model=EndpointResponse, status_code=status.HTTP_201_CREATED)
def create_endpoint(
gateway_id: int,
endpoint_data: EndpointCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Create a new endpoint for a gateway."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if gateway.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot add endpoints to gateways from other tenants"
)
endpoint = Endpoint(
**endpoint_data.model_dump(),
gateway_id=gateway_id
)
db.add(endpoint)
db.commit()
db.refresh(endpoint)
return endpoint
@router.get("/{endpoint_id}", response_model=EndpointResponse)
def get_endpoint(
endpoint_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get endpoint by ID."""
endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first()
if not endpoint:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Endpoint not found"
)
if not user_has_gateway_access(db, current_user, endpoint.gateway_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No access to this endpoint"
)
return endpoint
@router.put("/{endpoint_id}", response_model=EndpointResponse)
def update_endpoint(
endpoint_id: int,
endpoint_data: EndpointUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Update endpoint."""
endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first()
if not endpoint:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Endpoint not found"
)
gateway = db.query(Gateway).filter(Gateway.id == endpoint.gateway_id).first()
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if gateway.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify endpoints from other tenants"
)
update_data = endpoint_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(endpoint, field, value)
db.commit()
db.refresh(endpoint)
return endpoint
@router.delete("/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_endpoint(
endpoint_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Delete endpoint."""
endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first()
if not endpoint:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Endpoint not found"
)
gateway = db.query(Gateway).filter(Gateway.id == endpoint.gateway_id).first()
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if gateway.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete endpoints from other tenants"
)
db.delete(endpoint)
db.commit()
@router.post("/{endpoint_id}/access/{user_id}", status_code=status.HTTP_201_CREATED)
def grant_endpoint_access(
endpoint_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Grant user access to specific endpoint (fine-grained control)."""
endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first()
if not endpoint:
raise HTTPException(status_code=404, detail="Endpoint not found")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(UserEndpointAccess).filter(
UserEndpointAccess.endpoint_id == endpoint_id,
UserEndpointAccess.user_id == user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Access already granted")
access = UserEndpointAccess(
user_id=user_id,
endpoint_id=endpoint_id,
granted_by_id=current_user.id
)
db.add(access)
db.commit()
return {"message": "Access granted"}
@router.delete("/{endpoint_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke_endpoint_access(
endpoint_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Revoke user access to endpoint."""
access = db.query(UserEndpointAccess).filter(
UserEndpointAccess.endpoint_id == endpoint_id,
UserEndpointAccess.user_id == user_id
).first()
if not access:
raise HTTPException(status_code=404, detail="Access not found")
db.delete(access)
db.commit()

335
server/app/api/gateways.py Normal file
View File

@ -0,0 +1,335 @@
"""Gateway management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status, Response
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.gateway import Gateway
from ..models.user import User, UserRole
from ..models.access import UserGatewayAccess
from ..models.vpn_connection_log import VPNConnectionLog
from ..schemas.gateway import GatewayCreate, GatewayUpdate, GatewayResponse, GatewayStatus
from ..services.vpn_sync_service import VPNSyncService
from .deps import get_current_user, require_admin
router = APIRouter()
def get_accessible_gateways_query(db: Session, user: User):
"""Get query for gateways accessible by user."""
if user.role == UserRole.SUPER_ADMIN:
return db.query(Gateway)
elif user.role == UserRole.ADMIN:
return db.query(Gateway).filter(Gateway.tenant_id == user.tenant_id)
else:
# Technicians and viewers only see assigned gateways
return db.query(Gateway).join(
UserGatewayAccess,
UserGatewayAccess.gateway_id == Gateway.id
).filter(UserGatewayAccess.user_id == user.id)
@router.get("/", response_model=list[GatewayResponse])
def list_gateways(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List gateways accessible by current user."""
query = get_accessible_gateways_query(db, current_user)
gateways = query.offset(skip).limit(limit).all()
return gateways
@router.get("/status", response_model=list[GatewayStatus])
def get_gateways_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get online status of all accessible gateways."""
query = get_accessible_gateways_query(db, current_user)
gateways = query.all()
return [
GatewayStatus(
id=gw.id,
name=gw.name,
is_online=gw.is_online,
last_seen=gw.last_seen,
vpn_ip=gw.vpn_ip
)
for gw in gateways
]
@router.post("/", response_model=GatewayResponse, status_code=status.HTTP_201_CREATED)
def create_gateway(
gateway_data: GatewayCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Create a new gateway (admin only)."""
# Determine tenant_id
if current_user.role == UserRole.SUPER_ADMIN:
# Super admin must specify tenant_id via query param or we use a default
tenant_id = current_user.tenant_id or 1 # Fallback
else:
tenant_id = current_user.tenant_id
# Generate VPN certificate CN
vpn_cert_cn = f"gateway-{gateway_data.name.lower().replace(' ', '-')}"
gateway = Gateway(
**gateway_data.model_dump(),
tenant_id=tenant_id,
vpn_cert_cn=vpn_cert_cn
)
db.add(gateway)
db.commit()
db.refresh(gateway)
return gateway
@router.get("/{gateway_id}", response_model=GatewayResponse)
def get_gateway(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get gateway by ID."""
query = get_accessible_gateways_query(db, current_user)
gateway = query.filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found or access denied"
)
return gateway
@router.put("/{gateway_id}", response_model=GatewayResponse)
def update_gateway(
gateway_id: int,
gateway_data: GatewayUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Update gateway (admin only)."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if gateway.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify gateways from other tenants"
)
update_data = gateway_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(gateway, field, value)
db.commit()
db.refresh(gateway)
return gateway
@router.delete("/{gateway_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_gateway(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Delete gateway (admin only)."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if gateway.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete gateways from other tenants"
)
db.delete(gateway)
db.commit()
@router.get("/{gateway_id}/provision")
def download_provisioning_package(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Download provisioning package for gateway.
DEPRECATED: Use VPN profiles for provisioning.
GET /api/gateways/{gateway_id}/profiles/{profile_id}/provision
"""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Provisioning is now done through VPN profiles. "
"Please create a VPN profile for this gateway first, "
"then use GET /api/gateways/{gateway_id}/profiles/{profile_id}/provision"
)
@router.post("/{gateway_id}/access/{user_id}", status_code=status.HTTP_201_CREATED)
def grant_gateway_access(
gateway_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Grant user access to gateway."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(status_code=404, detail="Gateway not found")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check existing access
existing = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id,
UserGatewayAccess.user_id == user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Access already granted")
access = UserGatewayAccess(
user_id=user_id,
gateway_id=gateway_id,
granted_by_id=current_user.id
)
db.add(access)
db.commit()
return {"message": "Access granted"}
@router.delete("/{gateway_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke_gateway_access(
gateway_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Revoke user access to gateway."""
access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id,
UserGatewayAccess.user_id == user_id
).first()
if not access:
raise HTTPException(status_code=404, detail="Access not found")
db.delete(access)
db.commit()
@router.get("/{gateway_id}/vpn-logs")
def get_gateway_vpn_logs(
gateway_id: int,
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get VPN connection logs for a gateway."""
# Check access
query = get_accessible_gateways_query(db, current_user)
gateway = query.filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found or access denied"
)
sync_service = VPNSyncService(db)
logs = sync_service.get_gateway_connection_logs(gateway_id, limit=limit)
return [
{
"id": log.id,
"profile_id": log.vpn_profile_id,
"profile_name": log.vpn_profile.name if log.vpn_profile else None,
"server_id": log.vpn_server_id,
"server_name": log.vpn_server.name if log.vpn_server else None,
"common_name": log.common_name,
"real_address": log.real_address,
"vpn_ip": log.vpn_ip,
"connected_at": log.connected_at.isoformat() if log.connected_at else None,
"disconnected_at": log.disconnected_at.isoformat() if log.disconnected_at else None,
"duration_seconds": log.duration_seconds,
"bytes_received": log.bytes_received,
"bytes_sent": log.bytes_sent,
"is_active": log.is_active
}
for log in logs
]
@router.get("/{gateway_id}/vpn-status")
def get_gateway_vpn_status(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get current VPN connection status for a gateway."""
# Check access
query = get_accessible_gateways_query(db, current_user)
gateway = query.filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found or access denied"
)
# Sync connections first
sync_service = VPNSyncService(db)
sync_service.sync_all_connections()
# Get active connection
active_log = db.query(VPNConnectionLog).filter(
VPNConnectionLog.gateway_id == gateway_id,
VPNConnectionLog.disconnected_at.is_(None)
).first()
if active_log:
return {
"is_connected": True,
"profile_name": active_log.vpn_profile.name if active_log.vpn_profile else None,
"server_name": active_log.vpn_server.name if active_log.vpn_server else None,
"real_address": active_log.real_address,
"connected_since": active_log.connected_at.isoformat() if active_log.connected_at else None,
"bytes_received": active_log.bytes_received,
"bytes_sent": active_log.bytes_sent
}
else:
return {
"is_connected": False,
"last_connection": gateway.last_seen.isoformat() if gateway.last_seen else None
}

363
server/app/api/internal.py Normal file
View File

@ -0,0 +1,363 @@
"""Internal API endpoints for container-to-container communication.
These endpoints are used by OpenVPN containers to fetch their configuration.
They should only be accessible from within the Docker network.
"""
import os
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response, Query
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.vpn_server import VPNServer, VPNServerStatus
from ..models.vpn_profile import VPNProfile
from ..services.vpn_server_service import VPNServerService
from ..services.vpn_sync_service import VPNSyncService
from ..services.certificate_service import CertificateService
# Log directory (shared volume with OpenVPN container)
LOG_DIR = Path("/var/log/openvpn")
router = APIRouter(prefix="/api/internal", tags=["internal"])
@router.get("/health")
async def health_check():
"""Health check endpoint for container startup."""
return {"status": "healthy"}
@router.get("/vpn-servers/active")
async def get_active_servers(db: Session = Depends(get_db)):
"""Get list of all active and ready VPN servers.
Used by OpenVPN container to discover which servers to start.
"""
servers = db.query(VPNServer).filter(
VPNServer.is_active == True
).all()
return [
{
"id": s.id,
"name": s.name,
"port": s.port,
"protocol": s.protocol.value,
"management_port": s.management_port,
"vpn_network": s.vpn_network,
"vpn_netmask": s.vpn_netmask,
"is_ready": s.is_ready,
"has_ca": s.certificate_authority is not None and s.certificate_authority.is_ready,
"has_cert": s.server_cert is not None and s.server_key is not None
}
for s in servers
]
@router.get("/vpn-servers/{server_id}/config")
async def get_server_config(
server_id: int,
db: Session = Depends(get_db)
):
"""Get OpenVPN server configuration file."""
service = VPNServerService(db)
server = service.get_server_by_id(server_id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.server_cert or not server.server_key:
raise HTTPException(status_code=400, detail="Server certificate not generated")
config = service.generate_server_config(server)
# Update server status
server.status = VPNServerStatus.STARTING
db.commit()
return Response(content=config, media_type="text/plain")
@router.get("/vpn-servers/{server_id}/ca")
async def get_server_ca(
server_id: int,
db: Session = Depends(get_db)
):
"""Get CA certificate for a VPN server."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.certificate_authority or not server.certificate_authority.ca_cert:
raise HTTPException(status_code=400, detail="CA not available")
return Response(
content=server.certificate_authority.ca_cert,
media_type="application/x-pem-file"
)
@router.get("/vpn-servers/{server_id}/cert")
async def get_server_cert(
server_id: int,
db: Session = Depends(get_db)
):
"""Get server certificate."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.server_cert:
raise HTTPException(status_code=400, detail="Server certificate not generated")
return Response(
content=server.server_cert,
media_type="application/x-pem-file"
)
@router.get("/vpn-servers/{server_id}/key")
async def get_server_key(
server_id: int,
db: Session = Depends(get_db)
):
"""Get server private key."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.server_key:
raise HTTPException(status_code=400, detail="Server key not generated")
return Response(
content=server.server_key,
media_type="application/x-pem-file"
)
@router.get("/vpn-servers/{server_id}/dh")
async def get_server_dh(
server_id: int,
db: Session = Depends(get_db)
):
"""Get DH parameters."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.certificate_authority or not server.certificate_authority.dh_params:
raise HTTPException(status_code=400, detail="DH parameters not available")
return Response(
content=server.certificate_authority.dh_params,
media_type="application/x-pem-file"
)
@router.get("/vpn-servers/{server_id}/ta")
async def get_server_ta(
server_id: int,
db: Session = Depends(get_db)
):
"""Get TLS-Auth key."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.ta_key:
raise HTTPException(status_code=400, detail="TA key not available")
return Response(
content=server.ta_key,
media_type="text/plain"
)
@router.get("/vpn-servers/{server_id}/crl")
async def get_server_crl(
server_id: int,
db: Session = Depends(get_db)
):
"""Get Certificate Revocation List."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.certificate_authority:
raise HTTPException(status_code=400, detail="CA not available")
cert_service = CertificateService(db)
crl = cert_service.get_crl(server.certificate_authority)
return Response(
content=crl,
media_type="application/x-pem-file"
)
@router.post("/vpn-servers/{server_id}/started")
async def notify_server_started(
server_id: int,
db: Session = Depends(get_db)
):
"""Notify that server has started successfully."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
server.status = VPNServerStatus.RUNNING
db.commit()
return {"status": "ok"}
@router.post("/vpn-servers/{server_id}/stopped")
async def notify_server_stopped(
server_id: int,
db: Session = Depends(get_db)
):
"""Notify that server has stopped."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
server.status = VPNServerStatus.STOPPED
db.commit()
return {"status": "ok"}
@router.get("/vpn-servers/{server_id}/logs")
async def get_server_logs(
server_id: int,
lines: int = Query(default=100, le=1000),
db: Session = Depends(get_db)
):
"""Get OpenVPN server log file.
Args:
server_id: VPN server ID
lines: Number of lines to return (max 1000)
"""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
log_file = LOG_DIR / f"server-{server_id}.log"
if not log_file.exists():
return {"lines": [], "message": "Log file not found"}
try:
# Read last N lines
with open(log_file, 'r') as f:
all_lines = f.readlines()
log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
return {
"server_id": server_id,
"server_name": server.name,
"total_lines": len(all_lines),
"returned_lines": len(log_lines),
"lines": [line.rstrip() for line in log_lines]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error reading log: {str(e)}")
@router.get("/vpn-servers/{server_id}/logs/raw")
async def get_server_logs_raw(
server_id: int,
lines: int = Query(default=100, le=5000),
db: Session = Depends(get_db)
):
"""Get OpenVPN server log file as plain text."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
log_file = LOG_DIR / f"server-{server_id}.log"
if not log_file.exists():
return Response(content="Log file not found", media_type="text/plain")
try:
with open(log_file, 'r') as f:
all_lines = f.readlines()
log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
return Response(
content="".join(log_lines),
media_type="text/plain"
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error reading log: {str(e)}")
@router.get("/logs/supervisord")
async def get_supervisord_logs(
lines: int = Query(default=100, le=1000)
):
"""Get supervisord log file."""
log_file = LOG_DIR / "supervisord.log"
if not log_file.exists():
return {"lines": [], "message": "Supervisord log not found"}
try:
with open(log_file, 'r') as f:
all_lines = f.readlines()
log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
return {
"total_lines": len(all_lines),
"returned_lines": len(log_lines),
"lines": [line.rstrip() for line in log_lines]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error reading log: {str(e)}")
@router.get("/debug/sync")
async def debug_sync(db: Session = Depends(get_db)):
"""Debug endpoint to check VPN sync status."""
sync_service = VPNSyncService(db)
vpn_service = VPNServerService(db)
# Get all profiles with their CNs
profiles = db.query(VPNProfile).all()
profile_cns = [{"id": p.id, "name": p.name, "cert_cn": p.cert_cn, "gateway_id": p.gateway_id} for p in profiles]
# Get connected clients from all servers
servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
connected_clients = []
for server in servers:
try:
clients = vpn_service.get_connected_clients(server)
for client in clients:
client['server_id'] = server.id
client['server_name'] = server.name
connected_clients.append(client)
except Exception as e:
connected_clients.append({"server_id": server.id, "error": str(e)})
# Run sync and get result
sync_result = sync_service.sync_all_connections()
return {
"profiles": profile_cns,
"connected_clients": connected_clients,
"sync_result": sync_result
}

103
server/app/api/tenants.py Normal file
View File

@ -0,0 +1,103 @@
"""Tenant management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.tenant import Tenant
from ..models.user import User
from ..schemas.tenant import TenantCreate, TenantUpdate, TenantResponse
from .deps import require_super_admin
router = APIRouter()
@router.get("/", response_model=list[TenantResponse])
def list_tenants(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin)
):
"""List all tenants (super admin only)."""
tenants = db.query(Tenant).offset(skip).limit(limit).all()
return tenants
@router.post("/", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
def create_tenant(
tenant_data: TenantCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin)
):
"""Create a new tenant (super admin only)."""
# Check if name already exists
existing = db.query(Tenant).filter(Tenant.name == tenant_data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant with this name already exists"
)
tenant = Tenant(**tenant_data.model_dump())
db.add(tenant)
db.commit()
db.refresh(tenant)
return tenant
@router.get("/{tenant_id}", response_model=TenantResponse)
def get_tenant(
tenant_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin)
):
"""Get tenant by ID (super admin only)."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
return tenant
@router.put("/{tenant_id}", response_model=TenantResponse)
def update_tenant(
tenant_id: int,
tenant_data: TenantUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin)
):
"""Update tenant (super admin only)."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
update_data = tenant_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tenant, field, value)
db.commit()
db.refresh(tenant)
return tenant
@router.delete("/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_tenant(
tenant_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin)
):
"""Delete tenant (super admin only)."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
db.delete(tenant)
db.commit()

176
server/app/api/users.py Normal file
View File

@ -0,0 +1,176 @@
"""User management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
from ..schemas.user import UserCreate, UserUpdate, UserResponse
from ..utils.security import get_password_hash
from .deps import get_current_user, require_admin
router = APIRouter()
@router.get("/", response_model=list[UserResponse])
def list_users(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""List users (admins see their tenant's users, super admins see all)."""
query = db.query(User)
# Filter by tenant for non-super-admins
if current_user.role != UserRole.SUPER_ADMIN:
query = query.filter(User.tenant_id == current_user.tenant_id)
users = query.offset(skip).limit(limit).all()
return users
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
user_data: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Create a new user."""
# Check permissions
if current_user.role != UserRole.SUPER_ADMIN:
# Regular admins can only create users in their own tenant
if user_data.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot create users in other tenants"
)
# Cannot create super admins
if user_data.role == UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot create super admin users"
)
# Check if username exists
existing = db.query(User).filter(User.username == user_data.username).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already exists"
)
# Check if email exists
existing = db.query(User).filter(User.email == user_data.email).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already exists"
)
user = User(
username=user_data.username,
email=user_data.email,
password_hash=get_password_hash(user_data.password),
full_name=user_data.full_name,
role=user_data.role,
tenant_id=user_data.tenant_id
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.get("/{user_id}", response_model=UserResponse)
def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Get user by ID."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if user.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot access users from other tenants"
)
return user
@router.put("/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
user_data: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Update user."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if user.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify users from other tenants"
)
update_data = user_data.model_dump(exclude_unset=True)
# Hash password if provided
if "password" in update_data:
update_data["password_hash"] = get_password_hash(update_data.pop("password"))
for field, value in update_data.items():
setattr(user, field, value)
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Delete user."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Cannot delete yourself
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if user.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete users from other tenants"
)
db.delete(user)
db.commit()

41
server/app/config.py Normal file
View File

@ -0,0 +1,41 @@
"""Application configuration using Pydantic settings."""
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Database
database_url: str = "mysql+pymysql://mguard:password@localhost:3306/mguard_vpn"
# Security
secret_key: str = "change-me-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 # 24 hours
refresh_token_expire_days: int = 7
# OpenVPN Management Interface
openvpn_management_host: str = "openvpn"
openvpn_management_port: int = 7505
# VPN Network
vpn_network: str = "10.8.0.0"
vpn_netmask: str = "255.255.255.0"
vpn_server_address: str = "vpn.example.com" # External address for clients to connect
# Admin defaults
admin_username: str = "admin"
admin_password: str = "changeme"
admin_email: str = "admin@example.com"
class Config:
env_file = ".env"
case_sensitive = False
@lru_cache()
def get_settings() -> Settings:
"""Get cached settings instance."""
return Settings()

36
server/app/database.py Normal file
View File

@ -0,0 +1,36 @@
"""Database connection and session management."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from .config import get_settings
settings = get_settings()
# Create engine with connection pooling
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
pool_recycle=3600,
echo=False # Set to True for SQL debugging
)
# Session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for models
Base = declarative_base()
def get_db():
"""Dependency for getting database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""Initialize database tables."""
from . import models # noqa: F401
Base.metadata.create_all(bind=engine)

154
server/app/main.py Normal file
View File

@ -0,0 +1,154 @@
"""FastAPI main application entry point."""
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from sqlalchemy.orm import Session
from .config import get_settings
from .database import engine, Base, SessionLocal
from .api import api_router
from .api.internal import router as internal_router
from .web import web_router
from .models.user import User, UserRole
from .models.tenant import Tenant
from .models.endpoint import ApplicationTemplate, DEFAULT_APPLICATION_TEMPLATES
from .utils.security import get_password_hash
settings = get_settings()
# Template directory
TEMPLATE_DIR = Path(__file__).parent / "templates"
STATIC_DIR = Path(__file__).parent / "static"
def init_db():
"""Initialize database with tables and default data."""
# Create all tables
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
# Create default tenant if none exists
default_tenant = db.query(Tenant).first()
if not default_tenant:
default_tenant = Tenant(
name="Default",
description="Default tenant"
)
db.add(default_tenant)
db.commit()
db.refresh(default_tenant)
print("Created default tenant")
# Create admin user if none exists
admin_user = db.query(User).filter(User.role == UserRole.SUPER_ADMIN).first()
if not admin_user:
admin_user = User(
username=settings.admin_username,
email=settings.admin_email,
password_hash=get_password_hash(settings.admin_password),
role=UserRole.SUPER_ADMIN,
tenant_id=None # Super admin has no tenant
)
db.add(admin_user)
db.commit()
print(f"Created admin user: {settings.admin_username}")
# Seed application templates
existing_templates = db.query(ApplicationTemplate).count()
if existing_templates == 0:
for template_data in DEFAULT_APPLICATION_TEMPLATES:
template = ApplicationTemplate(**template_data)
db.add(template)
db.commit()
print(f"Seeded {len(DEFAULT_APPLICATION_TEMPLATES)} application templates")
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan events."""
# Startup
print("Starting mGuard VPN Endpoint Server...")
init_db()
yield
# Shutdown
print("Shutting down mGuard VPN Endpoint Server...")
# Create FastAPI application
app = FastAPI(
title="mGuard VPN Endpoint Server",
description="""
VPN management system for Phoenix Contact mGuard routers.
## Features
- Multi-tenant gateway management
- Endpoint configuration (IP + Port)
- User access control
- VPN connection management
- Connection logging and auditing
""",
version="1.0.0",
lifespan=lifespan,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json"
)
# Session middleware for web UI
app.add_middleware(
SessionMiddleware,
secret_key=settings.secret_key,
session_cookie="mguard_session",
max_age=86400 * 7, # 7 days
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure properly in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Setup Jinja2 templates
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
app.state.templates = templates
# Include API router
app.include_router(api_router)
# Include Internal API router (for container-to-container communication)
app.include_router(internal_router)
# Include Web router
app.include_router(web_router)
@app.get("/")
def root():
"""Root endpoint with API information."""
return {
"name": "mGuard VPN Endpoint Server",
"version": "1.0.0",
"docs": "/docs",
"health": "/health"
}
@app.get("/health")
def health_check():
"""Health check endpoint."""
return {"status": "healthy"}

View File

@ -0,0 +1,36 @@
"""SQLAlchemy models for mGuard VPN Endpoint Server."""
from .tenant import Tenant
from .user import User
from .gateway import Gateway, RouterType
from .endpoint import Endpoint, ApplicationTemplate
from .access import UserGatewayAccess, UserEndpointAccess, ConnectionLog
from .certificate_authority import CertificateAuthority, CAStatus, CAAlgorithm
from .vpn_server import VPNServer, VPNProtocol, VPNCipher, VPNAuth, VPNCompression, VPNServerStatus
from .vpn_profile import VPNProfile, VPNProfileStatus
from .vpn_connection_log import VPNConnectionLog
__all__ = [
"Tenant",
"User",
"Gateway",
"RouterType",
"Endpoint",
"ApplicationTemplate",
"UserGatewayAccess",
"UserEndpointAccess",
"ConnectionLog",
# PKI & VPN
"CertificateAuthority",
"CAStatus",
"CAAlgorithm",
"VPNServer",
"VPNProtocol",
"VPNCipher",
"VPNAuth",
"VPNCompression",
"VPNServerStatus",
"VPNProfile",
"VPNProfileStatus",
"VPNConnectionLog",
]

View File

@ -0,0 +1,78 @@
"""Access control and logging models."""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from ..database import Base
class UserGatewayAccess(Base):
"""User access to gateways."""
__tablename__ = "user_gateway_access"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
gateway_id = Column(Integer, ForeignKey("gateways.id"), nullable=False)
granted_at = Column(DateTime, default=datetime.utcnow, nullable=False)
granted_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships
user = relationship("User", foreign_keys=[user_id], back_populates="gateway_access")
gateway = relationship("Gateway", back_populates="user_access")
granted_by = relationship("User", foreign_keys=[granted_by_id])
def __repr__(self):
return f"<UserGatewayAccess(user_id={self.user_id}, gateway_id={self.gateway_id})>"
class UserEndpointAccess(Base):
"""User access to specific endpoints (optional fine-grained control)."""
__tablename__ = "user_endpoint_access"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
endpoint_id = Column(Integer, ForeignKey("endpoints.id"), nullable=False)
granted_at = Column(DateTime, default=datetime.utcnow, nullable=False)
granted_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships
user = relationship("User", foreign_keys=[user_id], back_populates="endpoint_access")
endpoint = relationship("Endpoint", back_populates="user_access")
granted_by = relationship("User", foreign_keys=[granted_by_id])
def __repr__(self):
return f"<UserEndpointAccess(user_id={self.user_id}, endpoint_id={self.endpoint_id})>"
class ConnectionLog(Base):
"""Log of VPN connections for auditing."""
__tablename__ = "connection_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
gateway_id = Column(Integer, ForeignKey("gateways.id"), nullable=False)
endpoint_id = Column(Integer, ForeignKey("endpoints.id"), nullable=True)
# Connection details
client_ip = Column(String(45), nullable=True) # Client's real IP
vpn_ip = Column(String(45), nullable=True) # Assigned VPN IP
connected_at = Column(DateTime, default=datetime.utcnow, nullable=False)
disconnected_at = Column(DateTime, nullable=True)
# Relationships
user = relationship("User", back_populates="connection_logs")
gateway = relationship("Gateway", back_populates="connection_logs")
endpoint = relationship("Endpoint", back_populates="connection_logs")
def __repr__(self):
return f"<ConnectionLog(user={self.user_id}, gateway={self.gateway_id}, connected={self.connected_at})>"
@property
def duration_seconds(self) -> int | None:
"""Get connection duration in seconds."""
if self.disconnected_at:
return int((self.disconnected_at - self.connected_at).total_seconds())
return None

View File

@ -0,0 +1,99 @@
"""Certificate Authority model for PKI management."""
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, Text
from sqlalchemy.orm import relationship
from ..database import Base
class CAStatus(str, PyEnum):
"""Certificate Authority status."""
PENDING = "pending" # DH parameters being generated
ACTIVE = "active"
EXPIRED = "expired"
REVOKED = "revoked"
class CAAlgorithm(str, PyEnum):
"""Key algorithm for CA."""
RSA = "rsa"
ECDSA = "ecdsa"
class CertificateAuthority(Base):
"""Certificate Authority for issuing VPN certificates."""
__tablename__ = "certificate_authorities"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True) # NULL for global CA
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
# Certificate data (PEM encoded)
ca_cert = Column(Text, nullable=True) # CA certificate
ca_key = Column(Text, nullable=True) # CA private key (encrypted)
# DH parameters (shared across servers using this CA)
dh_params = Column(Text, nullable=True) # Pre-generated DH parameters
dh_generating = Column(Boolean, default=False) # DH generation in progress
# Key configuration
key_size = Column(Integer, default=4096)
algorithm = Column(Enum(CAAlgorithm), default=CAAlgorithm.RSA)
# Validity
valid_from = Column(DateTime, nullable=True)
valid_until = Column(DateTime, nullable=True)
# Status
is_default = Column(Boolean, default=False) # Default CA for new certificates
status = Column(Enum(CAStatus), default=CAStatus.PENDING)
# CRL (Certificate Revocation List)
crl = Column(Text, nullable=True)
crl_updated_at = Column(DateTime, nullable=True)
# Serial number tracking
next_serial = Column(Integer, default=1)
# Audit
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships
tenant = relationship("Tenant", back_populates="certificate_authorities")
created_by = relationship("User", foreign_keys=[created_by_id])
vpn_servers = relationship("VPNServer", back_populates="certificate_authority")
vpn_profiles = relationship("VPNProfile", back_populates="certificate_authority")
def __repr__(self):
return f"<CertificateAuthority(id={self.id}, name='{self.name}', status='{self.status}')>"
@property
def is_ready(self) -> bool:
"""Check if CA is ready for issuing certificates."""
return (
self.status == CAStatus.ACTIVE and
self.ca_cert is not None and
self.ca_key is not None and
self.dh_params is not None
)
@property
def is_expired(self) -> bool:
"""Check if CA certificate is expired."""
if self.valid_until:
return datetime.utcnow() > self.valid_until
return False
@property
def days_until_expiry(self) -> int | None:
"""Days until CA expires."""
if self.valid_until:
delta = self.valid_until - datetime.utcnow()
return max(0, delta.days)
return None

View File

@ -0,0 +1,87 @@
"""Endpoint model for devices behind gateways."""
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Text
from sqlalchemy.orm import relationship
from ..database import Base
class Protocol(str, PyEnum):
"""Network protocol for endpoint access."""
TCP = "tcp"
UDP = "udp"
class Endpoint(Base):
"""Endpoint model representing a device/service behind a gateway."""
__tablename__ = "endpoints"
id = Column(Integer, primary_key=True, index=True)
gateway_id = Column(Integer, ForeignKey("gateways.id"), nullable=False)
# Endpoint info
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
# Network configuration
internal_ip = Column(String(45), nullable=False) # IP in customer network
port = Column(Integer, nullable=False)
protocol = Column(Enum(Protocol), default=Protocol.TCP, nullable=False)
# Application info
application_name = Column(String(100), nullable=True) # e.g., "CoDeSys", "SSH", "HTTP"
application_template_id = Column(Integer, ForeignKey("application_templates.id"), nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
gateway = relationship("Gateway", back_populates="endpoints")
user_access = relationship("UserEndpointAccess", back_populates="endpoint", cascade="all, delete-orphan")
application_template = relationship("ApplicationTemplate")
connection_logs = relationship("ConnectionLog", back_populates="endpoint")
def __repr__(self):
return f"<Endpoint(id={self.id}, name='{self.name}', ip='{self.internal_ip}:{self.port}')>"
@property
def address(self) -> str:
"""Get full address string."""
return f"{self.internal_ip}:{self.port}"
class ApplicationTemplate(Base):
"""Pre-defined application templates with default ports."""
__tablename__ = "application_templates"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False, unique=True)
description = Column(Text, nullable=True)
default_port = Column(Integer, nullable=False)
protocol = Column(Enum(Protocol), default=Protocol.TCP, nullable=False)
icon = Column(String(100), nullable=True) # Icon name for client UI
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<ApplicationTemplate(name='{self.name}', port={self.default_port})>"
# Default application templates to be seeded
DEFAULT_APPLICATION_TEMPLATES = [
{"name": "CoDeSys", "description": "CoDeSys Runtime/Gateway", "default_port": 11740, "protocol": "tcp"},
{"name": "CoDeSys Gateway", "description": "CoDeSys Gateway Service", "default_port": 1217, "protocol": "tcp"},
{"name": "SSH", "description": "Secure Shell", "default_port": 22, "protocol": "tcp"},
{"name": "HTTP", "description": "Web Interface", "default_port": 80, "protocol": "tcp"},
{"name": "HTTPS", "description": "Secure Web Interface", "default_port": 443, "protocol": "tcp"},
{"name": "VNC", "description": "Virtual Network Computing", "default_port": 5900, "protocol": "tcp"},
{"name": "RDP", "description": "Remote Desktop Protocol", "default_port": 3389, "protocol": "tcp"},
{"name": "Modbus TCP", "description": "Modbus over TCP/IP", "default_port": 502, "protocol": "tcp"},
{"name": "OPC UA", "description": "OPC Unified Architecture", "default_port": 4840, "protocol": "tcp"},
{"name": "MQTT", "description": "Message Queue Telemetry Transport", "default_port": 1883, "protocol": "tcp"},
{"name": "S7 Communication", "description": "Siemens S7 Protocol", "default_port": 102, "protocol": "tcp"},
]

View File

@ -0,0 +1,91 @@
"""Gateway model for mGuard routers."""
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, Text
from sqlalchemy.orm import relationship
from ..database import Base
class RouterType(str, PyEnum):
"""Supported mGuard router types."""
FL_MGUARD_2000 = "FL_MGUARD_2000"
FL_MGUARD_4000 = "FL_MGUARD_4000"
FL_MGUARD_RS4000 = "FL_MGUARD_RS4000"
FL_MGUARD_1000 = "FL_MGUARD_1000"
class ProvisioningMethod(str, PyEnum):
"""Method used to provision the gateway."""
REST_API = "rest_api" # For firmware 10.5.x+
SSH = "ssh" # For older firmware
ATV_FILE = "atv_file" # Offline via config file
class Gateway(Base):
"""Gateway model representing an mGuard router."""
__tablename__ = "gateways"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
# Basic info
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
location = Column(String(255), nullable=True) # Physical location
# Router details
serial_number = Column(String(100), nullable=True, unique=True)
router_type = Column(Enum(RouterType), nullable=False)
firmware_version = Column(String(50), nullable=True)
provisioning_method = Column(Enum(ProvisioningMethod), default=ProvisioningMethod.ATV_FILE)
# VPN configuration
vpn_ip = Column(String(45), nullable=True, unique=True) # IPv4/IPv6
vpn_cert_cn = Column(String(255), nullable=True, unique=True) # Certificate Common Name
vpn_subnet = Column(String(50), nullable=True) # Network behind gateway, e.g., "10.0.0.0/24"
# Status
is_online = Column(Boolean, default=False, nullable=False)
is_provisioned = Column(Boolean, default=False, nullable=False)
last_seen = Column(DateTime, nullable=True)
last_config_update = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
tenant = relationship("Tenant", back_populates="gateways")
endpoints = relationship("Endpoint", back_populates="gateway", cascade="all, delete-orphan")
user_access = relationship("UserGatewayAccess", back_populates="gateway", cascade="all, delete-orphan")
connection_logs = relationship("ConnectionLog", back_populates="gateway")
vpn_profiles = relationship("VPNProfile", back_populates="gateway", cascade="all, delete-orphan", order_by="VPNProfile.priority")
def __repr__(self):
return f"<Gateway(id={self.id}, name='{self.name}', type='{self.router_type}')>"
@property
def supports_rest_api(self) -> bool:
"""Check if gateway supports REST API (firmware 10.x+)."""
if not self.firmware_version:
return False
try:
major_version = int(self.firmware_version.split('.')[0])
return major_version >= 10
except (ValueError, IndexError):
return False
@property
def primary_profile(self):
"""Get the primary VPN profile (highest priority)."""
active_profiles = [p for p in self.vpn_profiles if p.is_active]
if active_profiles:
return min(active_profiles, key=lambda p: p.priority)
return None
@property
def has_vpn_profiles(self) -> bool:
"""Check if gateway has any VPN profiles."""
return len(self.vpn_profiles) > 0

View File

@ -0,0 +1,28 @@
"""Tenant model for multi-tenant support."""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
from sqlalchemy.orm import relationship
from ..database import Base
class Tenant(Base):
"""Tenant/Customer model for multi-tenant separation."""
__tablename__ = "tenants"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False, unique=True)
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
gateways = relationship("Gateway", back_populates="tenant", cascade="all, delete-orphan")
certificate_authorities = relationship("CertificateAuthority", back_populates="tenant", cascade="all, delete-orphan")
vpn_servers = relationship("VPNServer", back_populates="tenant", cascade="all, delete-orphan")
def __repr__(self):
return f"<Tenant(id={self.id}, name='{self.name}')>"

62
server/app/models/user.py Normal file
View File

@ -0,0 +1,62 @@
"""User model with role-based access control."""
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
from ..database import Base
class UserRole(str, PyEnum):
"""User roles for access control."""
SUPER_ADMIN = "super_admin" # Can manage all tenants
ADMIN = "admin" # Can manage own tenant
TECHNICIAN = "technician" # Can connect to assigned gateways
VIEWER = "viewer" # Read-only access
class User(Base):
"""User model with multi-tenant support."""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True) # NULL for super_admin
username = Column(String(255), nullable=False, unique=True, index=True)
password_hash = Column(String(255), nullable=False)
email = Column(String(255), nullable=False, unique=True)
full_name = Column(String(255), nullable=True)
role = Column(Enum(UserRole), default=UserRole.TECHNICIAN, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
last_login = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
tenant = relationship("Tenant", back_populates="users")
gateway_access = relationship(
"UserGatewayAccess",
back_populates="user",
cascade="all, delete-orphan",
primaryjoin="User.id == UserGatewayAccess.user_id"
)
endpoint_access = relationship(
"UserEndpointAccess",
back_populates="user",
cascade="all, delete-orphan",
primaryjoin="User.id == UserEndpointAccess.user_id"
)
connection_logs = relationship("ConnectionLog", back_populates="user")
def __repr__(self):
return f"<User(id={self.id}, username='{self.username}', role='{self.role}')>"
@property
def is_super_admin(self) -> bool:
"""Check if user is super admin."""
return self.role == UserRole.SUPER_ADMIN
@property
def is_admin(self) -> bool:
"""Check if user has admin privileges."""
return self.role in (UserRole.SUPER_ADMIN, UserRole.ADMIN)

View File

@ -0,0 +1,50 @@
"""VPN Connection Log model for tracking profile connection history."""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, BigInteger
from sqlalchemy.orm import relationship
from ..database import Base
class VPNConnectionLog(Base):
"""Log of VPN profile connections."""
__tablename__ = "vpn_connection_logs"
id = Column(Integer, primary_key=True, index=True)
vpn_profile_id = Column(Integer, ForeignKey("vpn_profiles.id"), nullable=False)
vpn_server_id = Column(Integer, ForeignKey("vpn_servers.id"), nullable=False)
gateway_id = Column(Integer, ForeignKey("gateways.id"), nullable=False)
# Connection info
common_name = Column(String(255), nullable=False)
real_address = Column(String(255), nullable=True) # IP:Port
vpn_ip = Column(String(15), nullable=True) # Assigned VPN IP
# Timestamps
connected_at = Column(DateTime, default=datetime.utcnow, nullable=False)
disconnected_at = Column(DateTime, nullable=True)
# Traffic stats (updated on disconnect)
bytes_received = Column(BigInteger, default=0)
bytes_sent = Column(BigInteger, default=0)
# Relationships
vpn_profile = relationship("VPNProfile")
vpn_server = relationship("VPNServer")
gateway = relationship("Gateway")
def __repr__(self):
return f"<VPNConnectionLog(id={self.id}, cn='{self.common_name}', connected={self.connected_at})>"
@property
def duration_seconds(self) -> int | None:
"""Connection duration in seconds."""
if self.disconnected_at:
return int((self.disconnected_at - self.connected_at).total_seconds())
return None
@property
def is_active(self) -> bool:
"""Check if connection is still active."""
return self.disconnected_at is None

View File

@ -0,0 +1,90 @@
"""VPN Profile model for gateway VPN configurations."""
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, Text
from sqlalchemy.orm import relationship
from ..database import Base
class VPNProfileStatus(str, PyEnum):
"""VPN Profile status."""
PENDING = "pending" # Certificate being generated
ACTIVE = "active" # Ready to use
PROVISIONED = "provisioned" # Downloaded/deployed to gateway
EXPIRED = "expired" # Certificate expired
REVOKED = "revoked" # Certificate revoked
class VPNProfile(Base):
"""VPN Profile for a gateway - links gateway to VPN server."""
__tablename__ = "vpn_profiles"
id = Column(Integer, primary_key=True, index=True)
gateway_id = Column(Integer, ForeignKey("gateways.id"), nullable=False)
vpn_server_id = Column(Integer, ForeignKey("vpn_servers.id"), nullable=False)
ca_id = Column(Integer, ForeignKey("certificate_authorities.id"), nullable=False)
# Profile info
name = Column(String(255), nullable=False) # e.g., "Produktion", "Fallback"
description = Column(Text, nullable=True)
# Certificate data
cert_cn = Column(String(255), nullable=False) # Common Name
client_cert = Column(Text, nullable=True) # Client certificate PEM
client_key = Column(Text, nullable=True) # Client private key PEM
# Priority for failover (1 = highest priority)
priority = Column(Integer, default=1)
# Status
status = Column(Enum(VPNProfileStatus), default=VPNProfileStatus.PENDING)
is_active = Column(Boolean, default=True)
# Validity
valid_from = Column(DateTime, nullable=True)
valid_until = Column(DateTime, nullable=True)
# Provisioning tracking
provisioned_at = Column(DateTime, nullable=True)
last_connection = Column(DateTime, nullable=True)
# VPN IP assigned to this profile (if static)
vpn_ip = Column(String(15), nullable=True)
# Audit
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
gateway = relationship("Gateway", back_populates="vpn_profiles")
vpn_server = relationship("VPNServer", back_populates="vpn_profiles")
certificate_authority = relationship("CertificateAuthority", back_populates="vpn_profiles")
def __repr__(self):
return f"<VPNProfile(id={self.id}, name='{self.name}', gateway_id={self.gateway_id}, priority={self.priority})>"
@property
def is_ready(self) -> bool:
"""Check if profile is ready for provisioning."""
return (
self.status in (VPNProfileStatus.ACTIVE, VPNProfileStatus.PROVISIONED) and
self.client_cert is not None and
self.client_key is not None
)
@property
def is_expired(self) -> bool:
"""Check if certificate is expired."""
if self.valid_until:
return datetime.utcnow() > self.valid_until
return False
@property
def days_until_expiry(self) -> int | None:
"""Days until certificate expires."""
if self.valid_until:
delta = self.valid_until - datetime.utcnow()
return max(0, delta.days)
return None

View File

@ -0,0 +1,130 @@
"""VPN Server model for managing multiple OpenVPN instances."""
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, Text
from sqlalchemy.orm import relationship
from ..database import Base
class VPNProtocol(str, PyEnum):
"""VPN protocol type."""
UDP = "udp"
TCP = "tcp"
class VPNCipher(str, PyEnum):
"""VPN cipher options."""
AES_256_GCM = "AES-256-GCM"
AES_128_GCM = "AES-128-GCM"
AES_256_CBC = "AES-256-CBC"
CHACHA20_POLY1305 = "CHACHA20-POLY1305"
class VPNAuth(str, PyEnum):
"""VPN auth digest options."""
SHA256 = "SHA256"
SHA384 = "SHA384"
SHA512 = "SHA512"
class VPNCompression(str, PyEnum):
"""VPN compression options."""
NONE = "none"
LZ4 = "lz4"
LZ4_V2 = "lz4-v2"
LZO = "lzo"
class VPNServerStatus(str, PyEnum):
"""VPN Server status."""
PENDING = "pending" # Server created but not started
STARTING = "starting" # Container starting
RUNNING = "running" # Server running
STOPPED = "stopped" # Server stopped
ERROR = "error" # Error state
class VPNServer(Base):
"""VPN Server instance configuration."""
__tablename__ = "vpn_servers"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True) # NULL for global
ca_id = Column(Integer, ForeignKey("certificate_authorities.id"), nullable=False)
# Basic info
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
# Network configuration
hostname = Column(String(255), nullable=False) # External hostname/IP
port = Column(Integer, default=1194, nullable=False)
protocol = Column(Enum(VPNProtocol), default=VPNProtocol.UDP, nullable=False)
# VPN network
vpn_network = Column(String(18), default="10.8.0.0", nullable=False) # CIDR or IP
vpn_netmask = Column(String(15), default="255.255.255.0", nullable=False)
# Server certificate (PEM encoded)
server_cert = Column(Text, nullable=True)
server_key = Column(Text, nullable=True)
ta_key = Column(Text, nullable=True) # TLS-Auth key
# Security settings
cipher = Column(Enum(VPNCipher), default=VPNCipher.AES_256_GCM)
auth = Column(Enum(VPNAuth), default=VPNAuth.SHA256)
tls_version_min = Column(String(10), default="1.2")
tls_auth_enabled = Column(Boolean, default=True)
# Performance settings
compression = Column(Enum(VPNCompression), default=VPNCompression.NONE)
max_clients = Column(Integer, default=100)
keepalive_interval = Column(Integer, default=10) # seconds
keepalive_timeout = Column(Integer, default=60) # seconds
# Docker settings
docker_container_name = Column(String(255), nullable=True)
management_port = Column(Integer, default=7505)
# Status
status = Column(Enum(VPNServerStatus), default=VPNServerStatus.PENDING)
is_active = Column(Boolean, default=True)
is_primary = Column(Boolean, default=False) # Primary server for tenant
auto_start = Column(Boolean, default=True) # Start on system boot
# Statistics (updated periodically)
connected_clients = Column(Integer, default=0)
last_status_check = Column(DateTime, nullable=True)
# Audit
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
tenant = relationship("Tenant", back_populates="vpn_servers")
certificate_authority = relationship("CertificateAuthority", back_populates="vpn_servers")
vpn_profiles = relationship("VPNProfile", back_populates="vpn_server")
def __repr__(self):
return f"<VPNServer(id={self.id}, name='{self.name}', {self.hostname}:{self.port}/{self.protocol.value})>"
@property
def is_ready(self) -> bool:
"""Check if server is ready to accept connections."""
return (
self.server_cert is not None and
self.server_key is not None and
self.certificate_authority is not None and
self.certificate_authority.is_ready
)
@property
def connection_string(self) -> str:
"""Get connection string for display."""
return f"{self.hostname}:{self.port}/{self.protocol.value.upper()}"
def get_docker_port_mapping(self) -> dict:
"""Get Docker port mapping for this server."""
return {f"{self.port}/{self.protocol.value}": self.port}

View File

@ -0,0 +1,25 @@
"""Pydantic schemas for API request/response validation."""
from .user import (
UserCreate, UserUpdate, UserResponse, UserLogin,
Token, TokenPayload
)
from .tenant import TenantCreate, TenantUpdate, TenantResponse
from .gateway import GatewayCreate, GatewayUpdate, GatewayResponse, GatewayStatus
from .endpoint import (
EndpointCreate, EndpointUpdate, EndpointResponse,
ApplicationTemplateResponse
)
__all__ = [
# User
"UserCreate", "UserUpdate", "UserResponse", "UserLogin",
"Token", "TokenPayload",
# Tenant
"TenantCreate", "TenantUpdate", "TenantResponse",
# Gateway
"GatewayCreate", "GatewayUpdate", "GatewayResponse", "GatewayStatus",
# Endpoint
"EndpointCreate", "EndpointUpdate", "EndpointResponse",
"ApplicationTemplateResponse",
]

View File

@ -0,0 +1,76 @@
"""Endpoint-related Pydantic schemas."""
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
import ipaddress
from ..models.endpoint import Protocol
class EndpointBase(BaseModel):
"""Base endpoint schema."""
name: str = Field(..., min_length=2, max_length=255)
description: str | None = None
internal_ip: str = Field(..., description="IP address in customer network")
port: int = Field(..., ge=1, le=65535)
protocol: Protocol = Protocol.TCP
application_name: str | None = None
@field_validator('internal_ip')
@classmethod
def validate_ip(cls, v: str) -> str:
try:
ipaddress.ip_address(v)
return v
except ValueError as e:
raise ValueError(f"Invalid IP address: {e}")
class EndpointCreate(EndpointBase):
"""Schema for creating an endpoint."""
application_template_id: int | None = None
class EndpointUpdate(BaseModel):
"""Schema for updating an endpoint."""
name: str | None = Field(None, min_length=2, max_length=255)
description: str | None = None
internal_ip: str | None = None
port: int | None = Field(None, ge=1, le=65535)
protocol: Protocol | None = None
application_name: str | None = None
@field_validator('internal_ip')
@classmethod
def validate_ip(cls, v: str | None) -> str | None:
if v is None:
return v
try:
ipaddress.ip_address(v)
return v
except ValueError as e:
raise ValueError(f"Invalid IP address: {e}")
class EndpointResponse(EndpointBase):
"""Schema for endpoint response."""
id: int
gateway_id: int
application_template_id: int | None
created_at: datetime
updated_at: datetime | None
class Config:
from_attributes = True
class ApplicationTemplateResponse(BaseModel):
"""Schema for application template response."""
id: int
name: str
description: str | None
default_port: int
protocol: Protocol
icon: str | None
class Config:
from_attributes = True

View File

@ -0,0 +1,82 @@
"""Gateway-related Pydantic schemas."""
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
import ipaddress
from ..models.gateway import RouterType, ProvisioningMethod
class GatewayBase(BaseModel):
"""Base gateway schema."""
name: str = Field(..., min_length=2, max_length=255)
description: str | None = None
location: str | None = None
router_type: RouterType
firmware_version: str | None = None
vpn_subnet: str | None = Field(None, description="Network behind gateway, e.g., 10.0.0.0/24")
@field_validator('vpn_subnet')
@classmethod
def validate_subnet(cls, v: str | None) -> str | None:
if v is None:
return v
try:
ipaddress.ip_network(v, strict=False)
return v
except ValueError as e:
raise ValueError(f"Invalid subnet format: {e}")
class GatewayCreate(GatewayBase):
"""Schema for creating a gateway."""
serial_number: str | None = None
provisioning_method: ProvisioningMethod = ProvisioningMethod.ATV_FILE
class GatewayUpdate(BaseModel):
"""Schema for updating a gateway."""
name: str | None = Field(None, min_length=2, max_length=255)
description: str | None = None
location: str | None = None
serial_number: str | None = None
firmware_version: str | None = None
vpn_subnet: str | None = None
provisioning_method: ProvisioningMethod | None = None
@field_validator('vpn_subnet')
@classmethod
def validate_subnet(cls, v: str | None) -> str | None:
if v is None:
return v
try:
ipaddress.ip_network(v, strict=False)
return v
except ValueError as e:
raise ValueError(f"Invalid subnet format: {e}")
class GatewayResponse(GatewayBase):
"""Schema for gateway response."""
id: int
tenant_id: int
serial_number: str | None
provisioning_method: ProvisioningMethod
vpn_ip: str | None
vpn_cert_cn: str | None
is_online: bool
is_provisioned: bool
last_seen: datetime | None
created_at: datetime
updated_at: datetime | None
class Config:
from_attributes = True
class GatewayStatus(BaseModel):
"""Schema for gateway online status."""
id: int
name: str
is_online: bool
last_seen: datetime | None
vpn_ip: str | None

View File

@ -0,0 +1,33 @@
"""Tenant-related Pydantic schemas."""
from datetime import datetime
from pydantic import BaseModel, Field
class TenantBase(BaseModel):
"""Base tenant schema."""
name: str = Field(..., min_length=2, max_length=255)
description: str | None = None
class TenantCreate(TenantBase):
"""Schema for creating a tenant."""
pass
class TenantUpdate(BaseModel):
"""Schema for updating a tenant."""
name: str | None = Field(None, min_length=2, max_length=255)
description: str | None = None
is_active: bool | None = None
class TenantResponse(TenantBase):
"""Schema for tenant response."""
id: int
is_active: bool
created_at: datetime
updated_at: datetime | None
class Config:
from_attributes = True

View File

@ -0,0 +1,63 @@
"""User-related Pydantic schemas."""
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
from ..models.user import UserRole
class UserBase(BaseModel):
"""Base user schema with common fields."""
username: str = Field(..., min_length=3, max_length=255)
email: EmailStr
full_name: str | None = None
role: UserRole = UserRole.TECHNICIAN
class UserCreate(UserBase):
"""Schema for creating a new user."""
password: str = Field(..., min_length=8)
tenant_id: int | None = None
class UserUpdate(BaseModel):
"""Schema for updating a user."""
email: EmailStr | None = None
full_name: str | None = None
role: UserRole | None = None
is_active: bool | None = None
password: str | None = Field(None, min_length=8)
class UserResponse(UserBase):
"""Schema for user response."""
id: int
tenant_id: int | None
is_active: bool
last_login: datetime | None
created_at: datetime
updated_at: datetime | None
class Config:
from_attributes = True
class UserLogin(BaseModel):
"""Schema for login request."""
username: str
password: str
class Token(BaseModel):
"""Schema for JWT token response."""
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenPayload(BaseModel):
"""Schema for decoded JWT payload."""
sub: int # user_id
username: str
role: str
tenant_id: int | None
exp: datetime

View File

@ -0,0 +1,17 @@
"""Business logic services."""
from .auth_service import AuthService
from .vpn_service import VPNService
from .firewall_service import FirewallService
from .certificate_service import CertificateService
from .vpn_server_service import VPNServerService
from .vpn_profile_service import VPNProfileService
__all__ = [
"AuthService",
"VPNService",
"FirewallService",
"CertificateService",
"VPNServerService",
"VPNProfileService",
]

View File

@ -0,0 +1,86 @@
"""Authentication service."""
from datetime import datetime
from sqlalchemy.orm import Session
from ..models.user import User
from ..schemas.user import UserCreate, Token
from ..utils.security import (
verify_password, get_password_hash,
create_access_token, create_refresh_token, decode_token
)
class AuthService:
"""Service for authentication operations."""
def __init__(self, db: Session):
self.db = db
def authenticate_user(self, username: str, password: str) -> User | None:
"""Authenticate user with username and password."""
user = self.db.query(User).filter(User.username == username).first()
if not user:
return None
if not verify_password(password, user.password_hash):
return None
if not user.is_active:
return None
# Update last login
user.last_login = datetime.utcnow()
self.db.commit()
return user
def create_tokens(self, user: User) -> Token:
"""Create access and refresh tokens for user."""
access_token = create_access_token(
user_id=user.id,
username=user.username,
role=user.role.value,
tenant_id=user.tenant_id
)
refresh_token = create_refresh_token(user_id=user.id)
return Token(
access_token=access_token,
refresh_token=refresh_token
)
def refresh_tokens(self, refresh_token: str) -> Token | None:
"""Refresh access token using refresh token."""
payload = decode_token(refresh_token)
if not payload:
return None
if payload.get("type") != "refresh":
return None
user_id = payload.get("sub")
user = self.db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
return None
return self.create_tokens(user)
def create_user(self, user_data: UserCreate) -> User:
"""Create a new user."""
user = User(
username=user_data.username,
email=user_data.email,
password_hash=get_password_hash(user_data.password),
full_name=user_data.full_name,
role=user_data.role,
tenant_id=user_data.tenant_id
)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
return user
def get_user_by_id(self, user_id: int) -> User | None:
"""Get user by ID."""
return self.db.query(User).filter(User.id == user_id).first()
def get_user_by_username(self, username: str) -> User | None:
"""Get user by username."""
return self.db.query(User).filter(User.username == username).first()

View File

@ -0,0 +1,588 @@
"""Certificate management service for PKI operations."""
import os
import subprocess
import threading
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtensionOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
from sqlalchemy.orm import Session
from ..models.certificate_authority import CertificateAuthority, CAStatus
from ..models.vpn_profile import VPNProfile, VPNProfileStatus
from ..config import get_settings
settings = get_settings()
class CertificateService:
"""Service for managing certificates and CAs."""
def __init__(self, db: Session):
self.db = db
def create_ca(
self,
name: str,
key_size: int = 4096,
validity_days: int = 3650,
organization: str = "mGuard VPN",
country: str = "DE",
state: str = "NRW",
city: str = "Dortmund",
tenant_id: Optional[int] = None,
created_by_id: Optional[int] = None,
is_default: bool = False
) -> CertificateAuthority:
"""Create a new Certificate Authority."""
# Generate CA private key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=key_size,
backend=default_backend()
)
# Build CA certificate
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, country),
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, state),
x509.NameAttribute(NameOID.LOCALITY_NAME, city),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
x509.NameAttribute(NameOID.COMMON_NAME, f"{name} CA"),
])
valid_from = datetime.utcnow()
valid_until = valid_from + timedelta(days=validity_days)
ca_cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(private_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(valid_from)
.not_valid_after(valid_until)
.add_extension(
x509.BasicConstraints(ca=True, path_length=0),
critical=True,
)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_cert_sign=True,
crl_sign=True,
key_encipherment=False,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
critical=False,
)
.sign(private_key, hashes.SHA256(), default_backend())
)
# Serialize to PEM
ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode('utf-8')
ca_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
# If this is default, unset other defaults for this tenant
if is_default:
self.db.query(CertificateAuthority).filter(
CertificateAuthority.tenant_id == tenant_id,
CertificateAuthority.is_default == True
).update({"is_default": False})
# Create CA record
ca = CertificateAuthority(
name=name,
tenant_id=tenant_id,
ca_cert=ca_cert_pem,
ca_key=ca_key_pem,
key_size=key_size,
valid_from=valid_from,
valid_until=valid_until,
is_default=is_default,
status=CAStatus.PENDING, # Will be ACTIVE after DH generation
created_by_id=created_by_id,
dh_generating=True
)
self.db.add(ca)
self.db.commit()
self.db.refresh(ca)
# Start DH parameter generation in background
self._generate_dh_async(ca.id, key_size)
return ca
def _generate_dh_async(self, ca_id: int, key_size: int):
"""Generate DH parameters in background thread."""
def generate():
try:
# Use openssl for DH generation (faster than pure Python)
result = subprocess.run(
["openssl", "dhparam", "-out", "-", str(key_size)],
capture_output=True,
text=True,
timeout=3600 # 1 hour max
)
if result.returncode == 0:
dh_pem = result.stdout
# Update CA in database (new session needed for thread)
from ..database import SessionLocal
db = SessionLocal()
try:
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if ca:
ca.dh_params = dh_pem
ca.dh_generating = False
ca.status = CAStatus.ACTIVE
db.commit()
finally:
db.close()
except Exception as e:
# Log error and mark CA as failed
from ..database import SessionLocal
db = SessionLocal()
try:
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if ca:
ca.dh_generating = False
ca.description = f"DH generation failed: {str(e)}"
db.commit()
finally:
db.close()
thread = threading.Thread(target=generate, daemon=True)
thread.start()
def import_ca(
self,
name: str,
ca_cert_pem: str,
ca_key_pem: str,
dh_params_pem: Optional[str] = None,
tenant_id: Optional[int] = None,
created_by_id: Optional[int] = None
) -> CertificateAuthority:
"""Import an existing CA from PEM files."""
# Parse certificate to extract metadata
ca_cert = x509.load_pem_x509_certificate(
ca_cert_pem.encode('utf-8'),
default_backend()
)
# Validate it's a CA certificate
try:
basic_constraints = ca_cert.extensions.get_extension_for_oid(
ExtensionOID.BASIC_CONSTRAINTS
)
if not basic_constraints.value.ca:
raise ValueError("Certificate is not a CA certificate")
except x509.extensions.ExtensionNotFound:
raise ValueError("Certificate missing BasicConstraints extension")
# Get key size from private key
private_key = serialization.load_pem_private_key(
ca_key_pem.encode('utf-8'),
password=None,
backend=default_backend()
)
key_size = private_key.key_size
ca = CertificateAuthority(
name=name,
tenant_id=tenant_id,
ca_cert=ca_cert_pem,
ca_key=ca_key_pem,
dh_params=dh_params_pem,
key_size=key_size,
valid_from=ca_cert.not_valid_before_utc,
valid_until=ca_cert.not_valid_after_utc,
status=CAStatus.ACTIVE if dh_params_pem else CAStatus.PENDING,
created_by_id=created_by_id,
dh_generating=dh_params_pem is None
)
self.db.add(ca)
self.db.commit()
self.db.refresh(ca)
# Generate DH if not provided
if not dh_params_pem:
self._generate_dh_async(ca.id, key_size)
return ca
def generate_server_certificate(
self,
ca: CertificateAuthority,
common_name: str,
validity_days: int = 825
) -> dict:
"""Generate a server certificate from the CA."""
if not ca.is_ready:
raise ValueError("CA is not ready for issuing certificates")
# Load CA key and cert
ca_key = serialization.load_pem_private_key(
ca.ca_key.encode('utf-8'),
password=None,
backend=default_backend()
)
ca_cert = x509.load_pem_x509_certificate(
ca.ca_cert.encode('utf-8'),
default_backend()
)
# Generate server private key
server_key = rsa.generate_private_key(
public_exponent=65537,
key_size=ca.key_size,
backend=default_backend()
)
# Build server certificate
valid_from = datetime.utcnow()
valid_until = valid_from + timedelta(days=validity_days)
subject = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
])
server_cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(ca_cert.subject)
.public_key(server_key.public_key())
.serial_number(ca.next_serial)
.not_valid_before(valid_from)
.not_valid_after(valid_until)
.add_extension(
x509.BasicConstraints(ca=False, path_length=None),
critical=True,
)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
key_cert_sign=False,
crl_sign=False,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.add_extension(
x509.ExtendedKeyUsage([
x509.oid.ExtendedKeyUsageOID.SERVER_AUTH,
]),
critical=False,
)
.add_extension(
x509.SubjectAlternativeName([
x509.DNSName(common_name),
]),
critical=False,
)
.sign(ca_key, hashes.SHA256(), default_backend())
)
# Increment serial
ca.next_serial += 1
self.db.commit()
# Serialize to PEM
cert_pem = server_cert.public_bytes(serialization.Encoding.PEM).decode('utf-8')
key_pem = server_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
return {
"cert": cert_pem,
"key": key_pem,
"valid_from": valid_from,
"valid_until": valid_until,
"serial": ca.next_serial - 1
}
def generate_client_certificate(
self,
ca: CertificateAuthority,
common_name: str,
validity_days: int = 365
) -> dict:
"""Generate a client certificate from the CA."""
if not ca.is_ready:
raise ValueError("CA is not ready for issuing certificates")
# Load CA key and cert
ca_key = serialization.load_pem_private_key(
ca.ca_key.encode('utf-8'),
password=None,
backend=default_backend()
)
ca_cert = x509.load_pem_x509_certificate(
ca.ca_cert.encode('utf-8'),
default_backend()
)
# Generate client private key
client_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048, # Client keys can be smaller
backend=default_backend()
)
# Build client certificate
valid_from = datetime.utcnow()
valid_until = valid_from + timedelta(days=validity_days)
subject = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
])
client_cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(ca_cert.subject)
.public_key(client_key.public_key())
.serial_number(ca.next_serial)
.not_valid_before(valid_from)
.not_valid_after(valid_until)
.add_extension(
x509.BasicConstraints(ca=False, path_length=None),
critical=True,
)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
key_cert_sign=False,
crl_sign=False,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.add_extension(
x509.ExtendedKeyUsage([
x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
]),
critical=False,
)
.sign(ca_key, hashes.SHA256(), default_backend())
)
# Increment serial
ca.next_serial += 1
self.db.commit()
# Serialize to PEM
cert_pem = client_cert.public_bytes(serialization.Encoding.PEM).decode('utf-8')
key_pem = client_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
return {
"cert": cert_pem,
"key": key_pem,
"valid_from": valid_from,
"valid_until": valid_until,
"serial": ca.next_serial - 1
}
def generate_ta_key(self) -> str:
"""Generate TLS-Auth key using OpenVPN."""
try:
result = subprocess.run(
["openvpn", "--genkey", "secret", "/dev/stdout"],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
return result.stdout
else:
raise RuntimeError(f"Failed to generate TA key: {result.stderr}")
except FileNotFoundError:
# OpenVPN not installed, generate a simple key
import secrets
key_data = secrets.token_hex(256)
return f"""#
# 2048 bit OpenVPN static key
#
-----BEGIN OpenVPN Static key V1-----
{key_data[:64]}
{key_data[64:128]}
{key_data[128:192]}
{key_data[192:256]}
{key_data[256:320]}
{key_data[320:384]}
{key_data[384:448]}
{key_data[448:512]}
-----END OpenVPN Static key V1-----
"""
def revoke_certificate(
self,
ca: CertificateAuthority,
cert_pem: str,
reason: str = "unspecified"
) -> bool:
"""Revoke a certificate and update CRL."""
# Parse certificate to get serial number
cert = x509.load_pem_x509_certificate(
cert_pem.encode('utf-8'),
default_backend()
)
# Load CA key
ca_key = serialization.load_pem_private_key(
ca.ca_key.encode('utf-8'),
password=None,
backend=default_backend()
)
# Load existing CRL or create new one
revoked_certs = []
if ca.crl:
try:
existing_crl = x509.load_pem_x509_crl(
ca.crl.encode('utf-8'),
default_backend()
)
for revoked in existing_crl:
revoked_certs.append(revoked)
except Exception:
pass
# Add new revoked certificate
revoked_cert = (
x509.RevokedCertificateBuilder()
.serial_number(cert.serial_number)
.revocation_date(datetime.utcnow())
.build()
)
revoked_certs.append(revoked_cert)
# Load CA cert
ca_cert = x509.load_pem_x509_certificate(
ca.ca_cert.encode('utf-8'),
default_backend()
)
# Build new CRL
crl_builder = (
x509.CertificateRevocationListBuilder()
.issuer_name(ca_cert.subject)
.last_update(datetime.utcnow())
.next_update(datetime.utcnow() + timedelta(days=180))
)
for revoked in revoked_certs:
crl_builder = crl_builder.add_revoked_certificate(revoked)
crl = crl_builder.sign(ca_key, hashes.SHA256(), default_backend())
# Update CA
ca.crl = crl.public_bytes(serialization.Encoding.PEM).decode('utf-8')
ca.crl_updated_at = datetime.utcnow()
self.db.commit()
return True
def get_crl(self, ca: CertificateAuthority) -> str:
"""Get the CRL for a CA, generating if needed."""
if not ca.crl:
# Generate empty CRL
ca_key = serialization.load_pem_private_key(
ca.ca_key.encode('utf-8'),
password=None,
backend=default_backend()
)
ca_cert = x509.load_pem_x509_certificate(
ca.ca_cert.encode('utf-8'),
default_backend()
)
crl = (
x509.CertificateRevocationListBuilder()
.issuer_name(ca_cert.subject)
.last_update(datetime.utcnow())
.next_update(datetime.utcnow() + timedelta(days=180))
.sign(ca_key, hashes.SHA256(), default_backend())
)
ca.crl = crl.public_bytes(serialization.Encoding.PEM).decode('utf-8')
ca.crl_updated_at = datetime.utcnow()
self.db.commit()
return ca.crl
def get_expiring_certificates(
self,
ca: CertificateAuthority,
days: int = 30
) -> list[VPNProfile]:
"""Get profiles with certificates expiring within given days."""
threshold = datetime.utcnow() + timedelta(days=days)
return self.db.query(VPNProfile).filter(
VPNProfile.ca_id == ca.id,
VPNProfile.valid_until <= threshold,
VPNProfile.status == VPNProfileStatus.ACTIVE
).all()
def get_default_ca(self, tenant_id: Optional[int] = None) -> Optional[CertificateAuthority]:
"""Get the default CA for a tenant (or global)."""
ca = self.db.query(CertificateAuthority).filter(
CertificateAuthority.tenant_id == tenant_id,
CertificateAuthority.is_default == True,
CertificateAuthority.status == CAStatus.ACTIVE
).first()
if not ca and tenant_id:
# Fall back to global CA
ca = self.db.query(CertificateAuthority).filter(
CertificateAuthority.tenant_id == None,
CertificateAuthority.is_default == True,
CertificateAuthority.status == CAStatus.ACTIVE
).first()
return ca

View File

@ -0,0 +1,129 @@
"""Firewall management service for dynamic iptables rules."""
import subprocess
from typing import Literal
class FirewallService:
"""Service for managing iptables firewall rules."""
# Chain name for our VPN rules
VPN_CHAIN = "MGUARD_VPN"
def __init__(self):
self._ensure_chain_exists()
def _run_iptables(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess:
"""Run iptables command."""
cmd = ["iptables"] + args
return subprocess.run(cmd, capture_output=True, text=True, check=check)
def _ensure_chain_exists(self):
"""Ensure our custom chain exists."""
# Check if chain exists
result = self._run_iptables(["-L", self.VPN_CHAIN], check=False)
if result.returncode != 0:
# Create chain
self._run_iptables(["-N", self.VPN_CHAIN], check=False)
# Add jump to our chain from FORWARD
self._run_iptables(["-I", "FORWARD", "-j", self.VPN_CHAIN], check=False)
def allow_connection(
self,
client_vpn_ip: str,
gateway_vpn_ip: str,
target_ip: str,
target_port: int,
protocol: Literal["tcp", "udp"] = "tcp"
) -> bool:
"""Allow connection from client through gateway to target endpoint."""
try:
# Rule 1: Allow client to reach target through gateway
self._run_iptables([
"-A", self.VPN_CHAIN,
"-s", client_vpn_ip,
"-d", target_ip,
"-p", protocol,
"--dport", str(target_port),
"-j", "ACCEPT"
])
# Rule 2: Allow return traffic
self._run_iptables([
"-A", self.VPN_CHAIN,
"-s", target_ip,
"-d", client_vpn_ip,
"-p", protocol,
"--sport", str(target_port),
"-j", "ACCEPT"
])
# Add NAT/masquerade if needed for routing through gateway
self._run_iptables([
"-t", "nat",
"-A", "POSTROUTING",
"-s", client_vpn_ip,
"-d", target_ip,
"-j", "MASQUERADE"
], check=False)
return True
except subprocess.CalledProcessError:
return False
def revoke_connection(
self,
client_vpn_ip: str,
gateway_vpn_ip: str,
target_ip: str,
target_port: int,
protocol: Literal["tcp", "udp"] = "tcp"
) -> bool:
"""Remove firewall rules for a connection."""
try:
# Remove forward rules
self._run_iptables([
"-D", self.VPN_CHAIN,
"-s", client_vpn_ip,
"-d", target_ip,
"-p", protocol,
"--dport", str(target_port),
"-j", "ACCEPT"
], check=False)
self._run_iptables([
"-D", self.VPN_CHAIN,
"-s", target_ip,
"-d", client_vpn_ip,
"-p", protocol,
"--sport", str(target_port),
"-j", "ACCEPT"
], check=False)
# Remove NAT rule
self._run_iptables([
"-t", "nat",
"-D", "POSTROUTING",
"-s", client_vpn_ip,
"-d", target_ip,
"-j", "MASQUERADE"
], check=False)
return True
except subprocess.CalledProcessError:
return False
def list_rules(self) -> list[str]:
"""List all rules in our VPN chain."""
result = self._run_iptables(["-L", self.VPN_CHAIN, "-n", "-v"], check=False)
if result.returncode == 0:
return result.stdout.strip().split('\n')
return []
def flush_rules(self) -> bool:
"""Remove all rules from our VPN chain."""
try:
self._run_iptables(["-F", self.VPN_CHAIN])
return True
except subprocess.CalledProcessError:
return False

View File

@ -0,0 +1,282 @@
"""VPN Profile management service."""
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from ..models.vpn_profile import VPNProfile, VPNProfileStatus
from ..models.vpn_server import VPNServer
from ..models.gateway import Gateway
from ..models.certificate_authority import CertificateAuthority
from .certificate_service import CertificateService
from ..config import get_settings
settings = get_settings()
class VPNProfileService:
"""Service for managing VPN profiles for gateways."""
def __init__(self, db: Session):
self.db = db
self.cert_service = CertificateService(db)
def create_profile(
self,
gateway_id: int,
vpn_server_id: int,
name: str,
priority: int = 1,
description: Optional[str] = None
) -> VPNProfile:
"""Create a new VPN profile for a gateway."""
# Get gateway
gateway = self.db.query(Gateway).filter(
Gateway.id == gateway_id
).first()
if not gateway:
raise ValueError(f"Gateway with id {gateway_id} not found")
# Get VPN server
server = self.db.query(VPNServer).filter(
VPNServer.id == vpn_server_id
).first()
if not server:
raise ValueError(f"VPN Server with id {vpn_server_id} not found")
if not server.certificate_authority.is_ready:
raise ValueError("CA is not ready (DH parameters may still be generating)")
# Generate unique common name
base_cn = f"gw-{gateway.name.lower().replace(' ', '-')}"
profile_count = self.db.query(VPNProfile).filter(
VPNProfile.gateway_id == gateway_id
).count()
cert_cn = f"{base_cn}-{profile_count + 1}"
# Create profile
profile = VPNProfile(
gateway_id=gateway_id,
vpn_server_id=vpn_server_id,
ca_id=server.ca_id,
name=name,
description=description,
cert_cn=cert_cn,
priority=priority,
status=VPNProfileStatus.PENDING
)
self.db.add(profile)
self.db.commit()
self.db.refresh(profile)
# Generate client certificate
self._generate_client_cert(profile)
return profile
def _generate_client_cert(self, profile: VPNProfile):
"""Generate client certificate for profile."""
ca = profile.certificate_authority
cert_data = self.cert_service.generate_client_certificate(
ca=ca,
common_name=profile.cert_cn
)
profile.client_cert = cert_data["cert"]
profile.client_key = cert_data["key"]
profile.valid_from = cert_data["valid_from"]
profile.valid_until = cert_data["valid_until"]
profile.status = VPNProfileStatus.ACTIVE
self.db.commit()
def generate_client_config(self, profile: VPNProfile) -> str:
"""Generate OpenVPN client configuration (.ovpn) for a profile."""
if not profile.is_ready:
raise ValueError("Profile is not ready for provisioning")
server = profile.vpn_server
ca = profile.certificate_authority
config_lines = [
"# OpenVPN Client Configuration",
f"# Profile: {profile.name}",
f"# Gateway: {profile.gateway.name}",
f"# Server: {server.name}",
f"# Generated: {datetime.utcnow().isoformat()}",
"",
"client",
"dev tun",
f"proto {server.protocol.value}",
f"remote {server.hostname} {server.port}",
"",
"resolv-retry infinite",
"nobind",
"persist-key",
"persist-tun",
"",
"remote-cert-tls server",
f"cipher {server.cipher.value}",
f"auth {server.auth.value}",
"",
"verb 3",
"",
]
# Add CA certificate
config_lines.extend([
"<ca>",
ca.ca_cert.strip(),
"</ca>",
"",
])
# Add client certificate
config_lines.extend([
"<cert>",
profile.client_cert.strip(),
"</cert>",
"",
])
# Add client key
config_lines.extend([
"<key>",
profile.client_key.strip(),
"</key>",
"",
])
# Add TLS-Auth key if enabled
if server.tls_auth_enabled and server.ta_key:
config_lines.extend([
"key-direction 1",
"<tls-auth>",
server.ta_key.strip(),
"</tls-auth>",
])
return "\n".join(config_lines)
def provision_profile(self, profile: VPNProfile) -> str:
"""Mark profile as provisioned and return config."""
config = self.generate_client_config(profile)
profile.status = VPNProfileStatus.PROVISIONED
profile.provisioned_at = datetime.utcnow()
profile.gateway.is_provisioned = True
self.db.commit()
return config
def set_priority(self, profile: VPNProfile, new_priority: int):
"""Set profile priority and reorder others if needed."""
gateway_id = profile.gateway_id
# Get all profiles for this gateway ordered by priority
profiles = self.db.query(VPNProfile).filter(
VPNProfile.gateway_id == gateway_id,
VPNProfile.id != profile.id
).order_by(VPNProfile.priority).all()
# Update priorities
current_priority = 1
for p in profiles:
if current_priority == new_priority:
current_priority += 1
p.priority = current_priority
current_priority += 1
profile.priority = new_priority
self.db.commit()
def get_profiles_for_gateway(self, gateway_id: int) -> list[VPNProfile]:
"""Get all VPN profiles for a gateway, ordered by priority."""
return self.db.query(VPNProfile).filter(
VPNProfile.gateway_id == gateway_id
).order_by(VPNProfile.priority).all()
def get_active_profiles_for_gateway(self, gateway_id: int) -> list[VPNProfile]:
"""Get active VPN profiles for a gateway."""
return self.db.query(VPNProfile).filter(
VPNProfile.gateway_id == gateway_id,
VPNProfile.is_active == True,
VPNProfile.status.in_([VPNProfileStatus.ACTIVE, VPNProfileStatus.PROVISIONED])
).order_by(VPNProfile.priority).all()
def revoke_profile(self, profile: VPNProfile, reason: str = "unspecified"):
"""Revoke a VPN profile's certificate."""
if profile.client_cert:
self.cert_service.revoke_certificate(
ca=profile.certificate_authority,
cert_pem=profile.client_cert,
reason=reason
)
profile.status = VPNProfileStatus.REVOKED
profile.is_active = False
self.db.commit()
def renew_profile(self, profile: VPNProfile):
"""Renew the certificate for a profile."""
# Revoke old certificate
if profile.client_cert:
self.cert_service.revoke_certificate(
ca=profile.certificate_authority,
cert_pem=profile.client_cert,
reason="superseded"
)
# Generate new certificate
self._generate_client_cert(profile)
# Reset provisioned status
profile.status = VPNProfileStatus.ACTIVE
profile.provisioned_at = None
self.db.commit()
def get_profile_by_id(self, profile_id: int) -> Optional[VPNProfile]:
"""Get a VPN profile by ID."""
return self.db.query(VPNProfile).filter(
VPNProfile.id == profile_id
).first()
def delete_profile(self, profile: VPNProfile):
"""Delete a VPN profile."""
# Revoke certificate first
if profile.client_cert and profile.status != VPNProfileStatus.REVOKED:
self.cert_service.revoke_certificate(
ca=profile.certificate_authority,
cert_pem=profile.client_cert,
reason="cessationOfOperation"
)
self.db.delete(profile)
self.db.commit()
def generate_all_configs_for_gateway(self, gateway_id: int) -> list[tuple[str, str]]:
"""Generate configs for all active profiles of a gateway.
Returns list of tuples: (filename, config_content)
"""
profiles = self.get_active_profiles_for_gateway(gateway_id)
configs = []
for profile in profiles:
try:
config = self.generate_client_config(profile)
filename = f"{profile.gateway.name}-{profile.name}.ovpn"
filename = filename.lower().replace(' ', '-')
configs.append((filename, config))
except Exception:
continue
return configs

View File

@ -0,0 +1,344 @@
"""VPN Server management service."""
import socket
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from ..models.vpn_server import VPNServer, VPNServerStatus, VPNProtocol, VPNCipher, VPNAuth, VPNCompression
from ..models.certificate_authority import CertificateAuthority
from .certificate_service import CertificateService
from ..config import get_settings
settings = get_settings()
class VPNServerService:
"""Service for managing VPN server instances."""
def __init__(self, db: Session):
self.db = db
self.cert_service = CertificateService(db)
def create_server(
self,
name: str,
hostname: str,
ca_id: int,
port: int = 1194,
protocol: VPNProtocol = VPNProtocol.UDP,
vpn_network: str = "10.8.0.0",
vpn_netmask: str = "255.255.255.0",
cipher: VPNCipher = VPNCipher.AES_256_GCM,
auth: VPNAuth = VPNAuth.SHA256,
tls_version_min: str = "1.2",
compression: VPNCompression = VPNCompression.NONE,
max_clients: int = 100,
keepalive_interval: int = 10,
keepalive_timeout: int = 60,
management_port: int = 7505,
is_primary: bool = False,
tenant_id: Optional[int] = None,
description: Optional[str] = None
) -> VPNServer:
"""Create a new VPN server instance."""
# Get CA
ca = self.db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if not ca:
raise ValueError(f"CA with id {ca_id} not found")
if not ca.is_ready:
raise ValueError("CA is not ready (DH parameters may still be generating)")
# Generate container name
existing_count = self.db.query(VPNServer).count()
container_name = f"mguard-openvpn-{existing_count + 1}"
# If setting as primary, unset other primaries
if is_primary:
self.db.query(VPNServer).filter(
VPNServer.tenant_id == tenant_id,
VPNServer.is_primary == True
).update({"is_primary": False})
# Create server record
server = VPNServer(
name=name,
description=description,
hostname=hostname,
port=port,
protocol=protocol,
vpn_network=vpn_network,
vpn_netmask=vpn_netmask,
cipher=cipher,
auth=auth,
tls_version_min=tls_version_min,
compression=compression,
max_clients=max_clients,
keepalive_interval=keepalive_interval,
keepalive_timeout=keepalive_timeout,
management_port=management_port,
docker_container_name=container_name,
ca_id=ca_id,
tenant_id=tenant_id,
is_primary=is_primary,
status=VPNServerStatus.PENDING
)
self.db.add(server)
self.db.commit()
self.db.refresh(server)
# Generate server certificate
self._generate_server_cert(server)
return server
def _generate_server_cert(self, server: VPNServer):
"""Generate server certificate and TA key."""
ca = server.certificate_authority
# Generate server certificate
cert_data = self.cert_service.generate_server_certificate(
ca=ca,
common_name=f"{server.name}-server"
)
server.server_cert = cert_data["cert"]
server.server_key = cert_data["key"]
# Generate TLS-Auth key
server.ta_key = self.cert_service.generate_ta_key()
self.db.commit()
def generate_server_config(self, server: VPNServer) -> str:
"""Generate OpenVPN server configuration file."""
if not server.certificate_authority:
raise ValueError("Server has no CA assigned")
config_lines = [
"# OpenVPN Server Configuration",
f"# Server: {server.name}",
f"# Generated: {datetime.utcnow().isoformat()}",
"",
"# Basic settings",
f"port {server.port}",
f"proto {server.protocol.value}",
"dev tun",
"",
"# Certificates",
"ca /etc/openvpn/ca.crt",
"cert /etc/openvpn/server.crt",
"key /etc/openvpn/server.key",
"dh /etc/openvpn/dh.pem",
"crl-verify /etc/openvpn/crl.pem",
"",
"# TLS Auth",
]
if server.tls_auth_enabled and server.ta_key:
config_lines.append("tls-auth /etc/openvpn/ta.key 0")
config_lines.extend([
f"tls-version-min {server.tls_version_min}",
"",
"# Network",
f"server {server.vpn_network} {server.vpn_netmask}",
"topology subnet",
"",
"# Routing",
'push "redirect-gateway def1 bypass-dhcp"',
'push "dhcp-option DNS 8.8.8.8"',
'push "dhcp-option DNS 8.8.4.4"',
"",
"# Security",
f"cipher {server.cipher.value}",
f"auth {server.auth.value}",
"",
"# Performance",
f"keepalive {server.keepalive_interval} {server.keepalive_timeout}",
f"max-clients {server.max_clients}",
])
# Compression handling
# Note: OpenVPN 2.5+ deprecates compression due to VORACLE attack
if server.compression != VPNCompression.NONE:
config_lines.append(f"compress {server.compression.value}")
config_lines.append("allow-compression yes")
# If compression is NONE, don't add any directive - OpenVPN defaults to no compression
config_lines.extend([
"",
"# Persistence",
"persist-key",
"persist-tun",
"",
"# Logging",
f"status /var/log/openvpn/status-{server.id}.log",
f"log-append /var/log/openvpn/server-{server.id}.log",
"verb 3",
"",
"# Management interface",
f"management 0.0.0.0 {server.management_port}",
"",
"# User/Group (for Linux)",
"user nobody",
"group nogroup",
"",
"# Client config directory",
"client-config-dir /etc/openvpn/ccd",
"",
"# Scripts",
"script-security 2",
"client-connect /etc/openvpn/scripts/client-connect.sh",
"client-disconnect /etc/openvpn/scripts/client-disconnect.sh",
])
return "\n".join(config_lines)
def get_connected_clients(self, server: VPNServer) -> list[dict]:
"""Get list of currently connected VPN clients."""
try:
response = self._send_management_command(server, "status")
clients = []
if "ERROR" in response:
return clients
# Parse status output
in_client_list = False
for line in response.split('\n'):
if line.startswith("ROUTING TABLE"):
in_client_list = False
elif line.startswith("Common Name"):
in_client_list = True
continue
elif in_client_list and ',' in line:
parts = line.split(',')
if len(parts) >= 5:
clients.append({
"common_name": parts[0],
"real_address": parts[1],
"bytes_received": int(parts[2]) if parts[2].isdigit() else 0,
"bytes_sent": int(parts[3]) if parts[3].isdigit() else 0,
"connected_since": parts[4]
})
# Update connected count
server.connected_clients = len(clients)
server.last_status_check = datetime.utcnow()
self.db.commit()
return clients
except Exception as e:
return []
def disconnect_client(self, server: VPNServer, common_name: str) -> bool:
"""Disconnect a specific VPN client."""
response = self._send_management_command(server, f"kill {common_name}")
return "SUCCESS" in response
def get_server_status(self, server: VPNServer) -> dict:
"""Get detailed server status."""
try:
# Try to connect to management interface
response = self._send_management_command(server, "state")
if "ERROR" in response:
server.status = VPNServerStatus.ERROR
self.db.commit()
return {
"status": "error",
"message": response
}
# Parse state
for line in response.split('\n'):
if 'CONNECTED' in line:
server.status = VPNServerStatus.RUNNING
break
else:
server.status = VPNServerStatus.STOPPED
server.last_status_check = datetime.utcnow()
self.db.commit()
clients = self.get_connected_clients(server)
return {
"status": server.status.value,
"connected_clients": len(clients),
"clients": clients,
"last_check": server.last_status_check.isoformat() if server.last_status_check else None
}
except Exception as e:
return {
"status": "unknown",
"message": str(e)
}
def _send_management_command(self, server: VPNServer, command: str) -> str:
"""Send command to OpenVPN management interface."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
# Connect to management interface
# OpenVPN runs in host network mode, so we connect via host.docker.internal
# This resolves to the Docker host from within containers
host = "host.docker.internal"
sock.connect((host, server.management_port))
# Read welcome message
sock.recv(1024)
# Send command
sock.send(f"{command}\n".encode())
# Read response
response = b""
while True:
data = sock.recv(4096)
if not data:
break
response += data
if b"END" in data or b"SUCCESS" in data or b"ERROR" in data:
break
sock.close()
return response.decode()
except Exception as e:
return f"ERROR: {str(e)}"
def update_all_server_status(self):
"""Update status for all active servers."""
servers = self.db.query(VPNServer).filter(
VPNServer.is_active == True
).all()
for server in servers:
self.get_server_status(server)
def get_server_by_id(self, server_id: int) -> Optional[VPNServer]:
"""Get a VPN server by ID."""
return self.db.query(VPNServer).filter(
VPNServer.id == server_id
).first()
def get_servers_for_tenant(self, tenant_id: Optional[int] = None) -> list[VPNServer]:
"""Get all VPN servers for a tenant."""
query = self.db.query(VPNServer)
if tenant_id:
query = query.filter(
(VPNServer.tenant_id == tenant_id) | (VPNServer.tenant_id == None)
)
else:
query = query.filter(VPNServer.tenant_id == None)
return query.order_by(VPNServer.name).all()

View File

@ -0,0 +1,95 @@
"""VPN management service for OpenVPN operations.
This service provides basic OpenVPN management interface communication.
For PKI/certificate management, use CertificateService.
For VPN server management, use VPNServerService.
"""
import socket
from ..config import get_settings
settings = get_settings()
class VPNService:
"""Service for OpenVPN management interface operations."""
def __init__(self, host: str = None, port: int = None):
self.management_host = host or settings.openvpn_management_host
self.management_port = port or settings.openvpn_management_port
def _send_management_command(self, command: str) -> str:
"""Send command to OpenVPN management interface."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((self.management_host, self.management_port))
# Read welcome message
sock.recv(1024)
# Send command
sock.send(f"{command}\n".encode())
# Read response
response = b""
while True:
data = sock.recv(4096)
if not data:
break
response += data
if b"END" in data or b"SUCCESS" in data or b"ERROR" in data:
break
sock.close()
return response.decode()
except Exception as e:
return f"ERROR: {str(e)}"
def get_connected_clients(self) -> list[dict]:
"""Get list of currently connected VPN clients."""
response = self._send_management_command("status")
clients = []
if "ERROR" in response:
return clients
# Parse status output
in_client_list = False
for line in response.split('\n'):
if line.startswith("ROUTING TABLE"):
in_client_list = False
elif line.startswith("Common Name"):
in_client_list = True
continue
elif in_client_list and ',' in line:
parts = line.split(',')
if len(parts) >= 5:
clients.append({
"common_name": parts[0],
"real_address": parts[1],
"bytes_received": int(parts[2]) if parts[2].isdigit() else 0,
"bytes_sent": int(parts[3]) if parts[3].isdigit() else 0,
"connected_since": parts[4]
})
return clients
def disconnect_client(self, common_name: str) -> bool:
"""Disconnect a specific VPN client."""
response = self._send_management_command(f"kill {common_name}")
return "SUCCESS" in response
def get_server_status(self) -> dict:
"""Get OpenVPN server status."""
response = self._send_management_command("state")
if "ERROR" in response:
return {"status": "error", "message": response}
# Parse state
for line in response.split('\n'):
if 'CONNECTED' in line:
return {"status": "running", "message": "Server is running"}
return {"status": "unknown", "message": response}

View File

@ -0,0 +1,182 @@
"""VPN Sync Service for synchronizing VPN connection state with database."""
from datetime import datetime
from sqlalchemy.orm import Session
from ..models.vpn_server import VPNServer
from ..models.vpn_profile import VPNProfile
from ..models.vpn_connection_log import VPNConnectionLog
from ..models.gateway import Gateway
from .vpn_server_service import VPNServerService
class VPNSyncService:
"""Service to sync VPN connections with gateway status and logs."""
def __init__(self, db: Session):
self.db = db
self.vpn_service = VPNServerService(db)
def sync_all_connections(self) -> dict:
"""
Sync all VPN connections across all servers.
Returns summary of changes.
"""
result = {
"servers_checked": 0,
"clients_found": 0,
"gateways_online": 0,
"gateways_offline": 0,
"new_connections": 0,
"closed_connections": 0,
"errors": []
}
# Get all active VPN servers
servers = self.db.query(VPNServer).filter(VPNServer.is_active == True).all()
# Collect all currently connected CNs
connected_cns = set()
for server in servers:
result["servers_checked"] += 1
try:
clients = self.vpn_service.get_connected_clients(server)
result["clients_found"] += len(clients)
for client in clients:
cn = client.get("common_name")
if cn:
connected_cns.add(cn)
self._handle_connected_client(server, client, result)
except Exception as e:
result["errors"].append(f"Server {server.name}: {str(e)}")
# Mark disconnected profiles/gateways
self._handle_disconnected_profiles(connected_cns, result)
self.db.commit()
return result
def _handle_connected_client(self, server: VPNServer, client: dict, result: dict):
"""Handle a connected VPN client - update profile, gateway, and log."""
cn = client.get("common_name")
real_address = client.get("real_address")
bytes_rx = client.get("bytes_received", 0)
bytes_tx = client.get("bytes_sent", 0)
connected_since = client.get("connected_since")
# Find profile by CN
profile = self.db.query(VPNProfile).filter(
VPNProfile.cert_cn == cn,
VPNProfile.vpn_server_id == server.id
).first()
if not profile:
# Try finding by CN across all servers (in case of migration)
profile = self.db.query(VPNProfile).filter(
VPNProfile.cert_cn == cn
).first()
if not profile:
return # Unknown client, skip
gateway = profile.gateway
# Update gateway status
if not gateway.is_online:
gateway.is_online = True
gateway.last_seen = datetime.utcnow()
result["gateways_online"] += 1
# Update profile last connection
profile.last_connection = datetime.utcnow()
# Check for existing active connection log
active_log = self.db.query(VPNConnectionLog).filter(
VPNConnectionLog.vpn_profile_id == profile.id,
VPNConnectionLog.disconnected_at.is_(None)
).first()
if not active_log:
# Create new connection log
log = VPNConnectionLog(
vpn_profile_id=profile.id,
vpn_server_id=server.id,
gateway_id=gateway.id,
common_name=cn,
real_address=real_address,
connected_at=datetime.utcnow(),
bytes_received=bytes_rx,
bytes_sent=bytes_tx
)
self.db.add(log)
result["new_connections"] += 1
else:
# Update traffic stats
active_log.bytes_received = bytes_rx
active_log.bytes_sent = bytes_tx
active_log.real_address = real_address
def _handle_disconnected_profiles(self, connected_cns: set, result: dict):
"""Mark profiles as disconnected if their CN is no longer connected."""
# Find all active connection logs
active_logs = self.db.query(VPNConnectionLog).filter(
VPNConnectionLog.disconnected_at.is_(None)
).all()
for log in active_logs:
if log.common_name not in connected_cns:
# Mark as disconnected
log.disconnected_at = datetime.utcnow()
result["closed_connections"] += 1
# Update gateway status
gateway = log.gateway
# Check if gateway has any other active connections
other_active = self.db.query(VPNConnectionLog).filter(
VPNConnectionLog.gateway_id == gateway.id,
VPNConnectionLog.id != log.id,
VPNConnectionLog.disconnected_at.is_(None)
).first()
if not other_active:
gateway.is_online = False
result["gateways_offline"] += 1
def get_profile_connection_logs(
self,
profile_id: int,
limit: int = 50
) -> list[VPNConnectionLog]:
"""Get connection logs for a specific profile."""
return self.db.query(VPNConnectionLog).filter(
VPNConnectionLog.vpn_profile_id == profile_id
).order_by(VPNConnectionLog.connected_at.desc()).limit(limit).all()
def get_gateway_connection_logs(
self,
gateway_id: int,
limit: int = 50
) -> list[VPNConnectionLog]:
"""Get connection logs for a gateway (all profiles)."""
return self.db.query(VPNConnectionLog).filter(
VPNConnectionLog.gateway_id == gateway_id
).order_by(VPNConnectionLog.connected_at.desc()).limit(limit).all()
def get_server_connection_logs(
self,
server_id: int,
limit: int = 50
) -> list[VPNConnectionLog]:
"""Get connection logs for a VPN server."""
return self.db.query(VPNConnectionLog).filter(
VPNConnectionLog.vpn_server_id == server_id
).order_by(VPNConnectionLog.connected_at.desc()).limit(limit).all()
def get_active_connections(self) -> list[VPNConnectionLog]:
"""Get all currently active VPN connections."""
return self.db.query(VPNConnectionLog).filter(
VPNConnectionLog.disconnected_at.is_(None)
).order_by(VPNConnectionLog.connected_at.desc()).all()

View File

@ -0,0 +1,196 @@
/* mGuard VPN Manager - Custom Styles */
/* Body */
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
/* Gateway Status Indicators */
.status-online {
color: #198754;
}
.status-offline {
color: #6c757d;
}
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
}
.status-indicator.online {
background-color: #198754;
box-shadow: 0 0 8px #198754;
}
.status-indicator.offline {
background-color: #6c757d;
}
/* Gateway Cards */
.gateway-card {
transition: transform 0.2s, box-shadow 0.2s;
}
.gateway-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.gateway-card.online {
border-left: 4px solid #198754;
}
.gateway-card.offline {
border-left: 4px solid #6c757d;
}
/* Dashboard Stats */
.stat-card {
border-radius: 10px;
}
.stat-card .stat-icon {
font-size: 2.5rem;
opacity: 0.2;
}
.stat-card .stat-value {
font-size: 2rem;
font-weight: bold;
}
/* Tables */
.table-hover tbody tr {
cursor: pointer;
}
/* Login Page */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
}
.login-box {
background: white;
border-radius: 10px;
padding: 2rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
.login-logo {
font-size: 3rem;
color: #0d6efd;
}
/* HTMX Loading Indicator */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
.htmx-request.htmx-indicator {
display: inline-block;
}
/* Sidebar (optional) */
.sidebar {
min-width: 250px;
max-width: 250px;
min-height: 100vh;
background: #212529;
}
.sidebar a {
color: rgba(255,255,255,.8);
text-decoration: none;
padding: 10px 15px;
display: block;
}
.sidebar a:hover {
background: rgba(255,255,255,.1);
color: white;
}
.sidebar a.active {
background: #0d6efd;
color: white;
}
/* Badges */
.badge-role-super_admin {
background-color: #dc3545;
}
.badge-role-admin {
background-color: #0d6efd;
}
.badge-role-technician {
background-color: #198754;
}
.badge-role-viewer {
background-color: #6c757d;
}
/* Endpoint Protocol Badges */
.badge-tcp {
background-color: #0d6efd;
}
.badge-udp {
background-color: #6610f2;
}
/* PKI Initialization Banner */
.pki-init-banner {
background: #ffc107;
color: #212529;
padding: 15px 20px;
text-align: center;
position: sticky;
top: 0;
z-index: 1100;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
border-bottom: 3px solid #e0a800;
}
.pki-init-banner .small {
opacity: 0.8;
}
.pki-init-banner .spinner-border {
color: #212529 !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.stat-card .stat-value {
font-size: 1.5rem;
}
.pki-init-banner {
padding: 10px 15px;
font-size: 0.9rem;
}
}

View File

@ -0,0 +1,82 @@
// mGuard VPN Manager - Custom JavaScript
// HTMX Configuration
document.body.addEventListener('htmx:configRequest', function(evt) {
// Add CSRF token to all HTMX requests
// evt.detail.headers['X-CSRFToken'] = document.querySelector('meta[name="csrf-token"]')?.content;
});
// Show loading indicator during HTMX requests
document.body.addEventListener('htmx:beforeRequest', function(evt) {
// Optional: Show global loading indicator
});
document.body.addEventListener('htmx:afterRequest', function(evt) {
// Optional: Hide global loading indicator
});
// Handle HTMX errors
document.body.addEventListener('htmx:responseError', function(evt) {
console.error('HTMX Error:', evt.detail);
// Show error toast or alert
});
// Initialize tooltips
document.addEventListener('DOMContentLoaded', function() {
// Bootstrap tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function(tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Bootstrap popovers
var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
popoverTriggerList.map(function(popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl);
});
});
// Confirm delete dialogs
function confirmDelete(message, formId) {
if (confirm(message || 'Wirklich löschen?')) {
document.getElementById(formId).submit();
}
return false;
}
// Auto-refresh gateway status
function refreshGatewayStatus() {
// Handled by HTMX polling
}
// Format relative time
function formatRelativeTime(dateString) {
if (!dateString) return 'Nie';
const date = new Date(dateString);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'Gerade eben';
if (diff < 3600) return Math.floor(diff / 60) + ' Min.';
if (diff < 86400) return Math.floor(diff / 3600) + ' Std.';
return Math.floor(diff / 86400) + ' Tage';
}
// Update all relative times on page
function updateRelativeTimes() {
document.querySelectorAll('[data-relative-time]').forEach(function(el) {
el.textContent = formatRelativeTime(el.dataset.relativeTime);
});
}
// Update times every minute
setInterval(updateRelativeTimes, 60000);
// Copy to clipboard
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
// Show success toast
console.log('Copied to clipboard');
});
}

View File

@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}{% if application %}Anwendung bearbeiten{% else %}Neue Anwendung{% endif %} - mGuard VPN{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-app-indicator"></i>
{% if application %}Anwendung bearbeiten{% else %}Neue Anwendung{% endif %}
</h4>
</div>
<div class="card-body">
<form method="post" action="{% if application %}/applications/{{ application.id }}/edit{% else %}/applications/new{% endif %}">
<div class="mb-3">
<label for="name" class="form-label">Name *</label>
<input type="text" class="form-control" id="name" name="name"
value="{{ application.name if application else '' }}" required
placeholder="z.B. CoDeSys Runtime">
</div>
<div class="row">
<div class="col-6 mb-3">
<label for="default_port" class="form-label">Standard-Port *</label>
<input type="number" class="form-control" id="default_port" name="default_port"
value="{{ application.default_port if application else '' }}" required
min="1" max="65535" placeholder="11740">
</div>
<div class="col-6 mb-3">
<label for="protocol" class="form-label">Protokoll</label>
<select class="form-select" id="protocol" name="protocol">
<option value="tcp" {% if not application or application.protocol.value == 'tcp' %}selected{% endif %}>TCP</option>
<option value="udp" {% if application and application.protocol.value == 'udp' %}selected{% endif %}>UDP</option>
</select>
</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">Beschreibung</label>
<input type="text" class="form-control" id="description" name="description"
value="{{ application.description if application and application.description else '' }}"
placeholder="z.B. CoDeSys Runtime/Gateway Service">
</div>
<div class="mb-3">
<label for="icon" class="form-label">Icon (Bootstrap Icon Name)</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-{{ application.icon if application and application.icon else 'app' }}"></i></span>
<input type="text" class="form-control" id="icon" name="icon"
value="{{ application.icon if application and application.icon else '' }}"
placeholder="z.B. hdd-network, terminal, globe">
</div>
<div class="form-text">
<a href="https://icons.getbootstrap.com/" target="_blank">Bootstrap Icons</a> - nur den Namen ohne "bi-"
</div>
</div>
<div class="d-flex justify-content-between">
<a href="/applications" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Abbrechen
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Speichern
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Anwendungen - mGuard VPN{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-app-indicator"></i> Anwendungstemplates</h1>
<a href="/applications/new" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> Neue Anwendung
</a>
</div>
<div class="card">
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Port</th>
<th>Protokoll</th>
<th>Beschreibung</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for app in applications %}
<tr>
<td>
{% if app.icon %}<i class="bi bi-{{ app.icon }} me-2"></i>{% endif %}
<strong>{{ app.name }}</strong>
</td>
<td><code>{{ app.default_port }}</code></td>
<td><span class="badge bg-{{ 'primary' if app.protocol.value == 'tcp' else 'info' }}">{{ app.protocol.value|upper }}</span></td>
<td class="text-muted">{{ app.description or '-' }}</td>
<td>
<a href="/applications/{{ app.id }}/edit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<form action="/applications/{{ app.id }}/delete" method="post" class="d-inline"
onsubmit="return confirm('Anwendung wirklich löschen?');">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center text-muted">Keine Anwendungen vorhanden</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-info-circle"></i> Hinweis</h5>
</div>
<div class="card-body">
<p class="mb-0">
Anwendungstemplates definieren Standard-Ports und Protokolle für bekannte Industrieanwendungen.
Beim Anlegen eines Endpunkts wird automatisch der Port ausgefüllt, wenn eine Anwendung ausgewählt wird.
</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - mGuard VPN Manager</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="/static/css/custom.css" rel="stylesheet">
</head>
<body>
<div class="login-container">
<div class="login-box">
<div class="text-center mb-4">
<i class="bi bi-shield-lock login-logo"></i>
<h2 class="mt-2">mGuard VPN Manager</h2>
<p class="text-muted">Bitte anmelden</p>
</div>
{% if error %}
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle"></i> {{ error }}
</div>
{% endif %}
<form method="post" action="/login">
<div class="mb-3">
<label for="username" class="form-label">Benutzername</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" class="form-control" id="username" name="username"
placeholder="Benutzername eingeben" required autofocus>
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Passwort</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
<input type="password" class="form-control" id="password" name="password"
placeholder="Passwort eingeben" required>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember" name="remember">
<label class="form-check-label" for="remember">Angemeldet bleiben</label>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right"></i> Anmelden
</button>
</form>
<hr class="my-4">
<p class="text-center text-muted small mb-0">
mGuard VPN Manager v1.0<br>
&copy; 2024
</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,142 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}mGuard VPN Manager{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
<!-- Custom CSS -->
<link href="/static/css/custom.css" rel="stylesheet">
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
{% block head %}{% endblock %}
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% if current_user %}
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="/dashboard">
<i class="bi bi-shield-lock"></i> mGuard VPN
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.url.path == '/dashboard' %}active{% endif %}" href="/dashboard">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/gateways' in request.url.path %}active{% endif %}" href="/gateways">
<i class="bi bi-router"></i> Gateways
</a>
</li>
{% if current_user.is_admin %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if '/vpn-servers' in request.url.path or '/ca' in request.url.path %}active{% endif %}"
href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-hdd-network"></i> VPN
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="/vpn-servers">
<i class="bi bi-server"></i> VPN-Server
</a>
</li>
<li>
<a class="dropdown-item" href="/ca">
<i class="bi bi-file-earmark-lock"></i> Zertifizierungsstellen
</a>
</li>
</ul>
</li>
{% endif %}
{% if current_user.is_admin %}
<li class="nav-item">
<a class="nav-link {% if '/users' in request.url.path %}active{% endif %}" href="/users">
<i class="bi bi-people"></i> Benutzer
</a>
</li>
{% endif %}
{% if current_user.is_super_admin %}
<li class="nav-item">
<a class="nav-link {% if '/tenants' in request.url.path %}active{% endif %}" href="/tenants">
<i class="bi bi-building"></i> Mandanten
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/applications' in request.url.path %}active{% endif %}" href="/applications">
<i class="bi bi-app-indicator"></i> Anwendungen
</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {% if '/connections' in request.url.path %}active{% endif %}" href="/connections">
<i class="bi bi-plug"></i> Verbindungen
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><span class="dropdown-item-text text-muted">{{ current_user.role.value }}</span></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-right"></i> Logout</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<!-- Main Content -->
<main class="{% if current_user %}py-4{% endif %}">
<div class="container-fluid">
<!-- Flash Messages -->
{% if flash_messages %}
{% for msg in flash_messages %}
<div class="alert alert-{{ msg.category }} alert-dismissible fade show" role="alert">
{{ msg.message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% block content %}{% endblock %}
</div>
</main>
<!-- Footer -->
{% if current_user %}
<footer class="footer mt-auto py-3 bg-light">
<div class="container-fluid">
<span class="text-muted">mGuard VPN Manager v1.0 |
<span hx-get="/htmx/connections/count" hx-trigger="load, every 30s" hx-swap="innerHTML">
<i class="bi bi-hourglass-split"></i>
</span>
</span>
</div>
</footer>
{% endif %}
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<!-- Custom JS -->
<script src="/static/js/app.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Verbindungen - mGuard VPN Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-plug"></i> Verbindungen</h1>
<div>
<span class="badge bg-success me-2" id="active-count"
hx-get="/htmx/connections/count" hx-trigger="load, every 30s">
...
</span>
<button class="btn btn-outline-primary"
hx-get="/htmx/connections/list" hx-target="#connection-log" hx-swap="innerHTML">
<i class="bi bi-arrow-clockwise"></i> Aktualisieren
</button>
</div>
</div>
<!-- VPN Clients (Gateways) -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-shield-check"></i> VPN-Clients (Gateways)</h5>
</div>
<div class="card-body" id="vpn-clients"
hx-get="/htmx/connections/vpn-clients" hx-trigger="load, every 10s" hx-swap="innerHTML">
<div class="text-center py-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Laden...</span>
</div>
</div>
</div>
</div>
<!-- Active User Sessions -->
<div class="card mb-4">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="bi bi-broadcast"></i> Aktive Benutzer-Sessions</h5>
</div>
<div class="card-body" id="active-connections"
hx-get="/htmx/connections/active" hx-trigger="load, every 10s" hx-swap="innerHTML">
<div class="text-center py-3">
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">Laden...</span>
</div>
</div>
</div>
</div>
<!-- Connection History -->
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-clock-history"></i> Verbindungshistorie</h5>
</div>
<div class="card-body" id="connection-log"
hx-get="/htmx/connections/list" hx-trigger="load" hx-swap="innerHTML">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Laden...</span>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block title %}Dashboard - mGuard VPN Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-speedometer2"></i> Dashboard</h1>
<button class="btn btn-outline-primary" hx-get="/htmx/dashboard/stats" hx-target="#stats-row" hx-swap="innerHTML">
<i class="bi bi-arrow-clockwise"></i> Aktualisieren
</button>
</div>
<!-- Stats Cards -->
<div class="row mb-4" id="stats-row">
<div class="col-md-3 mb-3">
<div class="card stat-card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Gateways Online</h6>
<div class="stat-value">{{ stats.gateways_online }} / {{ stats.gateways_total }}</div>
</div>
<i class="bi bi-router stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Aktive Verbindungen</h6>
<div class="stat-value">{{ stats.active_connections }}</div>
</div>
<i class="bi bi-plug stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card bg-info text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Endpunkte</h6>
<div class="stat-value">{{ stats.endpoints_total }}</div>
</div>
<i class="bi bi-hdd-network stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card bg-warning text-dark">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2">Benutzer</h6>
<div class="stat-value">{{ stats.users_total }}</div>
</div>
<i class="bi bi-people stat-icon"></i>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Gateway Status -->
<div class="col-lg-8 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-router"></i> Gateway Status</h5>
<a href="/gateways" class="btn btn-sm btn-outline-primary">Alle anzeigen</a>
</div>
<div class="card-body" hx-get="/htmx/gateways/status-list" hx-trigger="load, every 30s" hx-swap="innerHTML">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Laden...</span>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Connections -->
<div class="col-lg-4 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-clock-history"></i> Letzte Verbindungen</h5>
<a href="/connections" class="btn btn-sm btn-outline-primary">Alle</a>
</div>
<div class="card-body" hx-get="/htmx/connections/recent" hx-trigger="load, every 60s" hx-swap="innerHTML">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Laden...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-lightning"></i> Schnellaktionen</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 mb-2">
<a href="/gateways/new" class="btn btn-outline-primary w-100">
<i class="bi bi-plus-circle"></i> Neues Gateway
</a>
</div>
<div class="col-md-3 mb-2">
<a href="/users/new" class="btn btn-outline-success w-100">
<i class="bi bi-person-plus"></i> Neuer Benutzer
</a>
</div>
<div class="col-md-3 mb-2">
<a href="/connections" class="btn btn-outline-info w-100">
<i class="bi bi-list-check"></i> Verbindungs-Log
</a>
</div>
<div class="col-md-3 mb-2">
<a href="/api/docs" target="_blank" class="btn btn-outline-secondary w-100">
<i class="bi bi-code-slash"></i> API Docs
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,262 @@
{% extends "base.html" %}
{% block title %}{{ gateway.name }} - mGuard VPN Manager{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/gateways">Gateways</a></li>
<li class="breadcrumb-item active">{{ gateway.name }}</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1>
<span class="status-indicator {{ 'online' if gateway.is_online else 'offline' }}"></span>
{{ gateway.name }}
</h1>
<p class="text-muted mb-0">
{{ gateway.router_type }} |
{{ gateway.location or 'Kein Standort' }} |
{% if gateway.last_seen %}
Zuletzt gesehen: <span data-relative-time="{{ gateway.last_seen }}">{{ gateway.last_seen }}</span>
{% else %}
Nie verbunden
{% endif %}
</p>
</div>
<div>
<a href="/gateways/{{ gateway.id }}/provision" class="btn btn-success">
<i class="bi bi-download"></i> Provisioning
</a>
<a href="/gateways/{{ gateway.id }}/edit" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Bearbeiten
</a>
<button class="btn btn-outline-danger" onclick="confirmDelete('Gateway wirklich löschen?', 'delete-form')">
<i class="bi bi-trash"></i> Löschen
</button>
<form id="delete-form" action="/gateways/{{ gateway.id }}/delete" method="post" style="display:none;">
</form>
</div>
</div>
<div class="row">
<!-- Gateway Info -->
<div class="col-lg-4 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-info-circle"></i> Gateway Details</h5>
</div>
<div class="card-body">
<table class="table table-sm">
<tr>
<th>Status</th>
<td>
{% if gateway.is_online %}
<span class="badge bg-success">Online</span>
{% else %}
<span class="badge bg-secondary">Offline</span>
{% endif %}
</td>
</tr>
<tr>
<th>Typ</th>
<td>{{ gateway.router_type }}</td>
</tr>
<tr>
<th>Firmware</th>
<td>{{ gateway.firmware_version or '-' }}</td>
</tr>
<tr>
<th>Seriennummer</th>
<td>{{ gateway.serial_number or '-' }}</td>
</tr>
<tr>
<th>VPN IP</th>
<td>{{ gateway.vpn_ip or 'Nicht zugewiesen' }}</td>
</tr>
<tr>
<th>VPN Subnetz</th>
<td>{{ gateway.vpn_subnet or '-' }}</td>
</tr>
<tr>
<th>Provisioniert</th>
<td>
{% if gateway.is_provisioned %}
<span class="badge bg-success">Ja</span>
{% else %}
<span class="badge bg-warning">Nein</span>
{% endif %}
</td>
</tr>
<tr>
<th>Standort</th>
<td>{{ gateway.location or '-' }}</td>
</tr>
</table>
{% if gateway.description %}
<hr>
<p class="mb-0 text-muted">{{ gateway.description }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Endpoints -->
<div class="col-lg-8 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-hdd-network"></i> Endpunkte</h5>
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addEndpointModal">
<i class="bi bi-plus"></i> Endpunkt hinzufügen
</button>
</div>
<div class="card-body" id="endpoints-list"
hx-get="/htmx/gateways/{{ gateway.id }}/endpoints"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Laden...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- VPN Connection Log -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-shield-check"></i> VPN-Verbindungslog</h5>
</div>
<div class="card-body" id="vpn-log"
hx-get="/htmx/gateways/{{ gateway.id }}/vpn-log"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Laden...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- User Access -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-person-check"></i> Benutzerzugriff</h5>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addAccessModal">
<i class="bi bi-plus"></i> Zugriff gewähren
</button>
</div>
<div class="card-body" id="access-list"
hx-get="/htmx/gateways/{{ gateway.id }}/access"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Laden...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add Endpoint Modal -->
<div class="modal fade" id="addEndpointModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form hx-post="/htmx/gateways/{{ gateway.id }}/endpoints"
hx-target="#endpoints-list"
hx-swap="innerHTML"
hx-on::after-request="bootstrap.Modal.getInstance(document.getElementById('addEndpointModal')).hide(); this.reset()">
<div class="modal-header">
<h5 class="modal-title">Endpunkt hinzufügen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" required placeholder="z.B. HMI Türsteuerung">
</div>
<div class="row">
<div class="col-8 mb-3">
<label class="form-label">IP-Adresse</label>
<input type="text" class="form-control" name="internal_ip" required placeholder="10.0.0.3">
</div>
<div class="col-4 mb-3">
<label class="form-label">Port</label>
<input type="number" class="form-control" name="port" required placeholder="11740">
</div>
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">Protokoll</label>
<select class="form-select" name="protocol">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div class="col-6 mb-3">
<label class="form-label">Anwendung</label>
<select class="form-select" name="application_template_id" id="application-select"
onchange="applyApplicationTemplate()">
<option value="" data-port="" data-protocol="">Benutzerdefiniert</option>
{% for template in templates %}
<option value="{{ template.id }}"
data-port="{{ template.default_port }}"
data-protocol="{{ template.protocol }}">
{{ template.name }} (:{{ template.default_port }})
</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Beschreibung (optional)</label>
<textarea class="form-control" name="description" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function applyApplicationTemplate() {
const select = document.getElementById('application-select');
const option = select.options[select.selectedIndex];
const port = option.dataset.port;
const protocol = option.dataset.protocol;
if (port) {
document.querySelector('#addEndpointModal input[name="port"]').value = port;
}
if (protocol) {
document.querySelector('#addEndpointModal select[name="protocol"]').value = protocol;
}
}
function confirmDelete(message, formId) {
if (confirm(message)) {
document.getElementById(formId).submit();
}
}
</script>
{% endblock %}

View File

@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}{{ 'Gateway bearbeiten' if gateway else 'Neues Gateway' }} - mGuard VPN Manager{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/gateways">Gateways</a></li>
<li class="breadcrumb-item active">{{ 'Bearbeiten' if gateway else 'Neu' }}</li>
</ol>
</nav>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-{{ 'pencil' if gateway else 'plus-circle' }}"></i>
{{ 'Gateway bearbeiten' if gateway else 'Neues Gateway' }}
</h5>
</div>
<div class="card-body">
<form method="post" action="{{ '/gateways/' ~ gateway.id ~ '/edit' if gateway else '/gateways/new' }}">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Name *</label>
<input type="text" class="form-control" name="name" required
value="{{ gateway.name if gateway else '' }}"
placeholder="z.B. Kunde ABC - Türsteuerung">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Router-Typ *</label>
<select class="form-select" name="router_type" required>
<option value="">Bitte wählen</option>
<option value="FL_MGUARD_2000" {{ 'selected' if gateway and gateway.router_type.value == 'FL_MGUARD_2000' }}>FL MGUARD 2000</option>
<option value="FL_MGUARD_4000" {{ 'selected' if gateway and gateway.router_type.value == 'FL_MGUARD_4000' }}>FL MGUARD 4000</option>
<option value="FL_MGUARD_RS4000" {{ 'selected' if gateway and gateway.router_type.value == 'FL_MGUARD_RS4000' }}>FL MGUARD RS4000</option>
<option value="FL_MGUARD_1000" {{ 'selected' if gateway and gateway.router_type.value == 'FL_MGUARD_1000' }}>FL MGUARD 1000</option>
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Firmware-Version</label>
<input type="text" class="form-control" name="firmware_version"
value="{{ gateway.firmware_version if gateway else '' }}"
placeholder="z.B. 10.5.1">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Seriennummer</label>
<input type="text" class="form-control" name="serial_number"
value="{{ gateway.serial_number if gateway else '' }}"
placeholder="Optional">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Standort</label>
<input type="text" class="form-control" name="location"
value="{{ gateway.location if gateway else '' }}"
placeholder="z.B. Halle 1, Raum 102">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">VPN Subnetz</label>
<input type="text" class="form-control" name="vpn_subnet"
value="{{ gateway.vpn_subnet if gateway else '' }}"
placeholder="z.B. 10.0.0.0/24">
<small class="text-muted">Netzwerk hinter dem Gateway</small>
</div>
</div>
<div class="mb-3">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="description" rows="3"
placeholder="Optionale Beschreibung">{{ gateway.description if gateway else '' }}</textarea>
</div>
{% if current_user.is_super_admin and not gateway %}
<div class="mb-3">
<label class="form-label">Mandant</label>
<select class="form-select" name="tenant_id">
{% for tenant in tenants %}
<option value="{{ tenant.id }}">{{ tenant.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<hr>
<div class="d-flex justify-content-between">
<a href="/gateways{{ '/' ~ gateway.id if gateway else '' }}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Abbrechen
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> {{ 'Speichern' if gateway else 'Gateway anlegen' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}Gateways - mGuard VPN Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-router"></i> Gateways</h1>
<a href="/gateways/new" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Neues Gateway
</a>
</div>
<!-- Filter -->
<div class="card mb-4">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-4">
<input type="text" class="form-control" placeholder="Suchen..."
hx-get="/htmx/gateways/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#gateway-list"
name="q">
</div>
<div class="col-md-3">
<select class="form-select" hx-get="/htmx/gateways/filter"
hx-trigger="change" hx-target="#gateway-list" name="status">
<option value="">Alle Status</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
</select>
</div>
<div class="col-md-3">
<select class="form-select" hx-get="/htmx/gateways/filter"
hx-trigger="change" hx-target="#gateway-list" name="type">
<option value="">Alle Typen</option>
<option value="FL_MGUARD_2000">FL MGUARD 2000</option>
<option value="FL_MGUARD_4000">FL MGUARD 4000</option>
<option value="FL_MGUARD_RS4000">FL MGUARD RS4000</option>
</select>
</div>
<div class="col-md-2">
<button class="btn btn-outline-secondary w-100"
hx-get="/htmx/gateways/list"
hx-target="#gateway-list">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
</div>
<!-- Gateway List -->
<div id="gateway-list" hx-get="/htmx/gateways/list" hx-trigger="load" hx-swap="innerHTML">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Laden...</span>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,231 @@
{% extends "base.html" %}
{% block title %}{{ profile.name }} - {{ gateway.name }} - mGuard VPN{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/gateways">Gateways</a></li>
<li class="breadcrumb-item"><a href="/gateways/{{ gateway.id }}">{{ gateway.name }}</a></li>
<li class="breadcrumb-item"><a href="/gateways/{{ gateway.id }}/profiles">VPN-Profile</a></li>
<li class="breadcrumb-item active">{{ profile.name }}</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="bi bi-shield-lock"></i> {{ profile.name }}
{% if profile.priority == 1 %}
<span class="badge bg-success">Primär</span>
{% else %}
<span class="badge bg-secondary">Priorität {{ profile.priority }}</span>
{% endif %}
</h1>
<div>
<a href="/gateways/{{ gateway.id }}/profiles" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Zurück
</a>
<a href="/gateways/{{ gateway.id }}/profiles/{{ profile.id }}/edit" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Bearbeiten
</a>
<a href="/gateways/{{ gateway.id }}/profiles/{{ profile.id }}/provision" class="btn btn-success">
<i class="bi bi-download"></i> Herunterladen
</a>
</div>
</div>
<div class="row">
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-info-circle"></i> Profil-Informationen
</div>
<div class="card-body">
<table class="table table-borderless">
<tr>
<th style="width: 40%;">Name</th>
<td>{{ profile.name }}</td>
</tr>
<tr>
<th>Beschreibung</th>
<td>{{ profile.description or '-' }}</td>
</tr>
<tr>
<th>Status</th>
<td>
{% if profile.status.value == 'active' %}
<span class="badge bg-success">Aktiv</span>
{% elif profile.status.value == 'provisioned' %}
<span class="badge bg-info">Provisioniert</span>
{% elif profile.status.value == 'pending' %}
<span class="badge bg-warning text-dark">Ausstehend</span>
{% elif profile.status.value == 'expired' %}
<span class="badge bg-danger">Abgelaufen</span>
{% elif profile.status.value == 'revoked' %}
<span class="badge bg-dark">Widerrufen</span>
{% else %}
<span class="badge bg-secondary">{{ profile.status.value }}</span>
{% endif %}
</td>
</tr>
<tr>
<th>Priorität</th>
<td>{{ profile.priority }}</td>
</tr>
<tr>
<th>Aktiv</th>
<td>
{% if profile.is_active %}
<span class="text-success"><i class="bi bi-check-circle"></i> Ja</span>
{% else %}
<span class="text-danger"><i class="bi bi-x-circle"></i> Nein</span>
{% endif %}
</td>
</tr>
<tr>
<th>Erstellt</th>
<td>{{ profile.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
</tr>
{% if profile.provisioned_at %}
<tr>
<th>Zuletzt provisioniert</th>
<td>{{ profile.provisioned_at.strftime('%d.%m.%Y %H:%M') }}</td>
</tr>
{% endif %}
</table>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-hdd-network"></i> VPN-Server
</div>
<div class="card-body">
{% if profile.vpn_server %}
<table class="table table-borderless">
<tr>
<th style="width: 40%;">Server</th>
<td>
<a href="/vpn-servers/{{ profile.vpn_server.id }}">
{{ profile.vpn_server.name }}
</a>
</td>
</tr>
<tr>
<th>Hostname</th>
<td><code>{{ profile.vpn_server.hostname }}</code></td>
</tr>
<tr>
<th>Port / Protokoll</th>
<td>{{ profile.vpn_server.port }} / {{ profile.vpn_server.protocol.value|upper }}</td>
</tr>
<tr>
<th>VPN-Netzwerk</th>
<td><code>{{ profile.vpn_server.vpn_network }}/{{ profile.vpn_server.vpn_netmask }}</code></td>
</tr>
</table>
{% else %}
<p class="text-muted mb-0">Kein VPN-Server zugewiesen</p>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-file-earmark-lock"></i> Zertifikat
</div>
<div class="card-body">
<table class="table table-borderless">
<tr>
<th style="width: 40%;">Common Name</th>
<td><code>{{ profile.cert_cn }}</code></td>
</tr>
<tr>
<th>Gültig von</th>
<td>{{ profile.valid_from.strftime('%d.%m.%Y') if profile.valid_from else '-' }}</td>
</tr>
<tr>
<th>Gültig bis</th>
<td>
{% if profile.valid_until %}
{{ profile.valid_until.strftime('%d.%m.%Y') }}
{% if profile.is_expired %}
<span class="badge bg-danger ms-2">Abgelaufen</span>
{% elif profile.days_until_expiry <= 30 %}
<span class="badge bg-warning text-dark ms-2">{{ profile.days_until_expiry }} Tage</span>
{% endif %}
{% else %}
-
{% endif %}
</td>
</tr>
<tr>
<th>CA</th>
<td>
{% if profile.certificate_authority %}
<a href="/ca/{{ profile.certificate_authority.id }}">
{{ profile.certificate_authority.name }}
</a>
{% else %}
-
{% endif %}
</td>
</tr>
</table>
{% if profile.client_cert %}
<hr>
<details>
<summary class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i> Zertifikat anzeigen
</summary>
<pre class="mt-3 p-3 bg-light" style="max-height: 200px; overflow: auto; font-size: 0.8rem;">{{ profile.client_cert }}</pre>
</details>
{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-success text-white">
<i class="bi bi-download"></i> Provisioning
</div>
<div class="card-body">
<p>
Laden Sie die OpenVPN-Konfigurationsdatei herunter und importieren Sie sie auf dem mGuard-Router.
</p>
<a href="/gateways/{{ gateway.id }}/profiles/{{ profile.id }}/provision"
class="btn btn-success btn-lg w-100">
<i class="bi bi-download"></i> {{ profile.name }}.ovpn herunterladen
</a>
{% if profile.provisioned_at %}
<div class="text-muted mt-2 text-center">
<small>Zuletzt heruntergeladen: {{ profile.provisioned_at.strftime('%d.%m.%Y %H:%M') }}</small>
</div>
{% endif %}
</div>
</div>
{% if profile.status.value not in ['revoked', 'expired'] %}
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<i class="bi bi-exclamation-triangle"></i> Gefahrenzone
</div>
<div class="card-body">
<p class="mb-3">
Durch das Widerrufen des Zertifikats wird der Zugang zum VPN-Server gesperrt.
Diese Aktion kann nicht rückgängig gemacht werden.
</p>
<form action="/gateways/{{ gateway.id }}/profiles/{{ profile.id }}/revoke" method="post"
onsubmit="return confirm('Sind Sie sicher? Das Zertifikat wird unwiderruflich gesperrt.');">
<button type="submit" class="btn btn-outline-danger">
<i class="bi bi-shield-x"></i> Zertifikat widerrufen
</button>
</form>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}
{% if profile %}Profil bearbeiten{% else %}Neues VPN-Profil{% endif %} - {{ gateway.name }} - mGuard VPN
{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/gateways">Gateways</a></li>
<li class="breadcrumb-item"><a href="/gateways/{{ gateway.id }}">{{ gateway.name }}</a></li>
<li class="breadcrumb-item"><a href="/gateways/{{ gateway.id }}/profiles">VPN-Profile</a></li>
<li class="breadcrumb-item active">{% if profile %}Bearbeiten{% else %}Neu{% endif %}</li>
</ol>
</nav>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-shield-lock"></i>
{% if profile %}Profil bearbeiten{% else %}Neues VPN-Profil{% endif %}
</h4>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle"></i> {{ error }}
</div>
{% endif %}
<form method="post">
<div class="mb-3">
<label for="name" class="form-label">Name *</label>
<input type="text" class="form-control" id="name" name="name"
value="{{ profile.name if profile else '' }}" required
placeholder="z.B. Produktion, Fallback, Migration">
<div class="form-text">Ein beschreibender Name für dieses Profil</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">Beschreibung</label>
<textarea class="form-control" id="description" name="description" rows="2"
placeholder="Optionale Beschreibung">{{ profile.description if profile else '' }}</textarea>
</div>
<div class="mb-3">
<label for="vpn_server_id" class="form-label">VPN-Server *</label>
{% if profile %}
<input type="text" class="form-control" readonly disabled
value="{{ profile.vpn_server.name }} ({{ profile.vpn_server.hostname }}:{{ profile.vpn_server.port }})">
<div class="form-text text-muted">
Der VPN-Server kann nicht geändert werden (Zertifikat ist an den Server gebunden).
</div>
{% else %}
<select class="form-select" id="vpn_server_id" name="vpn_server_id" required>
<option value="">-- Server auswählen --</option>
{% for server in vpn_servers %}
<option value="{{ server.id }}">
{{ server.name }} ({{ server.hostname }}:{{ server.port }}/{{ server.protocol.value }})
</option>
{% endfor %}
</select>
<div class="form-text">
Der VPN-Server, mit dem sich das Gateway verbinden soll.
{% if not vpn_servers %}
<span class="text-warning">
<i class="bi bi-exclamation-triangle"></i>
Keine VPN-Server verfügbar. <a href="/vpn-servers/new">Erstellen Sie zuerst einen Server.</a>
</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="priority" class="form-label">Priorität *</label>
<input type="number" class="form-control" id="priority" name="priority"
value="{{ profile.priority if profile else (existing_profiles|length + 1) }}"
min="1" max="99" required style="max-width: 120px;">
<div class="form-text">
1 = Höchste Priorität (Primärer Server). Bei Verbindungsproblemen wird das Profil
mit der nächsthöheren Priorität verwendet.
</div>
</div>
{% if not profile %}
<div class="mb-3">
<label for="cert_cn" class="form-label">Common Name (CN)</label>
<input type="text" class="form-control" id="cert_cn" name="cert_cn"
placeholder="Automatisch generiert wenn leer"
value="">
<div class="form-text">
Der Common Name für das Client-Zertifikat. Wenn leer, wird automatisch
"<code>{{ gateway.name|lower|replace(' ', '-') }}-[profilname]</code>" verwendet.
</div>
</div>
<div class="mb-3">
<label for="validity_days" class="form-label">Gültigkeit (Tage)</label>
<input type="number" class="form-control" id="validity_days" name="validity_days"
value="365" min="30" max="3650" style="max-width: 150px;">
<div class="form-text">Wie lange das Zertifikat gültig sein soll (Standard: 365 Tage)</div>
</div>
{% endif %}
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="is_active" name="is_active"
{% if not profile or profile.is_active %}checked{% endif %}>
<label class="form-check-label" for="is_active">
Profil ist aktiv
</label>
</div>
<div class="form-text">Inaktive Profile werden beim Provisioning nicht berücksichtigt</div>
</div>
<hr>
<div class="d-flex justify-content-between">
<a href="/gateways/{{ gateway.id }}/profiles" class="btn btn-outline-secondary">
<i class="bi bi-x-lg"></i> Abbrechen
</a>
<button type="submit" class="btn btn-primary" {% if not vpn_servers %}disabled{% endif %}>
<i class="bi bi-check-lg"></i>
{% if profile %}Speichern{% else %}Profil erstellen{% endif %}
</button>
</div>
</form>
</div>
</div>
{% if not profile %}
<div class="card mt-4">
<div class="card-header">
<i class="bi bi-info-circle"></i> Was passiert beim Erstellen?
</div>
<div class="card-body">
<ol class="mb-0">
<li>Ein Client-Zertifikat wird aus der CA des VPN-Servers generiert</li>
<li>Das Zertifikat wird mit dem Gateway verknüpft</li>
<li>Nach dem Erstellen können Sie die OpenVPN-Konfigurationsdatei (.ovpn) herunterladen</li>
<li>Die Konfiguration kann dann auf dem mGuard-Router importiert werden</li>
</ol>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,155 @@
{% extends "base.html" %}
{% block title %}VPN-Profile - {{ gateway.name }} - mGuard VPN{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/gateways">Gateways</a></li>
<li class="breadcrumb-item"><a href="/gateways/{{ gateway.id }}">{{ gateway.name }}</a></li>
<li class="breadcrumb-item active">VPN-Profile</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="bi bi-shield-lock"></i> VPN-Profile
<span class="badge bg-primary">{{ profiles|length }}</span>
</h1>
<div>
<a href="/gateways/{{ gateway.id }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Zurück
</a>
<a href="/gateways/{{ gateway.id }}/profiles/new" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> Neues Profil
</a>
</div>
</div>
{% if profiles %}
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th style="width: 60px;">Priorität</th>
<th>Name</th>
<th>VPN-Server</th>
<th>Common Name</th>
<th>Status</th>
<th>Gültigkeit</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for profile in profiles %}
<tr>
<td>
<span class="badge bg-secondary fs-6">{{ profile.priority }}</span>
</td>
<td>
<a href="/gateways/{{ gateway.id }}/profiles/{{ profile.id }}" class="text-decoration-none">
<strong>{{ profile.name }}</strong>
</a>
{% if profile.priority == 1 %}
<span class="badge bg-success ms-1">Primär</span>
{% endif %}
</td>
<td>
{% if profile.vpn_server %}
<a href="/vpn-servers/{{ profile.vpn_server.id }}">
{{ profile.vpn_server.name }}
</a>
<br>
<small class="text-muted">
{{ profile.vpn_server.hostname }}:{{ profile.vpn_server.port }}/{{ profile.vpn_server.protocol.value }}
</small>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<code>{{ profile.cert_cn }}</code>
</td>
<td>
{% if profile.status.value == 'active' %}
<span class="badge bg-success">Aktiv</span>
{% elif profile.status.value == 'provisioned' %}
<span class="badge bg-info">Provisioniert</span>
{% elif profile.status.value == 'pending' %}
<span class="badge bg-warning text-dark">Ausstehend</span>
{% elif profile.status.value == 'expired' %}
<span class="badge bg-danger">Abgelaufen</span>
{% elif profile.status.value == 'revoked' %}
<span class="badge bg-dark">Widerrufen</span>
{% else %}
<span class="badge bg-secondary">{{ profile.status.value }}</span>
{% endif %}
</td>
<td>
{% if profile.valid_until %}
<small>
bis {{ profile.valid_until.strftime('%d.%m.%Y') }}
{% if profile.days_until_expiry is defined %}
{% if profile.days_until_expiry <= 30 %}
<br><span class="text-warning">{{ profile.days_until_expiry }} Tage</span>
{% endif %}
{% endif %}
</small>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="/gateways/{{ gateway.id }}/profiles/{{ profile.id }}"
class="btn btn-outline-primary" title="Details">
<i class="bi bi-eye"></i>
</a>
<a href="/gateways/{{ gateway.id }}/profiles/{{ profile.id }}/edit"
class="btn btn-outline-secondary" title="Bearbeiten">
<i class="bi bi-pencil"></i>
</a>
<a href="/gateways/{{ gateway.id }}/profiles/{{ profile.id }}/provision"
class="btn btn-outline-success" title="Konfiguration herunterladen">
<i class="bi bi-download"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<i class="bi bi-info-circle"></i> Hinweis zur Priorität
</div>
<div class="card-body">
<p class="mb-0">
Profile mit niedrigerer Prioritätsnummer werden bevorzugt verwendet.
Bei Verbindungsproblemen mit dem primären Server (Priorität 1) versucht der Client
automatisch, sich mit dem nächsten Profil zu verbinden (Failover).
</p>
</div>
</div>
{% else %}
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-shield-lock" style="font-size: 3rem;" class="text-muted"></i>
<h4 class="mt-3">Keine VPN-Profile vorhanden</h4>
<p class="text-muted">
Erstellen Sie ein VPN-Profil, um dieses Gateway mit einem VPN-Server zu verbinden.
</p>
<a href="/gateways/{{ gateway.id }}/profiles/new" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> Erstes Profil erstellen
</a>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}{% if tenant %}Mandant bearbeiten{% else %}Neuer Mandant{% endif %} - mGuard VPN{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-building"></i>
{% if tenant %}Mandant bearbeiten{% else %}Neuer Mandant{% endif %}
</h4>
</div>
<div class="card-body">
<form method="post" action="{% if tenant %}/tenants/{{ tenant.id }}/edit{% else %}/tenants/new{% endif %}">
<div class="mb-3">
<label for="name" class="form-label">Name *</label>
<input type="text" class="form-control" id="name" name="name"
value="{{ tenant.name if tenant else '' }}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Beschreibung</label>
<textarea class="form-control" id="description" name="description" rows="3">{{ tenant.description if tenant and tenant.description else '' }}</textarea>
</div>
{% if tenant %}
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="is_active" name="is_active" value="true"
{% if tenant.is_active %}checked{% endif %}>
<label class="form-check-label" for="is_active">Aktiv</label>
</div>
</div>
{% endif %}
<div class="d-flex justify-content-between">
<a href="/tenants" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Abbrechen
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Speichern
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Mandanten - mGuard VPN{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-building"></i> Mandanten</h1>
<a href="/tenants/new" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> Neuer Mandant
</a>
</div>
<div class="card">
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Beschreibung</th>
<th>Benutzer</th>
<th>Gateways</th>
<th>Status</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for tenant in tenants %}
<tr>
<td><strong>{{ tenant.name }}</strong></td>
<td>{{ tenant.description or '-' }}</td>
<td>{{ tenant.users|length }}</td>
<td>{{ tenant.gateways|length }}</td>
<td>
{% if tenant.is_active %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Inaktiv</span>
{% endif %}
</td>
<td>{{ tenant.created_at.strftime('%d.%m.%Y') }}</td>
<td>
<a href="/tenants/{{ tenant.id }}/edit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<form action="/tenants/{{ tenant.id }}/delete" method="post" class="d-inline"
onsubmit="return confirm('Mandant wirklich löschen?');">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center text-muted">Keine Mandanten vorhanden</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block title %}Zugriffe für {{ user.username }} - mGuard VPN Manager{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/users">Benutzer</a></li>
<li class="breadcrumb-item"><a href="/users/{{ user.id }}/edit">{{ user.username }}</a></li>
<li class="breadcrumb-item active">Zugriffe</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1><i class="bi bi-key"></i> Zugriffe für {{ user.username }}</h1>
<p class="text-muted mb-0">{{ user.email }} | Rolle: {{ user.role.value }}</p>
</div>
<a href="/users" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Zurück
</a>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-router"></i> Gateway-Zugriffe</h5>
</div>
<div class="card-body">
<form method="post" action="/users/{{ user.id }}/access">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 50px;">Zugriff</th>
<th>Gateway</th>
<th>Standort</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for gateway in gateways %}
<tr>
<td>
<div class="form-check">
<input class="form-check-input" type="checkbox"
name="gateway_ids" value="{{ gateway.id }}"
id="gw_{{ gateway.id }}"
{{ 'checked' if gateway.id in access_gateway_ids }}>
</div>
</td>
<td>
<label for="gw_{{ gateway.id }}" class="mb-0" style="cursor: pointer;">
{{ gateway.name }}
</label>
</td>
<td class="text-muted">{{ gateway.location or '-' }}</td>
<td>
{% if gateway.is_online %}
<span class="badge bg-success">Online</span>
{% else %}
<span class="badge bg-secondary">Offline</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-4">
Keine Gateways vorhanden
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if gateways %}
<div class="d-flex justify-content-between">
<div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll()">
Alle auswählen
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectNone()">
Keine auswählen
</button>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Speichern
</button>
</div>
{% endif %}
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-info-circle"></i> Info</h5>
</div>
<div class="card-body">
<p class="mb-2"><strong>Benutzer:</strong> {{ user.username }}</p>
<p class="mb-2"><strong>Rolle:</strong>
<span class="badge badge-role-{{ user.role.value }}">{{ user.role.value }}</span>
</p>
<hr>
<h6>Rollen-Erklärung:</h6>
<ul class="small text-muted">
<li><strong>technician:</strong> Kann nur zugewiesene Gateways sehen und verbinden</li>
<li><strong>admin:</strong> Kann alle Gateways des Mandanten verwalten</li>
<li><strong>super_admin:</strong> Voller Zugriff auf alle Mandanten</li>
</ul>
<div class="alert alert-info small mb-0">
<i class="bi bi-lightbulb"></i>
Admins haben automatisch Zugriff auf alle Gateways ihres Mandanten.
Diese Zuweisungen gelten nur für Techniker.
</div>
</div>
</div>
</div>
</div>
<script>
function selectAll() {
document.querySelectorAll('input[name="gateway_ids"]').forEach(cb => cb.checked = true);
}
function selectNone() {
document.querySelectorAll('input[name="gateway_ids"]').forEach(cb => cb.checked = false);
}
</script>
{% endblock %}

View File

@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}{{ 'Benutzer bearbeiten' if user else 'Neuer Benutzer' }} - mGuard VPN Manager{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/users">Benutzer</a></li>
<li class="breadcrumb-item active">{{ 'Bearbeiten' if user else 'Neu' }}</li>
</ol>
</nav>
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-{{ 'pencil' if user else 'person-plus' }}"></i>
{{ 'Benutzer bearbeiten' if user else 'Neuer Benutzer' }}
</h5>
</div>
<div class="card-body">
<form method="post" action="{{ '/users/' ~ user.id ~ '/edit' if user else '/users/new' }}">
<div class="mb-3">
<label class="form-label">Benutzername *</label>
<input type="text" class="form-control" name="username" required
value="{{ user.username if user else '' }}"
placeholder="benutzername"
{{ 'readonly' if user else '' }}>
</div>
<div class="mb-3">
<label class="form-label">E-Mail *</label>
<input type="email" class="form-control" name="email" required
value="{{ user.email if user else '' }}"
placeholder="user@example.com">
</div>
<div class="mb-3">
<label class="form-label">Vollständiger Name</label>
<input type="text" class="form-control" name="full_name"
value="{{ user.full_name if user else '' }}"
placeholder="Max Mustermann">
</div>
<div class="mb-3">
<label class="form-label">Passwort {{ '(leer lassen = unverändert)' if user else '*' }}</label>
<input type="password" class="form-control" name="password"
{{ '' if user else 'required' }}
placeholder="{{ '••••••••' if user else 'Mindestens 8 Zeichen' }}">
</div>
<div class="mb-3">
<label class="form-label">Rolle *</label>
<select class="form-select" name="role" required>
{% for role in roles %}
<option value="{{ role }}" {{ 'selected' if user and user.role.value == role }}>
{{ role }}
</option>
{% endfor %}
</select>
</div>
{% if current_user.is_super_admin and not user %}
<div class="mb-3">
<label class="form-label">Mandant</label>
<select class="form-select" name="tenant_id">
<option value="">Kein Mandant (Super Admin)</option>
{% for tenant in tenants %}
<option value="{{ tenant.id }}">{{ tenant.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if user %}
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="is_active" id="is_active"
{{ 'checked' if user.is_active }}>
<label class="form-check-label" for="is_active">Aktiv</label>
</div>
{% endif %}
<hr>
<div class="d-flex justify-content-between">
<a href="/users" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Abbrechen
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> {{ 'Speichern' if user else 'Benutzer anlegen' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Benutzer - mGuard VPN Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-people"></i> Benutzer</h1>
<a href="/users/new" class="btn btn-primary">
<i class="bi bi-person-plus"></i> Neuer Benutzer
</a>
</div>
<div class="card">
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>Benutzername</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Status</th>
<th>Letzter Login</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<i class="bi bi-person-circle"></i>
{{ user.username }}
{% if user.full_name %}
<small class="text-muted d-block">{{ user.full_name }}</small>
{% endif %}
</td>
<td>{{ user.email }}</td>
<td>
<span class="badge badge-role-{{ user.role.value }}">{{ user.role.value }}</span>
</td>
<td>
{% if user.is_active %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-danger">Inaktiv</span>
{% endif %}
</td>
<td>
{% if user.last_login %}
<span data-relative-time="{{ user.last_login }}">{{ user.last_login.strftime('%d.%m.%Y %H:%M') }}</span>
{% else %}
<span class="text-muted">Nie</span>
{% endif %}
</td>
<td>
<a href="/users/{{ user.id }}/edit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<a href="/users/{{ user.id }}/access" class="btn btn-sm btn-outline-info">
<i class="bi bi-key"></i> Zugriffe
</a>
{% if user.id != current_user.id %}
<button class="btn btn-sm btn-outline-danger"
hx-delete="/htmx/users/{{ user.id }}"
hx-confirm="Benutzer '{{ user.username }}' wirklich löschen?"
hx-target="closest tr"
hx-swap="outerHTML">
<i class="bi bi-trash"></i>
</button>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-muted py-4">
Keine Benutzer vorhanden
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}Verbundene Clients - {{ server.name }} - mGuard VPN{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/vpn-servers">VPN-Server</a></li>
<li class="breadcrumb-item"><a href="/vpn-servers/{{ server.id }}">{{ server.name }}</a></li>
<li class="breadcrumb-item active">Clients</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="bi bi-people"></i> Verbundene Clients
<span class="badge bg-primary">{{ clients|length }}</span>
</h1>
<a href="/vpn-servers/{{ server.id }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Zurück zum Server
</a>
</div>
<div class="card">
<div class="card-body">
{% if clients %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Common Name</th>
<th>Echte Adresse</th>
<th>Empfangen</th>
<th>Gesendet</th>
<th>Verbunden seit</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr>
<td>
<strong>{{ client.common_name }}</strong>
</td>
<td><code>{{ client.real_address }}</code></td>
<td>{{ (client.bytes_received / 1024 / 1024)|round(2) }} MB</td>
<td>{{ (client.bytes_sent / 1024 / 1024)|round(2) }} MB</td>
<td>{{ client.connected_since }}</td>
<td>
<form action="/vpn-servers/{{ server.id }}/disconnect/{{ client.common_name }}" method="post"
onsubmit="return confirm('Client wirklich trennen?');">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-x-circle"></i> Trennen
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-people" style="font-size: 3rem;"></i>
<p class="mt-3">Keine Clients verbunden</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,260 @@
{% extends "base.html" %}
{% block title %}{{ server.name }} - VPN-Server - mGuard VPN{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/vpn-servers">VPN-Server</a></li>
<li class="breadcrumb-item active">{{ server.name }}</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
{% if server.status.value == 'running' %}
<span class="status-indicator online"></span>
{% else %}
<span class="status-indicator offline"></span>
{% endif %}
{{ server.name }}
{% if server.is_primary %}
<span class="badge bg-primary">Primär</span>
{% endif %}
{% if server.protocol.value == 'udp' %}
<span class="badge badge-udp">UDP</span>
{% else %}
<span class="badge badge-tcp">TCP</span>
{% endif %}
</h1>
<div>
<a href="/vpn-servers/{{ server.id }}/clients" class="btn btn-outline-primary">
<i class="bi bi-people"></i> Clients ({{ status.connected_clients or 0 }})
</a>
<a href="/vpn-servers/{{ server.id }}/edit" class="btn btn-outline-secondary">
<i class="bi bi-pencil"></i> Bearbeiten
</a>
<form action="/vpn-servers/{{ server.id }}/delete" method="post" class="d-inline"
onsubmit="return confirm('VPN-Server wirklich löschen?');">
<button type="submit" class="btn btn-outline-danger">
<i class="bi bi-trash"></i> Löschen
</button>
</form>
</div>
</div>
<div class="row">
<!-- Status Card -->
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-activity"></i> Status</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">Status</dt>
<dd class="col-sm-7">
{% if server.status.value == 'running' %}
<span class="badge bg-success">Läuft</span>
{% elif server.status.value == 'stopped' %}
<span class="badge bg-secondary">Gestoppt</span>
{% elif server.status.value == 'starting' %}
<span class="badge bg-warning">Startet...</span>
{% elif server.status.value == 'error' %}
<span class="badge bg-danger">Fehler</span>
{% else %}
<span class="badge bg-light text-dark">{{ server.status.value }}</span>
{% endif %}
</dd>
<dt class="col-sm-5">Verbundene Clients</dt>
<dd class="col-sm-7">{{ status.connected_clients or 0 }}</dd>
<dt class="col-sm-5">Letzte Prüfung</dt>
<dd class="col-sm-7">
{{ server.last_status_check.strftime('%d.%m.%Y %H:%M') if server.last_status_check else '-' }}
</dd>
<dt class="col-sm-5">Container</dt>
<dd class="col-sm-7"><code>{{ server.docker_container_name or '-' }}</code></dd>
<dt class="col-sm-5">Management-Port</dt>
<dd class="col-sm-7">{{ server.management_port }}</dd>
</dl>
</div>
</div>
</div>
<!-- Network Card -->
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-hdd-network"></i> Netzwerk</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">Adresse</dt>
<dd class="col-sm-7"><code>{{ server.hostname }}:{{ server.port }}</code></dd>
<dt class="col-sm-5">Protokoll</dt>
<dd class="col-sm-7">{{ server.protocol.value.upper() }}</dd>
<dt class="col-sm-5">VPN-Netzwerk</dt>
<dd class="col-sm-7">{{ server.vpn_network }}/{{ server.vpn_netmask }}</dd>
<dt class="col-sm-5">Max Clients</dt>
<dd class="col-sm-7">{{ server.max_clients }}</dd>
<dt class="col-sm-5">Keepalive</dt>
<dd class="col-sm-7">{{ server.keepalive_interval }}s / {{ server.keepalive_timeout }}s</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Security Card -->
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-shield-lock"></i> Sicherheit</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">CA</dt>
<dd class="col-sm-7">
<a href="/ca/{{ server.certificate_authority.id }}">
{{ server.certificate_authority.name }}
</a>
</dd>
<dt class="col-sm-5">Cipher</dt>
<dd class="col-sm-7">{{ server.cipher.value }}</dd>
<dt class="col-sm-5">Auth</dt>
<dd class="col-sm-7">{{ server.auth.value }}</dd>
<dt class="col-sm-5">TLS Version</dt>
<dd class="col-sm-7">>= {{ server.tls_version_min }}</dd>
<dt class="col-sm-5">TLS-Auth</dt>
<dd class="col-sm-7">
{% if server.tls_auth_enabled %}
<span class="badge bg-success">Aktiviert</span>
{% else %}
<span class="badge bg-secondary">Deaktiviert</span>
{% endif %}
</dd>
<dt class="col-sm-5">Kompression</dt>
<dd class="col-sm-7">{{ server.compression.value }}</dd>
</dl>
</div>
</div>
</div>
<!-- Profiles Card -->
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-person-vcard"></i> VPN-Profile</h5>
</div>
<div class="card-body">
{% if server.vpn_profiles %}
<ul class="list-group list-group-flush">
{% for profile in server.vpn_profiles[:10] %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span>
<a href="/gateways/{{ profile.gateway_id }}/profiles/{{ profile.id }}">
{{ profile.gateway.name }} - {{ profile.name }}
</a>
</span>
{% if profile.status.value == 'active' %}
<span class="badge bg-success">Aktiv</span>
{% elif profile.status.value == 'provisioned' %}
<span class="badge bg-info">Provisioniert</span>
{% else %}
<span class="badge bg-secondary">{{ profile.status.value }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% if server.vpn_profiles|length > 10 %}
<p class="text-muted small mt-2 mb-0">... und {{ server.vpn_profiles|length - 10 }} weitere</p>
{% endif %}
{% else %}
<p class="text-muted mb-0">Keine Profile verwenden diesen Server.</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Server Log -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-terminal"></i> Server-Log</h5>
<div>
<button class="btn btn-sm btn-outline-secondary" onclick="refreshLog()">
<i class="bi bi-arrow-clockwise"></i> Aktualisieren
</button>
<a href="/api/internal/vpn-servers/{{ server.id }}/logs/raw?lines=500"
class="btn btn-sm btn-outline-primary" target="_blank">
<i class="bi bi-download"></i> Download
</a>
</div>
</div>
<div class="card-body p-0">
<div id="server-log" class="bg-dark text-light p-3"
style="max-height: 400px; overflow-y: auto; font-family: monospace; font-size: 12px;">
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-light" role="status">
<span class="visually-hidden">Laden...</span>
</div>
<span class="ms-2">Log wird geladen...</span>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function refreshLog() {
const container = document.getElementById('server-log');
try {
const response = await fetch('/api/internal/vpn-servers/{{ server.id }}/logs?lines=100');
const data = await response.json();
if (data.lines && data.lines.length > 0) {
container.innerHTML = data.lines.map(line => {
// Color-code log levels
let lineClass = '';
if (line.includes('ERROR') || line.includes('error')) lineClass = 'text-danger';
else if (line.includes('WARN') || line.includes('warning')) lineClass = 'text-warning';
else if (line.includes('INFO')) lineClass = 'text-info';
return `<div class="${lineClass}">${escapeHtml(line)}</div>`;
}).join('');
container.scrollTop = container.scrollHeight;
} else {
container.innerHTML = '<div class="text-muted">Keine Log-Einträge vorhanden</div>';
}
} catch (error) {
container.innerHTML = `<div class="text-danger">Fehler beim Laden: ${error.message}</div>`;
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load log on page load
document.addEventListener('DOMContentLoaded', refreshLog);
// Auto-refresh every 10 seconds
setInterval(refreshLog, 10000);
</script>
{% endblock %}

View File

@ -0,0 +1,204 @@
{% extends "base.html" %}
{% block title %}{% if server %}VPN-Server bearbeiten{% else %}Neuer VPN-Server{% endif %} - mGuard VPN{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
{% if server %}
<i class="bi bi-pencil"></i> VPN-Server bearbeiten
{% else %}
<i class="bi bi-plus-lg"></i> Neuer VPN-Server
{% endif %}
</h4>
</div>
<div class="card-body">
<form action="{% if server %}/vpn-servers/{{ server.id }}/edit{% else %}/vpn-servers/new{% endif %}" method="post">
<!-- Basic Settings -->
<h6 class="text-muted mb-3">Grundeinstellungen</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Name *</label>
<input type="text" name="name" class="form-control" required
value="{{ server.name if server else '' }}"
placeholder="z.B. Produktion UDP">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Certificate Authority *</label>
{% if server %}
<input type="text" class="form-control" disabled
value="{{ server.certificate_authority.name }}">
<input type="hidden" name="ca_id" value="{{ server.ca_id }}">
{% else %}
<select name="ca_id" class="form-select" required>
<option value="">-- CA auswählen --</option>
{% for ca in cas %}
<option value="{{ ca.id }}">{{ ca.name }}</option>
{% endfor %}
</select>
{% endif %}
</div>
</div>
<div class="mb-3">
<label class="form-label">Beschreibung</label>
<textarea name="description" class="form-control" rows="2"
placeholder="Optionale Beschreibung">{{ server.description if server else '' }}</textarea>
</div>
<hr>
<h6 class="text-muted mb-3">Netzwerk</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Hostname/IP *</label>
<input type="text" name="hostname" class="form-control" required
value="{{ server.hostname if server else '' }}"
placeholder="vpn.meinefirma.de">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Port *</label>
<input type="number" name="port" class="form-control" required
value="{{ server.port if server else 1194 }}"
min="1" max="65535">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Protokoll *</label>
<select name="protocol" class="form-select">
<option value="udp" {% if not server or server.protocol.value == 'udp' %}selected{% endif %}>UDP</option>
<option value="tcp" {% if server and server.protocol.value == 'tcp' %}selected{% endif %}>TCP</option>
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">VPN-Netzwerk</label>
<input type="text" name="vpn_network" class="form-control"
value="{{ server.vpn_network if server else '10.8.0.0' }}"
placeholder="10.8.0.0">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Netzmaske</label>
<input type="text" name="vpn_netmask" class="form-control"
value="{{ server.vpn_netmask if server else '255.255.255.0' }}"
placeholder="255.255.255.0">
</div>
</div>
<hr>
<h6 class="text-muted mb-3">Sicherheit</h6>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Cipher</label>
<select name="cipher" class="form-select">
{% for c in ciphers %}
<option value="{{ c.value }}" {% if server and server.cipher.value == c.value %}selected{% elif not server and c.value == 'AES-256-GCM' %}selected{% endif %}>
{{ c.value }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Auth</label>
<select name="auth" class="form-select">
{% for a in auth_methods %}
<option value="{{ a.value }}" {% if server and server.auth.value == a.value %}selected{% elif not server and a.value == 'SHA256' %}selected{% endif %}>
{{ a.value }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">TLS Version min</label>
<select name="tls_version_min" class="form-select">
<option value="1.2" {% if not server or server.tls_version_min == '1.2' %}selected{% endif %}>1.2</option>
<option value="1.3" {% if server and server.tls_version_min == '1.3' %}selected{% endif %}>1.3</option>
</select>
</div>
</div>
<hr>
<h6 class="text-muted mb-3">Performance</h6>
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label">Max Clients</label>
<input type="number" name="max_clients" class="form-control"
value="{{ server.max_clients if server else 100 }}"
min="1" max="1000">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Keepalive (s)</label>
<input type="number" name="keepalive_interval" class="form-control"
value="{{ server.keepalive_interval if server else 10 }}"
min="1" max="60">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Timeout (s)</label>
<input type="number" name="keepalive_timeout" class="form-control"
value="{{ server.keepalive_timeout if server else 60 }}"
min="10" max="300">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Kompression</label>
<select name="compression" class="form-select">
{% for co in compression_options %}
<option value="{{ co.value }}" {% if server and server.compression.value == co.value %}selected{% elif not server and co.value == 'none' %}selected{% endif %}>
{{ co.value }}
</option>
{% endfor %}
</select>
</div>
</div>
<hr>
<h6 class="text-muted mb-3">Docker</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Management-Port</label>
<input type="number" name="management_port" class="form-control"
value="{{ server.management_port if server else 7505 }}"
min="1" max="65535">
</div>
<div class="col-md-6 mb-3 d-flex align-items-end">
<div class="form-check">
<input type="checkbox" name="is_primary" class="form-check-input" id="isPrimary"
{% if server and server.is_primary %}checked{% endif %}>
<label class="form-check-label" for="isPrimary">Primärer Server</label>
</div>
</div>
</div>
{% if server %}
<div class="row">
<div class="col-md-6 mb-3 d-flex align-items-end">
<div class="form-check">
<input type="checkbox" name="is_active" class="form-check-input" id="isActive"
{% if server.is_active %}checked{% endif %}>
<label class="form-check-label" for="isActive">Server aktiv</label>
</div>
</div>
</div>
{% endif %}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i>
{% if server %}Speichern{% else %}VPN-Server erstellen{% endif %}
</button>
<a href="/vpn-servers" class="btn btn-outline-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}VPN-Server - mGuard VPN{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-server"></i> VPN-Server</h1>
<a href="/vpn-servers/new" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> Neuer VPN-Server
</a>
</div>
<div class="row">
{% for server in servers %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 {% if server.is_primary %}border-primary{% endif %}">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
{% if server.status.value == 'running' %}
<span class="status-indicator online"></span>
{% else %}
<span class="status-indicator offline"></span>
{% endif %}
{{ server.name }}
</span>
<div>
{% if server.is_primary %}
<span class="badge bg-primary">Primär</span>
{% endif %}
{% if server.protocol.value == 'udp' %}
<span class="badge badge-udp">UDP</span>
{% else %}
<span class="badge badge-tcp">TCP</span>
{% endif %}
</div>
</div>
<div class="card-body">
<p class="text-muted small">{{ server.description or 'Keine Beschreibung' }}</p>
<!-- Connection Info -->
<div class="mb-3">
<code>{{ server.hostname }}:{{ server.port }}</code>
</div>
<!-- Status -->
<div class="mb-2">
{% if server.status.value == 'running' %}
<span class="badge bg-success">Läuft</span>
<span class="badge bg-light text-dark">{{ server.connected_clients }} Clients</span>
{% elif server.status.value == 'stopped' %}
<span class="badge bg-secondary">Gestoppt</span>
{% elif server.status.value == 'starting' %}
<span class="badge bg-warning">Startet...</span>
{% elif server.status.value == 'error' %}
<span class="badge bg-danger">Fehler</span>
{% else %}
<span class="badge bg-light text-dark">{{ server.status.value }}</span>
{% endif %}
</div>
<!-- Details -->
<ul class="list-unstyled small mb-0">
<li><strong>Netzwerk:</strong> {{ server.vpn_network }}/{{ server.vpn_netmask }}</li>
<li><strong>Cipher:</strong> {{ server.cipher.value }}</li>
<li><strong>CA:</strong> {{ server.certificate_authority.name if server.certificate_authority else '-' }}</li>
</ul>
</div>
<div class="card-footer">
<a href="/vpn-servers/{{ server.id }}" class="btn btn-sm btn-primary">
<i class="bi bi-eye"></i> Details
</a>
<a href="/vpn-servers/{{ server.id }}/clients" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-people"></i> Clients
</a>
<a href="/vpn-servers/{{ server.id }}/edit" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
Keine VPN-Server vorhanden.
<a href="/vpn-servers/new">Erstellen Sie einen neuen VPN-Server</a>.
<br>
<small class="text-muted">Hinweis: Sie benötigen zuerst eine <a href="/ca">Certificate Authority</a>.</small>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@ -0,0 +1,11 @@
"""Utility modules."""
from .security import (
verify_password, get_password_hash,
create_access_token, create_refresh_token, decode_token
)
__all__ = [
"verify_password", "get_password_hash",
"create_access_token", "create_refresh_token", "decode_token"
]

View File

@ -0,0 +1,75 @@
"""Security utilities for password hashing and JWT tokens."""
from datetime import datetime, timedelta
import bcrypt
from jose import jwt, JWTError
from ..config import get_settings
settings = get_settings()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return bcrypt.checkpw(
plain_password.encode('utf-8'),
hashed_password.encode('utf-8')
)
def get_password_hash(password: str) -> str:
"""Generate password hash."""
return bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
def create_access_token(
user_id: int,
username: str,
role: str,
tenant_id: int | None = None,
expires_delta: timedelta | None = None
) -> str:
"""Create a JWT access token."""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
to_encode = {
"sub": user_id,
"username": username,
"role": role,
"tenant_id": tenant_id,
"exp": expire,
"type": "access"
}
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token(
user_id: int,
expires_delta: timedelta | None = None
) -> str:
"""Create a JWT refresh token."""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days)
to_encode = {
"sub": user_id,
"exp": expire,
"type": "refresh"
}
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> dict | None:
"""Decode and validate a JWT token."""
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
return payload
except JWTError:
return None

View File

@ -0,0 +1,23 @@
"""Web routes for browser-based UI using Jinja2 + HTMX."""
from fastapi import APIRouter
from . import auth, dashboard, gateways, users, tenants, applications, connections, htmx
from . import ca, vpn_servers, vpn_profiles
# Create web router
web_router = APIRouter()
# Include web route modules
web_router.include_router(auth.router, tags=["Web Auth"])
web_router.include_router(dashboard.router, tags=["Web Dashboard"])
web_router.include_router(gateways.router, tags=["Web Gateways"])
web_router.include_router(users.router, tags=["Web Users"])
web_router.include_router(tenants.router, tags=["Web Tenants"])
web_router.include_router(applications.router, tags=["Web Applications"])
web_router.include_router(connections.router, tags=["Web Connections"])
web_router.include_router(htmx.router, prefix="/htmx", tags=["HTMX Partials"])
# PKI & VPN Management
web_router.include_router(ca.router, tags=["Web CA"])
web_router.include_router(vpn_servers.router, tags=["Web VPN Servers"])
web_router.include_router(vpn_profiles.router, tags=["Web VPN Profiles"])

View File

@ -0,0 +1,156 @@
"""Application template management web routes (Super Admin only)."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User
from ..models.endpoint import ApplicationTemplate, Protocol
from .deps import require_super_admin_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/applications", response_class=HTMLResponse)
async def list_applications(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""List all application templates."""
applications = db.query(ApplicationTemplate).order_by(ApplicationTemplate.name).all()
return request.app.state.templates.TemplateResponse(
"applications/list.html",
{
"request": request,
"current_user": current_user,
"applications": applications,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/applications/new", response_class=HTMLResponse)
async def new_application_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""New application template form."""
return request.app.state.templates.TemplateResponse(
"applications/form.html",
{
"request": request,
"current_user": current_user,
"application": None,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/applications/new")
async def create_application(
request: Request,
name: str = Form(...),
default_port: int = Form(...),
protocol: str = Form("tcp"),
description: str = Form(None),
icon: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Create new application template."""
# Check if name exists
existing = db.query(ApplicationTemplate).filter(ApplicationTemplate.name == name).first()
if existing:
flash(request, "Anwendungsname bereits vergeben", "danger")
return RedirectResponse(url="/applications/new", status_code=303)
application = ApplicationTemplate(
name=name,
default_port=default_port,
protocol=Protocol(protocol),
description=description or None,
icon=icon or None
)
db.add(application)
db.commit()
flash(request, f"Anwendung '{name}' erstellt", "success")
return RedirectResponse(url="/applications", status_code=303)
@router.get("/applications/{app_id}/edit", response_class=HTMLResponse)
async def edit_application_form(
request: Request,
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Edit application template form."""
application = db.query(ApplicationTemplate).filter(ApplicationTemplate.id == app_id).first()
if not application:
flash(request, "Anwendung nicht gefunden", "danger")
return RedirectResponse(url="/applications", status_code=303)
return request.app.state.templates.TemplateResponse(
"applications/form.html",
{
"request": request,
"current_user": current_user,
"application": application,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/applications/{app_id}/edit")
async def update_application(
request: Request,
app_id: int,
name: str = Form(...),
default_port: int = Form(...),
protocol: str = Form("tcp"),
description: str = Form(None),
icon: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Update application template."""
application = db.query(ApplicationTemplate).filter(ApplicationTemplate.id == app_id).first()
if not application:
flash(request, "Anwendung nicht gefunden", "danger")
return RedirectResponse(url="/applications", status_code=303)
application.name = name
application.default_port = default_port
application.protocol = Protocol(protocol)
application.description = description or None
application.icon = icon or None
db.commit()
flash(request, "Anwendung aktualisiert", "success")
return RedirectResponse(url="/applications", status_code=303)
@router.post("/applications/{app_id}/delete")
async def delete_application(
request: Request,
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Delete application template."""
application = db.query(ApplicationTemplate).filter(ApplicationTemplate.id == app_id).first()
if not application:
flash(request, "Anwendung nicht gefunden", "danger")
return RedirectResponse(url="/applications", status_code=303)
db.delete(application)
db.commit()
flash(request, f"Anwendung '{application.name}' gelöscht", "warning")
return RedirectResponse(url="/applications", status_code=303)

58
server/app/web/auth.py Normal file
View File

@ -0,0 +1,58 @@
"""Web authentication routes (session-based)."""
from fastapi import APIRouter, Request, Form, Depends, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..services.auth_service import AuthService
from ..utils.security import verify_password
from .deps import get_flashed_messages
router = APIRouter()
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""Show login page."""
# If already logged in, redirect to dashboard
if request.session.get("user_id"):
return RedirectResponse(url="/dashboard", status_code=303)
return request.app.state.templates.TemplateResponse(
"auth/login.html",
{"request": request, "error": None, "flash_messages": get_flashed_messages(request)}
)
@router.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
remember: bool = Form(False),
db: Session = Depends(get_db)
):
"""Process login form."""
auth_service = AuthService(db)
user = auth_service.authenticate_user(username, password)
if not user:
return request.app.state.templates.TemplateResponse(
"auth/login.html",
{"request": request, "error": "Ungültige Anmeldedaten", "flash_messages": []}
)
# Store user in session
request.session["user_id"] = user.id
request.session["username"] = user.username
request.session["role"] = user.role.value
request.session["tenant_id"] = user.tenant_id
return RedirectResponse(url="/dashboard", status_code=303)
@router.get("/logout")
async def logout(request: Request):
"""Logout user."""
request.session.clear()
return RedirectResponse(url="/login", status_code=303)

301
server/app/web/ca.py Normal file
View File

@ -0,0 +1,301 @@
"""Certificate Authority management web routes."""
from fastapi import APIRouter, Request, Depends, Form, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.orm import Session
from typing import Optional
from ..database import get_db
from ..models.user import User
from ..models.certificate_authority import CertificateAuthority, CAStatus
from ..services.certificate_service import CertificateService
from .deps import require_admin_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/ca", response_class=HTMLResponse)
async def list_cas(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""List all Certificate Authorities."""
if current_user.is_super_admin:
cas = db.query(CertificateAuthority).all()
else:
cas = db.query(CertificateAuthority).filter(
(CertificateAuthority.tenant_id == current_user.tenant_id) |
(CertificateAuthority.tenant_id == None)
).all()
return request.app.state.templates.TemplateResponse(
"ca/list.html",
{
"request": request,
"current_user": current_user,
"cas": cas,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/ca/new", response_class=HTMLResponse)
async def new_ca_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""New CA form."""
return request.app.state.templates.TemplateResponse(
"ca/form.html",
{
"request": request,
"current_user": current_user,
"ca": None,
"mode": "create",
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/ca/new")
async def create_ca(
request: Request,
name: str = Form(...),
description: str = Form(None),
key_size: int = Form(4096),
validity_days: int = Form(3650),
organization: str = Form("mGuard VPN"),
country: str = Form("DE"),
state: str = Form("NRW"),
city: str = Form("Dortmund"),
is_default: bool = Form(False),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Create new CA."""
try:
service = CertificateService(db)
tenant_id = None if current_user.is_super_admin else current_user.tenant_id
ca = service.create_ca(
name=name,
key_size=key_size,
validity_days=validity_days,
organization=organization,
country=country,
state=state,
city=city,
tenant_id=tenant_id,
created_by_id=current_user.id,
is_default=is_default
)
flash(request, f"CA '{name}' wird erstellt. DH-Parameter werden im Hintergrund generiert...", "success")
return RedirectResponse(url=f"/ca/{ca.id}", status_code=303)
except Exception as e:
flash(request, f"Fehler beim Erstellen der CA: {str(e)}", "danger")
return RedirectResponse(url="/ca/new", status_code=303)
@router.get("/ca/import", response_class=HTMLResponse)
async def import_ca_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Import CA form."""
return request.app.state.templates.TemplateResponse(
"ca/form.html",
{
"request": request,
"current_user": current_user,
"ca": None,
"mode": "import",
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/ca/import")
async def import_ca(
request: Request,
name: str = Form(...),
description: str = Form(None),
ca_cert: UploadFile = File(...),
ca_key: UploadFile = File(...),
dh_params: Optional[UploadFile] = File(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Import existing CA from files."""
try:
service = CertificateService(db)
ca_cert_pem = (await ca_cert.read()).decode('utf-8')
ca_key_pem = (await ca_key.read()).decode('utf-8')
dh_params_pem = (await dh_params.read()).decode('utf-8') if dh_params else None
tenant_id = None if current_user.is_super_admin else current_user.tenant_id
ca = service.import_ca(
name=name,
ca_cert_pem=ca_cert_pem,
ca_key_pem=ca_key_pem,
dh_params_pem=dh_params_pem,
tenant_id=tenant_id,
created_by_id=current_user.id
)
if dh_params_pem:
flash(request, f"CA '{name}' erfolgreich importiert", "success")
else:
flash(request, f"CA '{name}' importiert. DH-Parameter werden generiert...", "success")
return RedirectResponse(url=f"/ca/{ca.id}", status_code=303)
except Exception as e:
flash(request, f"Fehler beim Importieren: {str(e)}", "danger")
return RedirectResponse(url="/ca/import", status_code=303)
@router.get("/ca/{ca_id}", response_class=HTMLResponse)
async def ca_detail(
request: Request,
ca_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""CA detail page."""
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if not ca:
flash(request, "CA nicht gefunden", "danger")
return RedirectResponse(url="/ca", status_code=303)
# Check access
if not current_user.is_super_admin and ca.tenant_id != current_user.tenant_id and ca.tenant_id is not None:
flash(request, "Zugriff verweigert", "danger")
return RedirectResponse(url="/ca", status_code=303)
return request.app.state.templates.TemplateResponse(
"ca/detail.html",
{
"request": request,
"current_user": current_user,
"ca": ca,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/ca/{ca_id}/set-default")
async def set_ca_default(
request: Request,
ca_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Set CA as default."""
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if not ca:
flash(request, "CA nicht gefunden", "danger")
return RedirectResponse(url="/ca", status_code=303)
# Unset other defaults for same tenant
db.query(CertificateAuthority).filter(
CertificateAuthority.tenant_id == ca.tenant_id,
CertificateAuthority.id != ca.id
).update({"is_default": False})
ca.is_default = True
db.commit()
flash(request, f"'{ca.name}' ist jetzt die Standard-CA", "success")
return RedirectResponse(url=f"/ca/{ca_id}", status_code=303)
@router.get("/ca/{ca_id}/download/cert")
async def download_ca_cert(
ca_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Download CA certificate."""
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if not ca or not ca.ca_cert:
return Response(status_code=404)
return Response(
content=ca.ca_cert,
media_type="application/x-pem-file",
headers={"Content-Disposition": f"attachment; filename={ca.name}-ca.crt"}
)
@router.get("/ca/{ca_id}/download/crl")
async def download_crl(
ca_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Download Certificate Revocation List."""
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if not ca:
return Response(status_code=404)
service = CertificateService(db)
crl = service.get_crl(ca)
return Response(
content=crl,
media_type="application/x-pem-file",
headers={"Content-Disposition": f"attachment; filename={ca.name}-crl.pem"}
)
@router.post("/ca/{ca_id}/delete")
async def delete_ca(
request: Request,
ca_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Delete CA."""
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if not ca:
flash(request, "CA nicht gefunden", "danger")
return RedirectResponse(url="/ca", status_code=303)
# Check if CA has servers or profiles
if ca.vpn_servers:
flash(request, "CA wird noch von VPN-Servern verwendet. Bitte zuerst löschen.", "danger")
return RedirectResponse(url=f"/ca/{ca_id}", status_code=303)
if ca.vpn_profiles:
flash(request, "CA wird noch von VPN-Profilen verwendet. Bitte zuerst löschen.", "danger")
return RedirectResponse(url=f"/ca/{ca_id}", status_code=303)
name = ca.name
db.delete(ca)
db.commit()
flash(request, f"CA '{name}' gelöscht", "warning")
return RedirectResponse(url="/ca", status_code=303)

View File

@ -0,0 +1,27 @@
"""Connection log web routes."""
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User
from .deps import get_current_user_web, get_flashed_messages
router = APIRouter()
@router.get("/connections", response_class=HTMLResponse)
async def connection_log(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Connection log page."""
return request.app.state.templates.TemplateResponse(
"connections/log.html",
{
"request": request,
"current_user": current_user,
"flash_messages": get_flashed_messages(request)
}
)

View File

@ -0,0 +1,57 @@
"""Dashboard web routes."""
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..database import get_db
from ..models.user import User
from ..models.gateway import Gateway
from ..models.endpoint import Endpoint
from ..models.access import ConnectionLog
from .deps import get_current_user_web, get_flashed_messages
router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Root redirect to dashboard."""
if request.session.get("user_id"):
return RedirectResponse(url="/dashboard", status_code=303)
return RedirectResponse(url="/login", status_code=303)
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Dashboard with overview."""
# Get statistics
gateways_total = db.query(Gateway).count()
gateways_online = db.query(Gateway).filter(Gateway.is_online == True).count()
endpoints_total = db.query(Endpoint).count()
users_total = db.query(User).filter(User.is_active == True).count()
active_connections = db.query(ConnectionLog).filter(
ConnectionLog.disconnected_at.is_(None)
).count()
stats = {
"gateways_total": gateways_total,
"gateways_online": gateways_online,
"endpoints_total": endpoints_total,
"users_total": users_total,
"active_connections": active_connections
}
return request.app.state.templates.TemplateResponse(
"dashboard/index.html",
{
"request": request,
"current_user": current_user,
"stats": stats,
"flash_messages": get_flashed_messages(request)
}
)

61
server/app/web/deps.py Normal file
View File

@ -0,0 +1,61 @@
"""Web route dependencies."""
from fastapi import Request, HTTPException, Depends
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
async def get_current_user_web(request: Request, db: Session = Depends(get_db)) -> User:
"""Get current user from session for web routes."""
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=303, headers={"Location": "/login"})
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
request.session.clear()
raise HTTPException(status_code=303, headers={"Location": "/login"})
return user
async def require_user_web(
current_user: User = Depends(get_current_user_web)
) -> User:
"""Require any authenticated user for web routes."""
return current_user
async def require_admin_web(
current_user: User = Depends(get_current_user_web)
) -> User:
"""Require admin role for web routes."""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
return current_user
async def require_super_admin_web(
current_user: User = Depends(get_current_user_web)
) -> User:
"""Require super admin role for web routes."""
if current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(status_code=403, detail="Super-Admin-Rechte erforderlich")
return current_user
def get_flashed_messages(request: Request) -> list[dict]:
"""Get and clear flash messages from session."""
messages = request.session.pop("flash_messages", [])
return messages
def flash(request: Request, message: str, category: str = "info"):
"""Add a flash message to session."""
if "flash_messages" not in request.session:
request.session["flash_messages"] = []
request.session["flash_messages"].append({"category": category, "message": message})

250
server/app/web/gateways.py Normal file
View File

@ -0,0 +1,250 @@
"""Gateway web routes."""
from fastapi import APIRouter, Request, Depends, Form, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
from ..models.gateway import Gateway, RouterType, ProvisioningMethod
from ..models.endpoint import ApplicationTemplate
from ..models.tenant import Tenant
from ..models.access import UserGatewayAccess
from .deps import get_current_user_web, require_admin_web, flash, get_flashed_messages
router = APIRouter()
def get_accessible_gateways(db: Session, user: User):
"""Get gateways accessible by user."""
if user.role == UserRole.SUPER_ADMIN:
return db.query(Gateway).all()
elif user.role == UserRole.ADMIN:
return db.query(Gateway).filter(Gateway.tenant_id == user.tenant_id).all()
else:
return db.query(Gateway).join(
UserGatewayAccess,
UserGatewayAccess.gateway_id == Gateway.id
).filter(UserGatewayAccess.user_id == user.id).all()
@router.get("/gateways", response_class=HTMLResponse)
async def list_gateways(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""List gateways page."""
return request.app.state.templates.TemplateResponse(
"gateways/list.html",
{
"request": request,
"current_user": current_user,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/gateways/new", response_class=HTMLResponse)
async def new_gateway_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""New gateway form."""
tenants = db.query(Tenant).filter(Tenant.is_active == True).all()
return request.app.state.templates.TemplateResponse(
"gateways/form.html",
{
"request": request,
"current_user": current_user,
"gateway": None,
"tenants": tenants,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/gateways/new", response_class=HTMLResponse)
async def create_gateway(
request: Request,
name: str = Form(...),
router_type: str = Form(...),
firmware_version: str = Form(None),
serial_number: str = Form(None),
location: str = Form(None),
vpn_subnet: str = Form(None),
description: str = Form(None),
tenant_id: int = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Create new gateway."""
# Determine tenant
if current_user.role == UserRole.SUPER_ADMIN and tenant_id:
gateway_tenant_id = tenant_id
else:
gateway_tenant_id = current_user.tenant_id
# Generate VPN cert CN
vpn_cert_cn = f"gateway-{name.lower().replace(' ', '-')}"
gateway = Gateway(
name=name,
router_type=RouterType(router_type),
firmware_version=firmware_version or None,
serial_number=serial_number or None,
location=location or None,
vpn_subnet=vpn_subnet or None,
description=description or None,
tenant_id=gateway_tenant_id,
vpn_cert_cn=vpn_cert_cn
)
db.add(gateway)
db.commit()
db.refresh(gateway)
flash(request, f"Gateway '{name}' erfolgreich erstellt", "success")
return RedirectResponse(url=f"/gateways/{gateway.id}", status_code=303)
@router.get("/gateways/{gateway_id}", response_class=HTMLResponse)
async def gateway_detail(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Gateway detail page."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
# Check access
if current_user.role == UserRole.TECHNICIAN:
access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.user_id == current_user.id,
UserGatewayAccess.gateway_id == gateway_id
).first()
if not access:
flash(request, "Kein Zugriff auf dieses Gateway", "danger")
return RedirectResponse(url="/gateways", status_code=303)
templates = db.query(ApplicationTemplate).all()
return request.app.state.templates.TemplateResponse(
"gateways/detail.html",
{
"request": request,
"current_user": current_user,
"gateway": gateway,
"templates": templates,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/gateways/{gateway_id}/edit", response_class=HTMLResponse)
async def edit_gateway_form(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Edit gateway form."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
tenants = db.query(Tenant).filter(Tenant.is_active == True).all()
return request.app.state.templates.TemplateResponse(
"gateways/form.html",
{
"request": request,
"current_user": current_user,
"gateway": gateway,
"tenants": tenants,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/gateways/{gateway_id}/edit")
async def update_gateway(
request: Request,
gateway_id: int,
name: str = Form(...),
router_type: str = Form(...),
firmware_version: str = Form(None),
serial_number: str = Form(None),
location: str = Form(None),
vpn_subnet: str = Form(None),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Update gateway."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
gateway.name = name
gateway.router_type = RouterType(router_type)
gateway.firmware_version = firmware_version or None
gateway.serial_number = serial_number or None
gateway.location = location or None
gateway.vpn_subnet = vpn_subnet or None
gateway.description = description or None
db.commit()
flash(request, "Gateway aktualisiert", "success")
return RedirectResponse(url=f"/gateways/{gateway_id}", status_code=303)
@router.post("/gateways/{gateway_id}/delete")
async def delete_gateway(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Delete gateway."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
db.delete(gateway)
db.commit()
flash(request, f"Gateway '{gateway.name}' gelöscht", "warning")
return RedirectResponse(url="/gateways", status_code=303)
@router.get("/gateways/{gateway_id}/provision")
async def download_provisioning(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Redirect to VPN profiles for provisioning.
Provisioning is now done through VPN profiles.
Each profile contains its own certificate and VPN server configuration.
"""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
# Redirect to profiles page
flash(request, "Bitte erstellen Sie ein VPN-Profil für das Provisioning", "info")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)

698
server/app/web/htmx.py Normal file
View File

@ -0,0 +1,698 @@
"""HTMX partial routes for dynamic updates."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
from ..models.gateway import Gateway
from ..models.endpoint import Endpoint
from ..models.access import UserGatewayAccess, ConnectionLog
from ..models.vpn_server import VPNServer
from ..models.vpn_connection_log import VPNConnectionLog
from ..services.vpn_server_service import VPNServerService
from ..services.vpn_sync_service import VPNSyncService
from .deps import get_current_user_web
router = APIRouter()
@router.get("/dashboard/stats", response_class=HTMLResponse)
async def dashboard_stats(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Dashboard statistics partial."""
gateways_total = db.query(Gateway).count()
gateways_online = db.query(Gateway).filter(Gateway.is_online == True).count()
endpoints_total = db.query(Endpoint).count()
users_total = db.query(User).filter(User.is_active == True).count()
active_connections = db.query(ConnectionLog).filter(
ConnectionLog.disconnected_at.is_(None)
).count()
# Count VPN clients across all active servers
vpn_clients_total = 0
vpn_servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
service = VPNServerService(db)
for server in vpn_servers:
try:
clients = service.get_connected_clients(server)
vpn_clients_total += len(clients)
except:
pass # Server might be offline
return f"""
<div class="col-md-2 mb-3">
<div class="card stat-card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Gateways</h6>
<div class="stat-value">{gateways_online} / {gateways_total}</div>
</div>
<i class="bi bi-router stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">VPN-Clients</h6>
<div class="stat-value">{vpn_clients_total}</div>
</div>
<i class="bi bi-shield-check stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-info text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Sessions</h6>
<div class="stat-value">{active_connections}</div>
</div>
<i class="bi bi-plug stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-secondary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Endpunkte</h6>
<div class="stat-value">{endpoints_total}</div>
</div>
<i class="bi bi-hdd-network stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-warning text-dark">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2">Benutzer</h6>
<div class="stat-value">{users_total}</div>
</div>
<i class="bi bi-people stat-icon"></i>
</div>
</div>
</div>
</div>
"""
@router.get("/gateways/list", response_class=HTMLResponse)
@router.get("/gateways/search", response_class=HTMLResponse)
@router.get("/gateways/filter", response_class=HTMLResponse)
async def gateway_list_partial(
request: Request,
q: str = "",
status: str = "",
type: str = "",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Gateway list partial for HTMX."""
query = db.query(Gateway)
# Filter by tenant for non-super-admins
if current_user.role != UserRole.SUPER_ADMIN:
if current_user.role == UserRole.ADMIN:
query = query.filter(Gateway.tenant_id == current_user.tenant_id)
else:
query = query.join(
UserGatewayAccess,
UserGatewayAccess.gateway_id == Gateway.id
).filter(UserGatewayAccess.user_id == current_user.id)
# Apply filters
if q:
query = query.filter(Gateway.name.ilike(f"%{q}%"))
if status == "online":
query = query.filter(Gateway.is_online == True)
elif status == "offline":
query = query.filter(Gateway.is_online == False)
if type:
query = query.filter(Gateway.router_type == type)
gateways = query.all()
if not gateways:
return """
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Keine Gateways gefunden
</div>
"""
html = '<div class="row">'
for gw in gateways:
status_class = "online" if gw.is_online else "offline"
status_badge = '<span class="badge bg-success">Online</span>' if gw.is_online else '<span class="badge bg-secondary">Offline</span>'
last_seen = gw.last_seen.strftime('%d.%m.%Y %H:%M') if gw.last_seen else 'Nie'
html += f"""
<div class="col-md-6 col-lg-4 mb-3">
<div class="card gateway-card {status_class}">
<div class="card-body">
<h5 class="card-title">
<span class="status-indicator {status_class}"></span>
{gw.name}
</h5>
<p class="card-text text-muted small mb-2">
{gw.router_type} | {gw.location or 'Kein Standort'}
</p>
<p class="card-text small">
{status_badge}
<span class="text-muted ms-2">Zuletzt: {last_seen}</span>
</p>
<a href="/gateways/{gw.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Details
</a>
</div>
</div>
</div>
"""
html += '</div>'
return html
@router.get("/gateways/status-list", response_class=HTMLResponse)
async def gateway_status_list(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Gateway status list for dashboard."""
query = db.query(Gateway)
if current_user.role != UserRole.SUPER_ADMIN:
if current_user.role == UserRole.ADMIN:
query = query.filter(Gateway.tenant_id == current_user.tenant_id)
gateways = query.limit(10).all()
if not gateways:
return '<p class="text-muted">Keine Gateways vorhanden</p>'
html = '<table class="table table-sm table-hover mb-0"><tbody>'
for gw in gateways:
status = '<span class="status-indicator online"></span>' if gw.is_online else '<span class="status-indicator offline"></span>'
html += f"""
<tr onclick="window.location='/gateways/{gw.id}'" style="cursor:pointer">
<td>{status} {gw.name}</td>
<td class="text-muted">{gw.router_type}</td>
<td class="text-end">
<a href="/gateways/{gw.id}" class="btn btn-sm btn-link">
<i class="bi bi-arrow-right"></i>
</a>
</td>
</tr>
"""
html += '</tbody></table>'
return html
@router.get("/gateways/{gateway_id}/endpoints", response_class=HTMLResponse)
async def gateway_endpoints_partial(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Endpoints list partial."""
endpoints = db.query(Endpoint).filter(Endpoint.gateway_id == gateway_id).all()
if not endpoints:
return """
<div class="text-center text-muted py-4">
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
<p>Keine Endpunkte definiert</p>
</div>
"""
html = '<table class="table table-hover mb-0"><thead><tr><th>Name</th><th>Adresse</th><th>Protokoll</th><th>Anwendung</th><th></th></tr></thead><tbody>'
for ep in endpoints:
protocol_badge = f'<span class="badge badge-{ep.protocol.value}">{ep.protocol.value.upper()}</span>'
html += f"""
<tr>
<td><strong>{ep.name}</strong></td>
<td><code>{ep.internal_ip}:{ep.port}</code></td>
<td>{protocol_badge}</td>
<td>{ep.application_name or '-'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger"
hx-delete="/htmx/endpoints/{ep.id}"
hx-confirm="Endpunkt '{ep.name}' löschen?"
hx-target="#endpoints-list"
hx-swap="innerHTML">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
"""
html += '</tbody></table>'
return html
@router.post("/gateways/{gateway_id}/endpoints", response_class=HTMLResponse)
async def create_endpoint_htmx(
request: Request,
gateway_id: int,
name: str = Form(...),
internal_ip: str = Form(...),
port: int = Form(...),
protocol: str = Form("tcp"),
application_template_id: int = Form(None),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Create endpoint via HTMX."""
from ..models.endpoint import Protocol
endpoint = Endpoint(
gateway_id=gateway_id,
name=name,
internal_ip=internal_ip,
port=port,
protocol=Protocol(protocol),
application_template_id=application_template_id if application_template_id else None,
description=description
)
db.add(endpoint)
db.commit()
# Return updated list
return await gateway_endpoints_partial(request, gateway_id, db, current_user)
@router.delete("/endpoints/{endpoint_id}", response_class=HTMLResponse)
async def delete_endpoint_htmx(
request: Request,
endpoint_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Delete endpoint via HTMX."""
endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first()
if endpoint:
gateway_id = endpoint.gateway_id
db.delete(endpoint)
db.commit()
return await gateway_endpoints_partial(request, gateway_id, db, current_user)
return ""
@router.get("/gateways/{gateway_id}/access", response_class=HTMLResponse)
async def gateway_access_partial(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""User access list partial for gateway."""
access_list = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id
).all()
if not access_list:
return """
<div class="text-center text-muted py-4">
<i class="bi bi-people" style="font-size: 2rem;"></i>
<p>Keine Benutzer haben Zugriff</p>
</div>
"""
html = '<table class="table table-hover mb-0"><thead><tr><th>Benutzer</th><th>Rolle</th><th>Gewährt am</th><th>Gewährt von</th><th></th></tr></thead><tbody>'
for access in access_list:
user = db.query(User).filter(User.id == access.user_id).first()
granted_by = db.query(User).filter(User.id == access.granted_by_id).first() if access.granted_by_id else None
granted_at = access.granted_at.strftime('%d.%m.%Y') if access.granted_at else '-'
html += f"""
<tr>
<td><strong>{user.username if user else '-'}</strong></td>
<td><span class="badge bg-secondary">{user.role.value if user else '-'}</span></td>
<td>{granted_at}</td>
<td>{granted_by.username if granted_by else '-'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger"
hx-delete="/htmx/gateways/{gateway_id}/access/{access.user_id}"
hx-confirm="Zugriff entziehen?"
hx-target="#access-list"
hx-swap="innerHTML">
<i class="bi bi-x-lg"></i>
</button>
</td>
</tr>
"""
html += '</tbody></table>'
return html
@router.post("/gateways/{gateway_id}/access", response_class=HTMLResponse)
async def grant_access_htmx(
request: Request,
gateway_id: int,
user_id: int = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Grant user access to gateway via HTMX."""
# Check if access already exists
existing = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id,
UserGatewayAccess.user_id == user_id
).first()
if not existing:
access = UserGatewayAccess(
gateway_id=gateway_id,
user_id=user_id,
granted_by_id=current_user.id
)
db.add(access)
db.commit()
return await gateway_access_partial(request, gateway_id, db, current_user)
@router.delete("/gateways/{gateway_id}/access/{user_id}", response_class=HTMLResponse)
async def revoke_access_htmx(
request: Request,
gateway_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Revoke user access to gateway via HTMX."""
access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id,
UserGatewayAccess.user_id == user_id
).first()
if access:
db.delete(access)
db.commit()
return await gateway_access_partial(request, gateway_id, db, current_user)
@router.get("/gateways/{gateway_id}/vpn-log", response_class=HTMLResponse)
async def gateway_vpn_log_partial(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""VPN connection log for gateway."""
sync_service = VPNSyncService(db)
logs = sync_service.get_gateway_connection_logs(gateway_id, limit=20)
if not logs:
return """
<div class="text-center text-muted py-4">
<i class="bi bi-shield-x" style="font-size: 2rem;"></i>
<p>Keine VPN-Verbindungen aufgezeichnet</p>
</div>
"""
html = '''<table class="table table-hover table-sm mb-0">
<thead>
<tr>
<th>Profil</th>
<th>Server</th>
<th>Echte Adresse</th>
<th>Verbunden</th>
<th>Getrennt</th>
<th>Dauer</th>
<th>Traffic</th>
</tr>
</thead>
<tbody>'''
for log in logs:
profile_name = log.vpn_profile.name if log.vpn_profile else '-'
server_name = log.vpn_server.name if log.vpn_server else '-'
real_addr = log.real_address or '-'
connected = log.connected_at.strftime('%d.%m. %H:%M') if log.connected_at else '-'
if log.disconnected_at:
disconnected = log.disconnected_at.strftime('%H:%M')
duration = log.duration_seconds or 0
if duration >= 3600:
duration_str = f"{duration // 3600}h {(duration % 3600) // 60}m"
elif duration >= 60:
duration_str = f"{duration // 60}m"
else:
duration_str = f"{duration}s"
status_badge = ''
else:
disconnected = '<span class="badge bg-success">Aktiv</span>'
duration_str = '-'
status_badge = ''
rx = log.bytes_received or 0
tx = log.bytes_sent or 0
rx_str = f"{rx / 1024 / 1024:.1f}" if rx > 1024*1024 else f"{rx / 1024:.0f}K"
tx_str = f"{tx / 1024 / 1024:.1f}" if tx > 1024*1024 else f"{tx / 1024:.0f}K"
traffic = f"{rx_str}{tx_str}"
html += f'''
<tr>
<td>{profile_name}</td>
<td>{server_name}</td>
<td><code class="small">{real_addr}</code></td>
<td>{connected}</td>
<td>{disconnected}</td>
<td>{duration_str}</td>
<td class="small">{traffic}</td>
</tr>'''
html += '</tbody></table>'
return html
@router.get("/connections/count", response_class=HTMLResponse)
async def connections_count(
db: Session = Depends(get_db)
):
"""Active connections count."""
count = db.query(ConnectionLog).filter(
ConnectionLog.disconnected_at.is_(None)
).count()
# Also count VPN clients
vpn_clients = 0
vpn_servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
service = VPNServerService(db)
for server in vpn_servers:
try:
clients = service.get_connected_clients(server)
vpn_clients += len(clients)
except:
pass
return f'<i class="bi bi-shield-check"></i> {vpn_clients} VPN-Clients &nbsp; <i class="bi bi-plug"></i> {count} Sessions'
@router.get("/connections/vpn-clients", response_class=HTMLResponse)
async def vpn_clients_list(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""List of connected VPN clients (gateways)."""
# Sync connections with database (updates gateway status, creates logs)
sync_service = VPNSyncService(db)
sync_service.sync_all_connections()
# Get active connections from database
active_connections = sync_service.get_active_connections()
if not active_connections:
return '<p class="text-muted text-center py-3">Keine VPN-Clients verbunden</p>'
html = '''<table class="table table-hover table-sm">
<thead>
<tr>
<th>Gateway</th>
<th>Profil</th>
<th>VPN-Server</th>
<th>Echte Adresse</th>
<th>Empfangen</th>
<th>Gesendet</th>
<th>Verbunden seit</th>
</tr>
</thead>
<tbody>'''
for conn in active_connections:
gateway_name = conn.gateway.name if conn.gateway else '-'
gateway_id = conn.gateway.id if conn.gateway else ''
profile_name = conn.vpn_profile.name if conn.vpn_profile else '-'
server_name = conn.vpn_server.name if conn.vpn_server else '-'
server_id = conn.vpn_server.id if conn.vpn_server else ''
real_addr = conn.real_address or '-'
rx = conn.bytes_received or 0
tx = conn.bytes_sent or 0
connected = conn.connected_at.strftime('%d.%m.%Y %H:%M') if conn.connected_at else '-'
# Format bytes
rx_str = f"{rx / 1024 / 1024:.2f} MB" if rx > 1024*1024 else f"{rx / 1024:.1f} KB"
tx_str = f"{tx / 1024 / 1024:.2f} MB" if tx > 1024*1024 else f"{tx / 1024:.1f} KB"
html += f'''
<tr>
<td><a href="/gateways/{gateway_id}"><strong>{gateway_name}</strong></a></td>
<td>{profile_name}</td>
<td><a href="/vpn-servers/{server_id}">{server_name}</a></td>
<td><code>{real_addr}</code></td>
<td>{rx_str}</td>
<td>{tx_str}</td>
<td>{connected}</td>
</tr>'''
html += '</tbody></table>'
return html
@router.get("/connections/active", response_class=HTMLResponse)
async def active_connections(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Active connections partial."""
connections = db.query(ConnectionLog).filter(
ConnectionLog.disconnected_at.is_(None)
).all()
if not connections:
return '<p class="text-muted text-center py-3">Keine aktiven Verbindungen</p>'
html = '<ul class="list-group list-group-flush">'
for conn in connections:
user = db.query(User).filter(User.id == conn.user_id).first()
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first() if conn.endpoint_id else None
html += f"""
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{user.username if user else 'Unknown'}</strong>
<i class="bi bi-arrow-right mx-2"></i>
{gateway.name if gateway else 'Unknown'}
{f' / {endpoint.name}' if endpoint else ''}
</div>
<span class="badge bg-success"><i class="bi bi-broadcast"></i> Verbunden</span>
</li>
"""
html += '</ul>'
return html
@router.get("/connections/recent", response_class=HTMLResponse)
async def recent_connections(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Recent connections for dashboard."""
connections = db.query(ConnectionLog).order_by(
ConnectionLog.connected_at.desc()
).limit(5).all()
if not connections:
return '<p class="text-muted text-center">Keine Verbindungen</p>'
html = '<ul class="list-group list-group-flush">'
for conn in connections:
user = db.query(User).filter(User.id == conn.user_id).first()
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
time = conn.connected_at.strftime('%H:%M')
if conn.disconnected_at:
status = '<span class="text-muted"><i class="bi bi-x-circle"></i></span>'
else:
status = '<span class="text-success"><i class="bi bi-check-circle"></i></span>'
html += f"""
<li class="list-group-item d-flex justify-content-between align-items-center py-2">
<span>
{status}
<span class="ms-2">{user.username if user else '?'}</span>
<i class="bi bi-arrow-right text-muted mx-1"></i>
<span class="text-muted">{gateway.name if gateway else '?'}</span>
</span>
<small class="text-muted">{time}</small>
</li>
"""
html += '</ul>'
return html
@router.get("/connections/list", response_class=HTMLResponse)
async def connections_list(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Connection history list."""
connections = db.query(ConnectionLog).order_by(
ConnectionLog.connected_at.desc()
).limit(50).all()
if not connections:
return '<p class="text-muted text-center py-4">Keine Verbindungshistorie</p>'
html = '<table class="table table-hover"><thead><tr><th>Benutzer</th><th>Gateway</th><th>Endpunkt</th><th>Verbunden</th><th>Getrennt</th><th>Dauer</th></tr></thead><tbody>'
for conn in connections:
user = db.query(User).filter(User.id == conn.user_id).first()
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first() if conn.endpoint_id else None
connected = conn.connected_at.strftime('%d.%m.%Y %H:%M')
disconnected = conn.disconnected_at.strftime('%H:%M') if conn.disconnected_at else '<span class="badge bg-success">Aktiv</span>'
duration = ""
if conn.duration_seconds:
mins = conn.duration_seconds // 60
duration = f"{mins} Min."
html += f"""
<tr>
<td>{user.username if user else '-'}</td>
<td>{gateway.name if gateway else '-'}</td>
<td>{endpoint.name if endpoint else '-'}</td>
<td>{connected}</td>
<td>{disconnected}</td>
<td>{duration}</td>
</tr>
"""
html += '</tbody></table>'
return html

151
server/app/web/tenants.py Normal file
View File

@ -0,0 +1,151 @@
"""Tenant management web routes (Super Admin only)."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User
from ..models.tenant import Tenant
from .deps import require_super_admin_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/tenants", response_class=HTMLResponse)
async def list_tenants(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""List all tenants."""
tenants = db.query(Tenant).all()
return request.app.state.templates.TemplateResponse(
"tenants/list.html",
{
"request": request,
"current_user": current_user,
"tenants": tenants,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/tenants/new", response_class=HTMLResponse)
async def new_tenant_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""New tenant form."""
return request.app.state.templates.TemplateResponse(
"tenants/form.html",
{
"request": request,
"current_user": current_user,
"tenant": None,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/tenants/new")
async def create_tenant(
request: Request,
name: str = Form(...),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Create new tenant."""
# Check if name exists
existing = db.query(Tenant).filter(Tenant.name == name).first()
if existing:
flash(request, "Mandantenname bereits vergeben", "danger")
return RedirectResponse(url="/tenants/new", status_code=303)
tenant = Tenant(
name=name,
description=description or None
)
db.add(tenant)
db.commit()
flash(request, f"Mandant '{name}' erstellt", "success")
return RedirectResponse(url="/tenants", status_code=303)
@router.get("/tenants/{tenant_id}/edit", response_class=HTMLResponse)
async def edit_tenant_form(
request: Request,
tenant_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Edit tenant form."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
flash(request, "Mandant nicht gefunden", "danger")
return RedirectResponse(url="/tenants", status_code=303)
return request.app.state.templates.TemplateResponse(
"tenants/form.html",
{
"request": request,
"current_user": current_user,
"tenant": tenant,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/tenants/{tenant_id}/edit")
async def update_tenant(
request: Request,
tenant_id: int,
name: str = Form(...),
description: str = Form(None),
is_active: bool = Form(True),
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Update tenant."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
flash(request, "Mandant nicht gefunden", "danger")
return RedirectResponse(url="/tenants", status_code=303)
tenant.name = name
tenant.description = description or None
tenant.is_active = is_active
db.commit()
flash(request, "Mandant aktualisiert", "success")
return RedirectResponse(url="/tenants", status_code=303)
@router.post("/tenants/{tenant_id}/delete")
async def delete_tenant(
request: Request,
tenant_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Delete tenant."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
flash(request, "Mandant nicht gefunden", "danger")
return RedirectResponse(url="/tenants", status_code=303)
# Check if tenant has users or gateways
if tenant.users or tenant.gateways:
flash(request, "Mandant hat noch Benutzer oder Gateways. Bitte zuerst löschen.", "danger")
return RedirectResponse(url="/tenants", status_code=303)
db.delete(tenant)
db.commit()
flash(request, f"Mandant '{tenant.name}' gelöscht", "warning")
return RedirectResponse(url="/tenants", status_code=303)

144
server/app/web/users.py Normal file
View File

@ -0,0 +1,144 @@
"""User management web routes."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
from ..utils.security import get_password_hash
from .deps import get_current_user_web, require_admin_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/users", response_class=HTMLResponse)
async def list_users(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""List users."""
if current_user.role == UserRole.SUPER_ADMIN:
users = db.query(User).all()
else:
users = db.query(User).filter(User.tenant_id == current_user.tenant_id).all()
return request.app.state.templates.TemplateResponse(
"users/list.html",
{
"request": request,
"current_user": current_user,
"users": users,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/users/new", response_class=HTMLResponse)
async def new_user_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""New user form."""
from ..models.tenant import Tenant
tenants = db.query(Tenant).filter(Tenant.is_active == True).all()
return request.app.state.templates.TemplateResponse(
"users/form.html",
{
"request": request,
"current_user": current_user,
"user": None,
"tenants": tenants,
"roles": [r.value for r in UserRole if r != UserRole.SUPER_ADMIN or current_user.role == UserRole.SUPER_ADMIN],
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/users/new")
async def create_user(
request: Request,
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
role: str = Form(...),
full_name: str = Form(None),
tenant_id: int = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Create new user."""
# Check if username exists
existing = db.query(User).filter(User.username == username).first()
if existing:
flash(request, "Benutzername bereits vergeben", "danger")
return RedirectResponse(url="/users/new", status_code=303)
# Check if email exists
existing = db.query(User).filter(User.email == email).first()
if existing:
flash(request, "E-Mail bereits vergeben", "danger")
return RedirectResponse(url="/users/new", status_code=303)
# Determine tenant
if current_user.role == UserRole.SUPER_ADMIN and tenant_id:
user_tenant_id = tenant_id
else:
user_tenant_id = current_user.tenant_id
user = User(
username=username,
email=email,
password_hash=get_password_hash(password),
role=UserRole(role),
full_name=full_name or None,
tenant_id=user_tenant_id if UserRole(role) != UserRole.SUPER_ADMIN else None
)
db.add(user)
db.commit()
flash(request, f"Benutzer '{username}' erstellt", "success")
return RedirectResponse(url="/users", status_code=303)
@router.get("/users/{user_id}/access", response_class=HTMLResponse)
async def user_access(
request: Request,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Manage user gateway access."""
from ..models.gateway import Gateway
from ..models.access import UserGatewayAccess
user = db.query(User).filter(User.id == user_id).first()
if not user:
flash(request, "Benutzer nicht gefunden", "danger")
return RedirectResponse(url="/users", status_code=303)
# Get all gateways and current access
if current_user.role == UserRole.SUPER_ADMIN:
gateways = db.query(Gateway).all()
else:
gateways = db.query(Gateway).filter(Gateway.tenant_id == current_user.tenant_id).all()
user_access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.user_id == user_id
).all()
access_gateway_ids = [a.gateway_id for a in user_access]
return request.app.state.templates.TemplateResponse(
"users/access.html",
{
"request": request,
"current_user": current_user,
"user": user,
"gateways": gateways,
"access_gateway_ids": access_gateway_ids,
"flash_messages": get_flashed_messages(request)
}
)

View File

@ -0,0 +1,394 @@
"""VPN Profile management web routes (nested under gateways)."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User
from ..models.gateway import Gateway
from ..models.vpn_server import VPNServer
from ..models.vpn_profile import VPNProfile
from ..services.vpn_profile_service import VPNProfileService
from .deps import require_admin_web, require_user_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/gateways/{gateway_id}/profiles", response_class=HTMLResponse)
async def list_profiles(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_user_web)
):
"""List VPN profiles for a gateway."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
# Check access
if not current_user.is_admin and not any(
access.gateway_id == gateway_id for access in current_user.gateway_access
):
flash(request, "Zugriff verweigert", "danger")
return RedirectResponse(url="/gateways", status_code=303)
service = VPNProfileService(db)
profiles = service.get_profiles_for_gateway(gateway_id)
return request.app.state.templates.TemplateResponse(
"gateways/profiles.html",
{
"request": request,
"current_user": current_user,
"gateway": gateway,
"profiles": profiles,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/gateways/{gateway_id}/profiles/new", response_class=HTMLResponse)
async def new_profile_form(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""New VPN profile form."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
# Get available VPN servers
if current_user.is_super_admin:
servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
else:
servers = db.query(VPNServer).filter(
(VPNServer.tenant_id == current_user.tenant_id) |
(VPNServer.tenant_id == None),
VPNServer.is_active == True
).all()
# Calculate next priority
existing_profiles = db.query(VPNProfile).filter(
VPNProfile.gateway_id == gateway_id
).count()
next_priority = existing_profiles + 1
return request.app.state.templates.TemplateResponse(
"gateways/profile_form.html",
{
"request": request,
"current_user": current_user,
"gateway": gateway,
"profile": None,
"vpn_servers": servers,
"next_priority": next_priority,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/gateways/{gateway_id}/profiles/new")
async def create_profile(
request: Request,
gateway_id: int,
name: str = Form(...),
vpn_server_id: int = Form(...),
priority: int = Form(1),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Create new VPN profile."""
try:
service = VPNProfileService(db)
profile = service.create_profile(
gateway_id=gateway_id,
vpn_server_id=vpn_server_id,
name=name,
priority=priority,
description=description
)
flash(request, f"VPN-Profil '{name}' erstellt", "success")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/{profile.id}", status_code=303)
except ValueError as e:
flash(request, str(e), "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/new", status_code=303)
except Exception as e:
flash(request, f"Fehler: {str(e)}", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/new", status_code=303)
@router.get("/gateways/{gateway_id}/profiles/{profile_id}", response_class=HTMLResponse)
async def profile_detail(
request: Request,
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_user_web)
):
"""VPN profile detail page."""
profile = db.query(VPNProfile).filter(
VPNProfile.id == profile_id,
VPNProfile.gateway_id == gateway_id
).first()
if not profile:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
return request.app.state.templates.TemplateResponse(
"gateways/profile_detail.html",
{
"request": request,
"current_user": current_user,
"gateway": profile.gateway,
"profile": profile,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/gateways/{gateway_id}/profiles/{profile_id}/edit", response_class=HTMLResponse)
async def edit_profile_form(
request: Request,
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Edit VPN profile form."""
profile = db.query(VPNProfile).filter(
VPNProfile.id == profile_id,
VPNProfile.gateway_id == gateway_id
).first()
if not profile:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
# Get available VPN servers
if current_user.is_super_admin:
servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
else:
servers = db.query(VPNServer).filter(
(VPNServer.tenant_id == current_user.tenant_id) |
(VPNServer.tenant_id == None),
VPNServer.is_active == True
).all()
return request.app.state.templates.TemplateResponse(
"gateways/profile_form.html",
{
"request": request,
"current_user": current_user,
"gateway": profile.gateway,
"profile": profile,
"vpn_servers": servers,
"next_priority": profile.priority,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/gateways/{gateway_id}/profiles/{profile_id}/edit")
async def update_profile(
request: Request,
gateway_id: int,
profile_id: int,
name: str = Form(...),
priority: int = Form(1),
description: str = Form(None),
is_active: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Update VPN profile."""
profile = db.query(VPNProfile).filter(
VPNProfile.id == profile_id,
VPNProfile.gateway_id == gateway_id
).first()
if not profile:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
profile.name = name
profile.description = description
profile.priority = priority
profile.is_active = is_active is not None # Checkbox sends "on" when checked, None when not
db.commit()
flash(request, f"Profil '{name}' aktualisiert", "success")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/{profile_id}", status_code=303)
@router.get("/gateways/{gateway_id}/profiles/{profile_id}/provision")
async def provision_profile(
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_user_web)
):
"""Download OpenVPN config for a profile."""
service = VPNProfileService(db)
profile = service.get_profile_by_id(profile_id)
if not profile or profile.gateway_id != gateway_id:
return Response(status_code=404, content="Profile not found")
if not profile.is_ready:
return Response(status_code=400, content="Profile not ready for provisioning")
try:
config = service.provision_profile(profile)
filename = f"{profile.gateway.name}-{profile.name}.ovpn"
filename = filename.lower().replace(' ', '-')
return Response(
content=config,
media_type="application/x-openvpn-profile",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
except Exception as e:
return Response(status_code=500, content=str(e))
@router.post("/gateways/{gateway_id}/profiles/{profile_id}/priority")
async def update_priority(
request: Request,
gateway_id: int,
profile_id: int,
priority: int = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Update profile priority."""
service = VPNProfileService(db)
profile = service.get_profile_by_id(profile_id)
if not profile or profile.gateway_id != gateway_id:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
service.set_priority(profile, priority)
flash(request, "Priorität aktualisiert", "success")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
@router.post("/gateways/{gateway_id}/profiles/{profile_id}/revoke")
async def revoke_profile(
request: Request,
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Revoke profile certificate."""
service = VPNProfileService(db)
profile = service.get_profile_by_id(profile_id)
if not profile or profile.gateway_id != gateway_id:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
service.revoke_profile(profile)
flash(request, f"Zertifikat für '{profile.name}' widerrufen", "warning")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
@router.post("/gateways/{gateway_id}/profiles/{profile_id}/renew")
async def renew_profile(
request: Request,
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Renew profile certificate."""
service = VPNProfileService(db)
profile = service.get_profile_by_id(profile_id)
if not profile or profile.gateway_id != gateway_id:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
service.renew_profile(profile)
flash(request, f"Zertifikat für '{profile.name}' erneuert", "success")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/{profile_id}", status_code=303)
@router.post("/gateways/{gateway_id}/profiles/{profile_id}/delete")
async def delete_profile(
request: Request,
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Delete VPN profile."""
service = VPNProfileService(db)
profile = service.get_profile_by_id(profile_id)
if not profile or profile.gateway_id != gateway_id:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
name = profile.name
service.delete_profile(profile)
flash(request, f"Profil '{name}' gelöscht", "warning")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
@router.get("/gateways/{gateway_id}/provision-all")
async def provision_all_profiles(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_user_web)
):
"""Download all active profiles as a ZIP file."""
import io
import zipfile
service = VPNProfileService(db)
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
return Response(status_code=404, content="Gateway not found")
configs = service.generate_all_configs_for_gateway(gateway_id)
if not configs:
return Response(status_code=400, content="No active profiles available")
# Create ZIP file in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for filename, config in configs:
zip_file.writestr(filename, config)
zip_buffer.seek(0)
zip_filename = f"{gateway.name}-vpn-profiles.zip"
zip_filename = zip_filename.lower().replace(' ', '-')
return Response(
content=zip_buffer.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename={zip_filename}"}
)

View File

@ -0,0 +1,349 @@
"""VPN Server management web routes."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from typing import Optional
from ..database import get_db
from ..models.user import User
from ..models.vpn_server import VPNServer, VPNProtocol, VPNCipher, VPNAuth, VPNCompression
from ..models.certificate_authority import CertificateAuthority, CAStatus
from ..services.vpn_server_service import VPNServerService
from .deps import require_admin_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/vpn-servers", response_class=HTMLResponse)
async def list_vpn_servers(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""List all VPN servers."""
service = VPNServerService(db)
if current_user.is_super_admin:
servers = db.query(VPNServer).all()
else:
servers = service.get_servers_for_tenant(current_user.tenant_id)
return request.app.state.templates.TemplateResponse(
"vpn_servers/list.html",
{
"request": request,
"current_user": current_user,
"servers": servers,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/vpn-servers/new", response_class=HTMLResponse)
async def new_vpn_server_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""New VPN server form."""
# Get available CAs
if current_user.is_super_admin:
cas = db.query(CertificateAuthority).filter(
CertificateAuthority.status == CAStatus.ACTIVE
).all()
else:
cas = db.query(CertificateAuthority).filter(
(CertificateAuthority.tenant_id == current_user.tenant_id) |
(CertificateAuthority.tenant_id == None),
CertificateAuthority.status == CAStatus.ACTIVE
).all()
return request.app.state.templates.TemplateResponse(
"vpn_servers/form.html",
{
"request": request,
"current_user": current_user,
"server": None,
"cas": cas,
"protocols": VPNProtocol,
"ciphers": VPNCipher,
"auth_methods": VPNAuth,
"compression_options": VPNCompression,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/vpn-servers/new")
async def create_vpn_server(
request: Request,
name: str = Form(...),
hostname: str = Form(...),
ca_id: int = Form(...),
port: int = Form(1194),
protocol: str = Form("udp"),
vpn_network: str = Form("10.8.0.0"),
vpn_netmask: str = Form("255.255.255.0"),
cipher: str = Form("AES-256-GCM"),
auth: str = Form("SHA256"),
tls_version_min: str = Form("1.2"),
compression: str = Form("none"),
max_clients: int = Form(100),
keepalive_interval: int = Form(10),
keepalive_timeout: int = Form(60),
management_port: int = Form(7505),
is_primary: bool = Form(False),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Create new VPN server."""
try:
service = VPNServerService(db)
tenant_id = None if current_user.is_super_admin else current_user.tenant_id
server = service.create_server(
name=name,
hostname=hostname,
ca_id=ca_id,
port=port,
protocol=VPNProtocol(protocol),
vpn_network=vpn_network,
vpn_netmask=vpn_netmask,
cipher=VPNCipher(cipher),
auth=VPNAuth(auth),
tls_version_min=tls_version_min,
compression=VPNCompression(compression),
max_clients=max_clients,
keepalive_interval=keepalive_interval,
keepalive_timeout=keepalive_timeout,
management_port=management_port,
is_primary=is_primary,
tenant_id=tenant_id,
description=description
)
flash(request, f"VPN-Server '{name}' erstellt", "success")
return RedirectResponse(url=f"/vpn-servers/{server.id}", status_code=303)
except ValueError as e:
flash(request, str(e), "danger")
return RedirectResponse(url="/vpn-servers/new", status_code=303)
except Exception as e:
flash(request, f"Fehler beim Erstellen: {str(e)}", "danger")
return RedirectResponse(url="/vpn-servers/new", status_code=303)
@router.get("/vpn-servers/{server_id}", response_class=HTMLResponse)
async def vpn_server_detail(
request: Request,
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""VPN server detail page."""
service = VPNServerService(db)
server = service.get_server_by_id(server_id)
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
# Check access
if not current_user.is_super_admin and server.tenant_id != current_user.tenant_id and server.tenant_id is not None:
flash(request, "Zugriff verweigert", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
# Get status
status = service.get_server_status(server)
return request.app.state.templates.TemplateResponse(
"vpn_servers/detail.html",
{
"request": request,
"current_user": current_user,
"server": server,
"status": status,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/vpn-servers/{server_id}/edit", response_class=HTMLResponse)
async def edit_vpn_server_form(
request: Request,
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Edit VPN server form."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
# Get available CAs
cas = db.query(CertificateAuthority).filter(
CertificateAuthority.status == CAStatus.ACTIVE
).all()
return request.app.state.templates.TemplateResponse(
"vpn_servers/form.html",
{
"request": request,
"current_user": current_user,
"server": server,
"cas": cas,
"protocols": VPNProtocol,
"ciphers": VPNCipher,
"auth_methods": VPNAuth,
"compression_options": VPNCompression,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/vpn-servers/{server_id}/edit")
async def update_vpn_server(
request: Request,
server_id: int,
name: str = Form(...),
hostname: str = Form(...),
port: int = Form(1194),
protocol: str = Form("udp"),
vpn_network: str = Form("10.8.0.0"),
vpn_netmask: str = Form("255.255.255.0"),
cipher: str = Form("AES-256-GCM"),
auth: str = Form("SHA256"),
tls_version_min: str = Form("1.2"),
compression: str = Form("none"),
max_clients: int = Form(100),
keepalive_interval: int = Form(10),
keepalive_timeout: int = Form(60),
management_port: int = Form(7505),
is_primary: bool = Form(False),
is_active: bool = Form(True),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Update VPN server."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
# Update fields
server.name = name
server.description = description
server.hostname = hostname
server.port = port
server.protocol = VPNProtocol(protocol)
server.vpn_network = vpn_network
server.vpn_netmask = vpn_netmask
server.cipher = VPNCipher(cipher)
server.auth = VPNAuth(auth)
server.tls_version_min = tls_version_min
server.compression = VPNCompression(compression)
server.max_clients = max_clients
server.keepalive_interval = keepalive_interval
server.keepalive_timeout = keepalive_timeout
server.management_port = management_port
server.is_primary = is_primary
server.is_active = is_active
# If setting as primary, unset other primaries
if is_primary:
db.query(VPNServer).filter(
VPNServer.tenant_id == server.tenant_id,
VPNServer.id != server.id
).update({"is_primary": False})
db.commit()
flash(request, "VPN-Server aktualisiert", "success")
return RedirectResponse(url=f"/vpn-servers/{server_id}", status_code=303)
@router.get("/vpn-servers/{server_id}/clients", response_class=HTMLResponse)
async def vpn_server_clients(
request: Request,
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Show connected clients for a VPN server."""
service = VPNServerService(db)
server = service.get_server_by_id(server_id)
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
clients = service.get_connected_clients(server)
return request.app.state.templates.TemplateResponse(
"vpn_servers/clients.html",
{
"request": request,
"current_user": current_user,
"server": server,
"clients": clients,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/vpn-servers/{server_id}/disconnect/{common_name}")
async def disconnect_client(
request: Request,
server_id: int,
common_name: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Disconnect a client from VPN server."""
service = VPNServerService(db)
server = service.get_server_by_id(server_id)
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
if service.disconnect_client(server, common_name):
flash(request, f"Client '{common_name}' getrennt", "success")
else:
flash(request, f"Konnte Client '{common_name}' nicht trennen", "danger")
return RedirectResponse(url=f"/vpn-servers/{server_id}/clients", status_code=303)
@router.post("/vpn-servers/{server_id}/delete")
async def delete_vpn_server(
request: Request,
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Delete VPN server."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
# Check if server has profiles
if server.vpn_profiles:
flash(request, "Server hat noch VPN-Profile. Bitte zuerst löschen.", "danger")
return RedirectResponse(url=f"/vpn-servers/{server_id}", status_code=303)
name = server.name
db.delete(server)
db.commit()
flash(request, f"VPN-Server '{name}' gelöscht", "warning")
return RedirectResponse(url="/vpn-servers", status_code=303)

8
server/init.sql Normal file
View File

@ -0,0 +1,8 @@
-- Initial database schema for mGuard VPN Endpoint Server
-- This file is executed on first container startup
-- Ensure proper character encoding
ALTER DATABASE mguard_vpn CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Note: Tables are created by SQLAlchemy/Alembic
-- This file can be used for initial data or stored procedures if needed

31
server/requirements.txt Normal file
View File

@ -0,0 +1,31 @@
# FastAPI and Server
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# Templates (Web UI)
jinja2==3.1.3
itsdangerous==2.1.2 # Session signing
aiofiles==23.2.1 # Static files
# Database
sqlalchemy==2.0.25
pymysql==1.1.0
alembic==1.13.1
# Authentication
python-jose[cryptography]==3.3.0
bcrypt==4.1.2
cryptography==42.0.0 # PKI/Certificate management
# Validation
pydantic==2.5.3
pydantic-settings==2.1.0
email-validator==2.1.0
# OpenVPN Management
# (Custom implementation, no external package needed)
# Utilities
python-dotenv==1.0.0
httpx==0.26.0