first commit
This commit is contained in:
commit
6901dc369b
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
|
@ -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'
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""Client services."""
|
||||
|
||||
from .api_client import APIClient
|
||||
from .vpn_manager import VPNManager
|
||||
|
||||
__all__ = ["APIClient", "VPNManager"]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""UI components."""
|
||||
|
||||
from .main_window import MainWindow
|
||||
from .login_dialog import LoginDialog
|
||||
|
||||
__all__ = ["MainWindow", "LoginDialog"]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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={})
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# mGuard VPN Endpoint Server
|
||||
__version__ = "1.0.0"
|
||||
|
|
@ -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"])
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"},
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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}')>"
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
© 2024
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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"
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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"])
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
|
@ -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})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 <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
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
|
@ -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}"}
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue