readme refreshed
This commit is contained in:
parent
1de7f5b593
commit
14b770af55
317
README.md
317
README.md
|
|
@ -86,6 +86,323 @@ Datenbank OpenVPN Container
|
||||||
└─────────────────────┘ └─────────────────────┘
|
└─────────────────────┘ └─────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbindungsweg: Client → Server → Gateway → Endpunkt
|
||||||
|
|
||||||
|
Diese Sektion erklärt den vollständigen Datenweg, wenn ein Techniker (Service-PC) auf ein Gerät (z.B. SPS, HMI) hinter einem Gateway zugreift.
|
||||||
|
|
||||||
|
### Übersicht
|
||||||
|
|
||||||
|
```
|
||||||
|
Techniker-Netz Internet Kunden-Netz
|
||||||
|
192.168.178.0/24 192.168.0.0/24
|
||||||
|
|
||||||
|
┌─────────────────┐ ┌──────────────────────────────┐ ┌─────────────────┐ ┌─────────────┐
|
||||||
|
│ Service-PC │ │ VPN-Server │ │ Gateway │ │ Endpunkt │
|
||||||
|
│ (Techniker) │ │ (Docker Host) │ │ (mGuard) │ │ (SPS) │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ LAN: 192.168. │ │ ┌────────────────────────┐ │ │ VPN: 10.8.0.100 │ │ 192.168.0.3 │
|
||||||
|
│ 178.100 │ │ │ OpenVPN Server │ │ │ │ │ Port 11740 │
|
||||||
|
│ GW: 192.168. │◄────►│ │ VPN-Netz: 10.8.0.0/24 │ │◄────►│ LAN: 192.168. │◄────►│ (CoDeSys) │
|
||||||
|
│ 178.1 │ │ │ Public: 85.16.65.177 │ │ │ 0.100 │ │ │
|
||||||
|
│ │ │ └────────────────────────┘ │ │ │ │ │
|
||||||
|
│ VPN: 10.8.0.50 │ │ │ │ │ │ │
|
||||||
|
└─────────────────┘ └──────────────────────────────┘ └─────────────────┘ └─────────────┘
|
||||||
|
│ │ │ │
|
||||||
|
│ VPN-Tunnel │ VPN-Tunnel │ Lokales Netz │
|
||||||
|
│ (verschlüsselt) │ (verschlüsselt) │ 192.168.0.0/24 │
|
||||||
|
└──────────────────────────────┴───────────────────────────────┴───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Netzwerk-Adressen im Beispiel
|
||||||
|
|
||||||
|
| Komponente | Interface | IP-Adresse | Beschreibung |
|
||||||
|
|------------|-----------|------------|--------------|
|
||||||
|
| **Service-PC** | eth0/wlan0 | 192.168.178.100/24 | Lokales Netz des Technikers |
|
||||||
|
| **Service-PC** | (Gateway) | 192.168.178.1 | Fritz!Box/Router ins Internet |
|
||||||
|
| **Service-PC** | tun0 | 10.8.0.50 | VPN-IP (vom Server zugewiesen) |
|
||||||
|
| **VPN-Server** | eth0 | 85.16.65.177 | Öffentliche IP |
|
||||||
|
| **VPN-Server** | tun0 | 10.8.0.1 | VPN-Gateway |
|
||||||
|
| **Gateway** | tun0 | 10.8.0.100 | VPN-IP des Gateways |
|
||||||
|
| **Gateway** | eth0 | 192.168.0.100/24 | LAN-Interface beim Kunden |
|
||||||
|
| **Endpunkt (SPS)** | eth0 | 192.168.0.3 | CoDeSys-SPS im Kunden-LAN |
|
||||||
|
|
||||||
|
### Schritt-für-Schritt Verbindungsablauf
|
||||||
|
|
||||||
|
#### 1. Techniker meldet sich im Desktop-Client an
|
||||||
|
|
||||||
|
```
|
||||||
|
Service-PC VPN-Server (API)
|
||||||
|
│ │
|
||||||
|
│ POST /api/auth/login │
|
||||||
|
│ {username, password} │
|
||||||
|
│ ─────────────────────────────────────► │
|
||||||
|
│ │
|
||||||
|
│ JWT Token │
|
||||||
|
│ ◄───────────────────────────────────── │
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Techniker wählt Gateway und Endpunkt
|
||||||
|
|
||||||
|
```
|
||||||
|
Service-PC VPN-Server (API)
|
||||||
|
│ │
|
||||||
|
│ GET /api/gateways │
|
||||||
|
│ ─────────────────────────────────────► │
|
||||||
|
│ │
|
||||||
|
│ Liste der zugewiesenen Gateways │
|
||||||
|
│ + Online-Status │
|
||||||
|
│ ◄───────────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ POST /api/connections/connect │
|
||||||
|
│ {gateway_id, endpoint_id} │
|
||||||
|
│ ─────────────────────────────────────► │
|
||||||
|
│ │
|
||||||
|
│ VPN-Konfiguration (.ovpn Inhalt) │
|
||||||
|
│ + Ziel-IP und Port │
|
||||||
|
│ ◄───────────────────────────────────── │
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. VPN-Tunnel wird aufgebaut
|
||||||
|
|
||||||
|
```
|
||||||
|
Service-PC OpenVPN-Server Gateway (mGuard)
|
||||||
|
│ │ │
|
||||||
|
│ UDP/TCP Handshake │ │
|
||||||
|
│ ────────────────────────────►│ │
|
||||||
|
│ │ │
|
||||||
|
│ TLS-Authentifizierung │ │
|
||||||
|
│ (Client-Zertifikat) │ │
|
||||||
|
│ ◄───────────────────────────►│ │
|
||||||
|
│ │ │
|
||||||
|
│ VPN-IP zugewiesen: │ │
|
||||||
|
│ 10.8.0.50 │ │
|
||||||
|
│ ◄────────────────────────── │ │
|
||||||
|
│ │ │
|
||||||
|
│ Tunnel aktiv │ Tunnel aktiv │
|
||||||
|
│ ════════════════════════════ │ ═══════════════════════ │
|
||||||
|
│ │ 10.8.0.100 │
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Routing zum Endpunkt
|
||||||
|
|
||||||
|
```
|
||||||
|
Service-PC (10.8.0.50) OpenVPN-Server Gateway (10.8.0.100) SPS (192.168.0.3)
|
||||||
|
│ │ │ │
|
||||||
|
│ Paket an 192.168.0.3:11740 │ │ │
|
||||||
|
│ ══════════════════════════════►│ │ │
|
||||||
|
│ (durch VPN-Tunnel) │ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Routing-Entscheidung: │ │
|
||||||
|
│ │ 192.168.0.0/24→10.8.0.100 │ │
|
||||||
|
│ │ (via iroute im CCD) │ │
|
||||||
|
│ │ ══════════════════════════►│ │
|
||||||
|
│ │ (durch VPN-Tunnel) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ IP-Forwarding + NAT │
|
||||||
|
│ │ │ ────────────────────────►│
|
||||||
|
│ │ │ (eth0: 192.168.0.100) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ Antwort │
|
||||||
|
│ │ │ ◄────────────────────────│
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ◄══════════════════════════│ │
|
||||||
|
│ ◄══════════════════════════════│ │ │
|
||||||
|
│ │ │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routing-Konfiguration im Detail
|
||||||
|
|
||||||
|
#### Server-Seite (OpenVPN-Server)
|
||||||
|
|
||||||
|
Der VPN-Server muss wissen, welche Subnetze über welchen Client (Gateway) erreichbar sind:
|
||||||
|
|
||||||
|
**server.conf (automatisch generiert):**
|
||||||
|
```
|
||||||
|
# VPN-Netzwerk für alle Clients
|
||||||
|
server 10.8.0.0 255.255.255.0
|
||||||
|
|
||||||
|
# Route für Kunden-Subnetz 192.168.0.0/24 → Gateway
|
||||||
|
route 192.168.0.0 255.255.255.0 # Kernel-Route
|
||||||
|
|
||||||
|
# Client-Config-Directory für iroute-Direktiven
|
||||||
|
client-config-dir /etc/openvpn/ccd
|
||||||
|
```
|
||||||
|
|
||||||
|
**CCD-Datei für Gateway (z.B. `/etc/openvpn/ccd/gw-kunde-abc-1`):**
|
||||||
|
```
|
||||||
|
# Traffic für Kunden-Subnetz 192.168.0.0/24 geht durch diesen Client
|
||||||
|
iroute 192.168.0.0 255.255.255.0
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Wichtig:** `route` sagt dem Kernel wohin, `iroute` sagt OpenVPN wohin.
|
||||||
|
> Beide sind erforderlich damit das Routing funktioniert!
|
||||||
|
|
||||||
|
#### Gateway-Seite (mGuard/Linux)
|
||||||
|
|
||||||
|
Das Gateway muss als Router fungieren und Traffic weiterleiten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# IP-Forwarding aktivieren
|
||||||
|
echo 1 > /proc/sys/net/ipv4/ip_forward
|
||||||
|
|
||||||
|
# NAT/Masquerading für VPN-Traffic ins Kunden-LAN (192.168.0.0/24)
|
||||||
|
iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE
|
||||||
|
|
||||||
|
# Forwarding erlauben: VPN (tun0) ↔ Kunden-LAN (eth0)
|
||||||
|
iptables -A FORWARD -i tun0 -o eth0 -j ACCEPT
|
||||||
|
iptables -A FORWARD -i eth0 -o tun0 -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:** Das Gateway hat zwei Interfaces:
|
||||||
|
- `tun0` (10.8.0.100) - VPN-Tunnel zum Server
|
||||||
|
- `eth0` (192.168.0.100) - Kunden-LAN mit der SPS
|
||||||
|
|
||||||
|
### Split Tunneling (Client-Seite)
|
||||||
|
|
||||||
|
Der Desktop-Client verwendet **Split Tunneling**, damit:
|
||||||
|
- VPN-Traffic (Gateways, Endpunkte) durch den Tunnel geht
|
||||||
|
- Internet-Traffic **nicht** durch den Tunnel geht
|
||||||
|
|
||||||
|
**Client-Konfiguration (.ovpn):**
|
||||||
|
```
|
||||||
|
# Redirect-Gateway vom Server ignorieren
|
||||||
|
pull-filter ignore "redirect-gateway"
|
||||||
|
|
||||||
|
# Nur VPN-Netzwerk routen
|
||||||
|
route 10.8.0.0 255.255.255.0
|
||||||
|
|
||||||
|
# Nur Kunden-Subnetze routen (automatisch hinzugefügt basierend auf Gateway-Zugriff)
|
||||||
|
route 192.168.0.0 255.255.255.0 # Kunde mit SPS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resultat auf dem Service-PC:**
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Service-PC │
|
||||||
|
│ LAN: 192.168.178.100 │
|
||||||
|
│ VPN: 10.8.0.50 │
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
┌──────────────┴──────────────┐
|
||||||
|
│ │
|
||||||
|
Ziel: 10.8.0.x Ziel: 8.8.8.8
|
||||||
|
Ziel: 192.168.0.x (Internet)
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────┐ ┌───────────────┐
|
||||||
|
│ VPN-Tunnel │ │ Fritz!Box │
|
||||||
|
│ (tun0) │ │ 192.168.178.1 │
|
||||||
|
└───────────────┘ └───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Routing-Tabelle auf dem Service-PC (nach VPN-Verbindung):**
|
||||||
|
```
|
||||||
|
$ ip route
|
||||||
|
default via 192.168.178.1 dev eth0 # Internet → Fritz!Box
|
||||||
|
10.8.0.0/24 via 10.8.0.1 dev tun0 # VPN-Netz → Tunnel
|
||||||
|
192.168.0.0/24 via 10.8.0.1 dev tun0 # Kunden-Netz → Tunnel
|
||||||
|
192.168.178.0/24 dev eth0 src 192.168.178.100 # Lokales Netz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel: Kompletter Verbindungsweg
|
||||||
|
|
||||||
|
**Szenario:** Techniker will auf CoDeSys (Port 11740) auf einer SPS zugreifen.
|
||||||
|
|
||||||
|
| Komponente | Lokale IP | VPN-IP | Rolle |
|
||||||
|
|------------|-----------|--------|-------|
|
||||||
|
| Service-PC | 192.168.178.100 | 10.8.0.50 | Techniker-Laptop mit Desktop-Client |
|
||||||
|
| Fritz!Box | 192.168.178.1 | - | Internet-Gateway des Technikers |
|
||||||
|
| VPN-Server | 85.16.65.177:1199 | 10.8.0.1 | Docker-Host mit OpenVPN |
|
||||||
|
| Gateway | 192.168.0.100 | 10.8.0.100 | mGuard/Linux beim Kunden |
|
||||||
|
| SPS (Endpunkt) | 192.168.0.3:11740 | - | CoDeSys-SPS im Kunden-LAN |
|
||||||
|
|
||||||
|
**Paketweg (Hinrichtung):**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Techniker öffnet CoDeSys, verbindet zu 192.168.0.3:11740
|
||||||
|
|
||||||
|
2. Service-PC prüft Routing-Tabelle:
|
||||||
|
192.168.0.0/24 → tun0 (VPN-Tunnel)
|
||||||
|
✓ Ziel 192.168.0.3 matcht → geht durch Tunnel
|
||||||
|
|
||||||
|
3. Paket wird verschlüsselt und durch VPN-Tunnel gesendet:
|
||||||
|
Inner: Src: 10.8.0.50 Dst: 192.168.0.3:11740
|
||||||
|
Outer: Src: 192.168.178.100 Dst: 85.16.65.177:1199 (UDP)
|
||||||
|
|
||||||
|
4. Paket geht über Fritz!Box (192.168.178.1) ins Internet
|
||||||
|
|
||||||
|
5. VPN-Server (85.16.65.177) empfängt, entschlüsselt, prüft Routing:
|
||||||
|
- route 192.168.0.0/24 → existiert (Kernel weiß Bescheid)
|
||||||
|
- iroute 192.168.0.0/24 → OpenVPN weiß: geht an Client "gw-xxx"
|
||||||
|
|
||||||
|
6. Server verschlüsselt und leitet an Gateway weiter (durch dessen Tunnel):
|
||||||
|
Src: 10.8.0.50 Dst: 192.168.0.3
|
||||||
|
|
||||||
|
7. Gateway (10.8.0.100) empfängt auf tun0, leitet weiter:
|
||||||
|
- IP-Forwarding aktiv
|
||||||
|
- NAT/Masquerade: Src-IP wird zu 192.168.0.100 (Gateway's LAN-IP)
|
||||||
|
- Paket geht raus auf eth0 (192.168.0.100) ins Kunden-LAN
|
||||||
|
|
||||||
|
8. SPS (192.168.0.3) empfängt Anfrage von 192.168.0.100, antwortet
|
||||||
|
|
||||||
|
9. Rückweg:
|
||||||
|
- SPS antwortet an 192.168.0.100 (Gateway)
|
||||||
|
- Gateway's NAT übersetzt zurück zu 10.8.0.50
|
||||||
|
- Paket geht durch VPN-Tunnel zurück zum Server
|
||||||
|
- Server leitet an Service-PC weiter
|
||||||
|
- CoDeSys empfängt Antwort
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zusammenfassung der IP-Adressen im Paket:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Service-PC VPN-Server Gateway SPS
|
||||||
|
192.168.178.100 85.16.65.177 192.168.0.100 192.168.0.3
|
||||||
|
│ │ │ │
|
||||||
|
│ Outer: 192.168.178.100 │ │ │
|
||||||
|
│ → 85.16.65.177 │ │ │
|
||||||
|
│ Inner: 10.8.0.50 │ │ │
|
||||||
|
│ → 192.168.0.3 │ │ │
|
||||||
|
│═════════════════════════│ │ │
|
||||||
|
│ (verschlüsselt) │ Inner: 10.8.0.50 │ │
|
||||||
|
│ │ → 192.168.0.3 │ │
|
||||||
|
│ │════════════════════════│ │
|
||||||
|
│ │ (verschlüsselt) │ NAT: 192.168.0.100 │
|
||||||
|
│ │ │ → 192.168.0.3 │
|
||||||
|
│ │ │───────────────────────│
|
||||||
|
│ │ │ (unverschlüsselt) │
|
||||||
|
```
|
||||||
|
|
||||||
|
### Firewall-Regeln (automatisch)
|
||||||
|
|
||||||
|
Der VPN-Server kann dynamisch Firewall-Regeln erstellen, um Zugriffe zu kontrollieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Wenn Techniker sich verbindet und Endpunkt anfordert:
|
||||||
|
iptables -A MGUARD_VPN -s 10.8.0.50 -d 192.168.0.3 -p tcp --dport 11740 -j ACCEPT
|
||||||
|
iptables -A MGUARD_VPN -s 192.168.0.3 -d 10.8.0.50 -p tcp --sport 11740 -j ACCEPT
|
||||||
|
|
||||||
|
# Wenn Techniker trennt:
|
||||||
|
iptables -D MGUARD_VPN ... (Regeln entfernen)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zusammenfassung der Netzwerk-Komponenten
|
||||||
|
|
||||||
|
| Schicht | Komponente | Funktion |
|
||||||
|
|---------|------------|----------|
|
||||||
|
| **Client** | Desktop-Client | Login, Gateway-Auswahl, OpenVPN starten |
|
||||||
|
| **Client** | OpenVPN (Client) | VPN-Tunnel aufbauen, Split-Tunneling |
|
||||||
|
| **Server** | OpenVPN (Server) | Tunnel-Endpunkt, Routing zwischen Clients |
|
||||||
|
| **Server** | CCD-Dateien | iroute-Direktiven für Gateway-Subnetze |
|
||||||
|
| **Gateway** | OpenVPN (Client) | Permanenter VPN-Tunnel zum Server |
|
||||||
|
| **Gateway** | IP-Forwarding | Pakete zwischen tun0 und eth0 weiterleiten |
|
||||||
|
| **Gateway** | NAT/Masquerade | Source-IP umschreiben für Rückweg |
|
||||||
|
| **Endpunkt** | Zielgerät | SPS, HMI, oder anderes Gerät im Kunden-LAN |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### PKI-Verwaltung über Web-UI
|
### PKI-Verwaltung über Web-UI
|
||||||
|
|
||||||
Die gesamte PKI (Zertifizierungsstellen, Zertifikate, VPN-Server) wird über die Web-Oberfläche verwaltet:
|
Die gesamte PKI (Zertifizierungsstellen, Zertifikate, VPN-Server) wird über die Web-Oberfläche verwaltet:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Client configuration."""
|
"""Client configuration."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Application info
|
# Application info
|
||||||
|
|
@ -10,13 +11,43 @@ APP_VERSION = "1.0.0"
|
||||||
# Default server settings
|
# Default server settings
|
||||||
DEFAULT_SERVER_URL = "http://localhost:8000"
|
DEFAULT_SERVER_URL = "http://localhost:8000"
|
||||||
|
|
||||||
# OpenVPN paths
|
# OpenVPN binary search paths by platform
|
||||||
|
OPENVPN_PATHS_WINDOWS = [
|
||||||
|
r"C:\Program Files\OpenVPN\bin\openvpn.exe",
|
||||||
|
r"C:\Program Files (x86)\OpenVPN\bin\openvpn.exe",
|
||||||
|
r"C:\Program Files\OpenVPN Connect\ovpnconnector.exe",
|
||||||
|
]
|
||||||
|
|
||||||
|
OPENVPN_PATHS_LINUX = [
|
||||||
|
"/usr/sbin/openvpn",
|
||||||
|
"/usr/bin/openvpn",
|
||||||
|
"/usr/local/sbin/openvpn",
|
||||||
|
"/usr/local/bin/openvpn",
|
||||||
|
"/opt/openvpn/sbin/openvpn",
|
||||||
|
"/snap/bin/openvpn",
|
||||||
|
]
|
||||||
|
|
||||||
|
OPENVPN_PATHS_MACOS = [
|
||||||
|
"/usr/local/sbin/openvpn",
|
||||||
|
"/usr/local/bin/openvpn",
|
||||||
|
"/opt/homebrew/bin/openvpn",
|
||||||
|
"/opt/homebrew/sbin/openvpn",
|
||||||
|
"/usr/local/opt/openvpn/sbin/openvpn",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Determine platform
|
||||||
if os.name == 'nt': # Windows
|
if os.name == 'nt': # Windows
|
||||||
OPENVPN_EXE = r"C:\Program Files\OpenVPN\bin\openvpn.exe"
|
OPENVPN_SEARCH_PATHS = OPENVPN_PATHS_WINDOWS
|
||||||
OPENVPN_CONFIG_DIR = Path.home() / "OpenVPN" / "config"
|
OPENVPN_CONFIG_DIR = Path.home() / "OpenVPN" / "config"
|
||||||
else: # Linux/Mac
|
elif sys.platform == 'darwin': # macOS
|
||||||
OPENVPN_EXE = "/usr/sbin/openvpn"
|
OPENVPN_SEARCH_PATHS = OPENVPN_PATHS_MACOS
|
||||||
OPENVPN_CONFIG_DIR = Path.home() / ".openvpn"
|
OPENVPN_CONFIG_DIR = Path.home() / ".openvpn"
|
||||||
|
else: # Linux
|
||||||
|
OPENVPN_SEARCH_PATHS = OPENVPN_PATHS_LINUX
|
||||||
|
OPENVPN_CONFIG_DIR = Path.home() / ".openvpn"
|
||||||
|
|
||||||
|
# Legacy variable for compatibility
|
||||||
|
OPENVPN_EXE = OPENVPN_SEARCH_PATHS[0] if OPENVPN_SEARCH_PATHS else "openvpn"
|
||||||
|
|
||||||
# Ensure config directory exists
|
# Ensure config directory exists
|
||||||
OPENVPN_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
OPENVPN_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
"""OpenVPN process management."""
|
"""OpenVPN process management."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from config import OPENVPN_EXE, OPENVPN_CONFIG_DIR
|
from config import OPENVPN_SEARCH_PATHS, OPENVPN_CONFIG_DIR
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -25,27 +27,61 @@ class VPNManager:
|
||||||
self.process: Optional[subprocess.Popen] = None
|
self.process: Optional[subprocess.Popen] = None
|
||||||
self.config_file: Optional[Path] = None
|
self.config_file: Optional[Path] = None
|
||||||
self.log_file: Optional[Path] = None
|
self.log_file: Optional[Path] = None
|
||||||
|
self._openvpn_path: Optional[str] = None
|
||||||
|
|
||||||
|
def find_openvpn_binary(self) -> Optional[str]:
|
||||||
|
"""Find the OpenVPN binary on the system.
|
||||||
|
|
||||||
|
Searches platform-specific paths and returns the first found binary.
|
||||||
|
Returns None if not found.
|
||||||
|
"""
|
||||||
|
if self._openvpn_path:
|
||||||
|
return self._openvpn_path
|
||||||
|
|
||||||
|
# First, check configured search paths
|
||||||
|
for path in OPENVPN_SEARCH_PATHS:
|
||||||
|
if Path(path).exists() and Path(path).is_file():
|
||||||
|
self._openvpn_path = path
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Try to find via PATH environment (works on all platforms)
|
||||||
|
openvpn_in_path = shutil.which("openvpn")
|
||||||
|
if openvpn_in_path:
|
||||||
|
self._openvpn_path = openvpn_in_path
|
||||||
|
return openvpn_in_path
|
||||||
|
|
||||||
|
# macOS: Check for Tunnelblick's bundled OpenVPN
|
||||||
|
if sys.platform == 'darwin':
|
||||||
|
tunnelblick_base = Path("/Applications/Tunnelblick.app/Contents/Resources/openvpn")
|
||||||
|
if tunnelblick_base.exists():
|
||||||
|
# Find latest openvpn version directory
|
||||||
|
for version_dir in sorted(tunnelblick_base.glob("openvpn-*"), reverse=True):
|
||||||
|
openvpn_binary = version_dir / "openvpn"
|
||||||
|
if openvpn_binary.exists():
|
||||||
|
self._openvpn_path = str(openvpn_binary)
|
||||||
|
return str(openvpn_binary)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def check_openvpn_installed(self) -> bool:
|
def check_openvpn_installed(self) -> bool:
|
||||||
"""Check if OpenVPN is installed."""
|
"""Check if OpenVPN is installed."""
|
||||||
if os.name == 'nt':
|
return self.find_openvpn_binary() is not None
|
||||||
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:
|
def connect(self, config_content: str) -> VPNStatus:
|
||||||
"""Connect using provided OpenVPN config."""
|
"""Connect using provided OpenVPN config."""
|
||||||
if self.process and self.process.poll() is None:
|
if self.process and self.process.poll() is None:
|
||||||
return VPNStatus(connected=False, error="Already connected")
|
return VPNStatus(connected=False, error="Already connected")
|
||||||
|
|
||||||
if not self.check_openvpn_installed():
|
openvpn_binary = self.find_openvpn_binary()
|
||||||
|
if not openvpn_binary:
|
||||||
return VPNStatus(
|
return VPNStatus(
|
||||||
connected=False,
|
connected=False,
|
||||||
error="OpenVPN is not installed. Please install OpenVPN first."
|
error="OpenVPN is not installed. Please install OpenVPN first.\n\n"
|
||||||
|
"Installation:\n"
|
||||||
|
" Linux (Debian/Ubuntu): sudo apt install openvpn\n"
|
||||||
|
" Linux (Fedora/RHEL): sudo dnf install openvpn\n"
|
||||||
|
" macOS: brew install openvpn\n"
|
||||||
|
" Windows: Download from https://openvpn.net/community-downloads/"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write config to temp file
|
# Write config to temp file
|
||||||
|
|
@ -57,16 +93,15 @@ class VPNManager:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
# Windows: Use OpenVPN GUI or direct call
|
# Windows: Direct call (requires admin privileges)
|
||||||
# Note: Requires admin privileges
|
|
||||||
self.process = subprocess.Popen(
|
self.process = subprocess.Popen(
|
||||||
[OPENVPN_EXE, "--config", str(self.config_file), "--log", str(self.log_file)],
|
[openvpn_binary, "--config", str(self.config_file), "--log", str(self.log_file)],
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW
|
creationflags=subprocess.CREATE_NO_WINDOW
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Linux: Use sudo openvpn
|
# Linux/macOS: Use sudo openvpn
|
||||||
self.process = subprocess.Popen(
|
self.process = subprocess.Popen(
|
||||||
["sudo", "openvpn", "--config", str(self.config_file), "--log", str(self.log_file)],
|
["sudo", openvpn_binary, "--config", str(self.config_file), "--log", str(self.log_file)],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL
|
stderr=subprocess.DEVNULL
|
||||||
)
|
)
|
||||||
|
|
@ -142,3 +177,13 @@ class VPNManager:
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""Check if VPN is connected."""
|
"""Check if VPN is connected."""
|
||||||
return self.process is not None and self.process.poll() is None
|
return self.process is not None and self.process.poll() is None
|
||||||
|
|
||||||
|
def get_openvpn_info(self) -> dict:
|
||||||
|
"""Get information about the OpenVPN installation."""
|
||||||
|
binary = self.find_openvpn_binary()
|
||||||
|
return {
|
||||||
|
"installed": binary is not None,
|
||||||
|
"path": binary,
|
||||||
|
"platform": sys.platform,
|
||||||
|
"search_paths": OPENVPN_SEARCH_PATHS
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,34 @@ fetch_active_servers() {
|
||||||
curl -sf "$API_URL/vpn-servers/active" 2>/dev/null || echo "[]"
|
curl -sf "$API_URL/vpn-servers/active" 2>/dev/null || echo "[]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fetch CCD files for a server
|
||||||
|
# =============================================================================
|
||||||
|
fetch_ccd_files() {
|
||||||
|
local server_id="$1"
|
||||||
|
local ccd_dir="$2"
|
||||||
|
|
||||||
|
log " Fetching CCD files for server $server_id..."
|
||||||
|
|
||||||
|
# Get all CCD files from API
|
||||||
|
local ccd_json
|
||||||
|
ccd_json=$(curl -sf "$API_URL/vpn-servers/$server_id/ccd" 2>/dev/null || echo '{"files":{}}')
|
||||||
|
|
||||||
|
# Parse and create CCD files
|
||||||
|
echo "$ccd_json" | jq -r '.files | to_entries[] | @base64' | while read -r entry; do
|
||||||
|
local cn=$(echo "$entry" | base64 -d | jq -r '.key')
|
||||||
|
local content=$(echo "$entry" | base64 -d | jq -r '.value')
|
||||||
|
|
||||||
|
if [ -n "$cn" ] && [ "$cn" != "null" ]; then
|
||||||
|
echo "$content" > "$ccd_dir/$cn"
|
||||||
|
log " Created CCD file: $cn"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local count=$(echo "$ccd_json" | jq -r '.count // 0')
|
||||||
|
log " Fetched $count CCD files"
|
||||||
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Setup a single VPN server
|
# Setup a single VPN server
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -120,8 +148,9 @@ setup_server() {
|
||||||
# CRL
|
# CRL
|
||||||
curl -sf "$API_URL/vpn-servers/$server_id/crl" > "$server_dir/crl.pem" 2>/dev/null || true
|
curl -sf "$API_URL/vpn-servers/$server_id/crl" > "$server_dir/crl.pem" 2>/dev/null || true
|
||||||
|
|
||||||
# Create client-config directory
|
# Create client-config directory and fetch CCD files
|
||||||
mkdir -p "$server_dir/ccd"
|
mkdir -p "$server_dir/ccd"
|
||||||
|
fetch_ccd_files "$server_id" "$server_dir/ccd"
|
||||||
|
|
||||||
# Create status file location
|
# Create status file location
|
||||||
touch "$RUN_DIR/openvpn-$server_id.status"
|
touch "$RUN_DIR/openvpn-$server_id.status"
|
||||||
|
|
@ -263,12 +292,16 @@ poll_for_changes() {
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Update CRL for all running servers
|
# Update CRL and CCD files for all running servers
|
||||||
for id in $api_server_ids; do
|
for id in $api_server_ids; do
|
||||||
if [ -d "$SERVERS_DIR/$id" ]; then
|
if [ -d "$SERVERS_DIR/$id" ]; then
|
||||||
|
# Update CRL
|
||||||
curl -sf "$API_URL/vpn-servers/$id/crl" > "$SERVERS_DIR/$id/crl.pem.new" 2>/dev/null && \
|
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" || \
|
mv "$SERVERS_DIR/$id/crl.pem.new" "$SERVERS_DIR/$id/crl.pem" || \
|
||||||
rm -f "$SERVERS_DIR/$id/crl.pem.new"
|
rm -f "$SERVERS_DIR/$id/crl.pem.new"
|
||||||
|
|
||||||
|
# Update CCD files (gateway routes)
|
||||||
|
fetch_ccd_files "$id" "$SERVERS_DIR/$id/ccd"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from ..models.user import User, UserRole
|
||||||
from ..models.access import UserGatewayAccess, ConnectionLog
|
from ..models.access import UserGatewayAccess, ConnectionLog
|
||||||
from ..services.vpn_service import VPNService
|
from ..services.vpn_service import VPNService
|
||||||
from ..services.firewall_service import FirewallService
|
from ..services.firewall_service import FirewallService
|
||||||
|
from ..services.client_vpn_profile_service import ClientVPNProfileService
|
||||||
from .deps import get_current_user
|
from .deps import get_current_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -92,9 +93,15 @@ def connect_to_endpoint(
|
||||||
detail="Endpoint not found"
|
detail="Endpoint not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: Dynamic VPN config generation has been replaced by VPN profiles.
|
# Get or create VPN profile for user
|
||||||
# Gateways should have pre-provisioned VPN profiles.
|
client_profile_service = ClientVPNProfileService(db)
|
||||||
# This endpoint now just logs the connection intent.
|
vpn_config = client_profile_service.get_vpn_config_for_user(current_user)
|
||||||
|
|
||||||
|
if not vpn_config:
|
||||||
|
return ConnectResponse(
|
||||||
|
success=False,
|
||||||
|
message="No VPN server available. Please contact administrator."
|
||||||
|
)
|
||||||
|
|
||||||
# Log connection
|
# Log connection
|
||||||
connection = ConnectionLog(
|
connection = ConnectionLog(
|
||||||
|
|
@ -107,10 +114,14 @@ def connect_to_endpoint(
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(connection)
|
db.refresh(connection)
|
||||||
|
|
||||||
|
# Set up firewall rules for this connection
|
||||||
|
# The client's VPN IP will be determined after VPN connects
|
||||||
|
# For now, we'll configure firewall rules when the client-connect script runs
|
||||||
|
|
||||||
return ConnectResponse(
|
return ConnectResponse(
|
||||||
success=True,
|
success=True,
|
||||||
message="Connection logged. Use the gateway's VPN profile configuration to connect.",
|
message="VPN configuration ready. Connect to access endpoint.",
|
||||||
vpn_config=None, # VPN config is now obtained through gateway VPN profiles
|
vpn_config=vpn_config,
|
||||||
target_ip=endpoint.internal_ip,
|
target_ip=endpoint.internal_ip,
|
||||||
target_port=endpoint.port,
|
target_port=endpoint.port,
|
||||||
connection_id=connection.id
|
connection_id=connection.id
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,34 @@ from pathlib import Path
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, Query
|
from fastapi import APIRouter, Depends, HTTPException, Response, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models.vpn_server import VPNServer, VPNServerStatus
|
from ..models.vpn_server import VPNServer, VPNServerStatus
|
||||||
from ..models.vpn_profile import VPNProfile
|
from ..models.vpn_profile import VPNProfile
|
||||||
|
from ..models.client_vpn_profile import ClientVPNProfile
|
||||||
|
from ..models.gateway import Gateway
|
||||||
|
from ..models.access import ConnectionLog
|
||||||
|
from ..models.endpoint import Endpoint
|
||||||
from ..services.vpn_server_service import VPNServerService
|
from ..services.vpn_server_service import VPNServerService
|
||||||
from ..services.vpn_sync_service import VPNSyncService
|
from ..services.vpn_sync_service import VPNSyncService
|
||||||
from ..services.certificate_service import CertificateService
|
from ..services.certificate_service import CertificateService
|
||||||
|
from ..services.firewall_service import FirewallService
|
||||||
|
|
||||||
|
|
||||||
|
class ClientConnectedRequest(BaseModel):
|
||||||
|
"""Request when VPN client connects."""
|
||||||
|
common_name: str
|
||||||
|
real_ip: str
|
||||||
|
vpn_ip: str
|
||||||
|
|
||||||
|
|
||||||
|
class ClientDisconnectedRequest(BaseModel):
|
||||||
|
"""Request when VPN client disconnects."""
|
||||||
|
common_name: str
|
||||||
|
bytes_received: int = 0
|
||||||
|
bytes_sent: int = 0
|
||||||
|
duration: int = 0
|
||||||
|
|
||||||
# Log directory (shared volume with OpenVPN container)
|
# Log directory (shared volume with OpenVPN container)
|
||||||
LOG_DIR = Path("/var/log/openvpn")
|
LOG_DIR = Path("/var/log/openvpn")
|
||||||
|
|
@ -361,3 +383,203 @@ async def debug_sync(db: Session = Depends(get_db)):
|
||||||
"connected_clients": connected_clients,
|
"connected_clients": connected_clients,
|
||||||
"sync_result": sync_result
|
"sync_result": sync_result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vpn-servers/{server_id}/ccd")
|
||||||
|
async def get_ccd_files(
|
||||||
|
server_id: int,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get all CCD (Client Config Directory) files for a VPN server.
|
||||||
|
|
||||||
|
CCD files contain iroute directives that tell OpenVPN which client
|
||||||
|
handles traffic for which subnet.
|
||||||
|
"""
|
||||||
|
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
|
||||||
|
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(status_code=404, detail="Server not found")
|
||||||
|
|
||||||
|
service = VPNServerService(db)
|
||||||
|
ccd_files = service.get_all_ccd_files(server)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"server_id": server_id,
|
||||||
|
"count": len(ccd_files),
|
||||||
|
"files": ccd_files
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vpn-servers/{server_id}/ccd/{common_name}")
|
||||||
|
async def get_ccd_file(
|
||||||
|
server_id: int,
|
||||||
|
common_name: str,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get a single CCD file for a specific client."""
|
||||||
|
from ..models.vpn_profile import VPNProfile
|
||||||
|
|
||||||
|
profile = db.query(VPNProfile).filter(
|
||||||
|
VPNProfile.vpn_server_id == server_id,
|
||||||
|
VPNProfile.cert_cn == common_name
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(status_code=404, detail="Profile not found")
|
||||||
|
|
||||||
|
service = VPNServerService(db)
|
||||||
|
content = service.generate_ccd_file(profile)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="No CCD content for this profile")
|
||||||
|
|
||||||
|
return Response(content=content, media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vpn-servers/{server_id}/client-connected")
|
||||||
|
async def client_connected(
|
||||||
|
server_id: int,
|
||||||
|
request: ClientConnectedRequest,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Handle VPN client connection event.
|
||||||
|
|
||||||
|
Called by OpenVPN client-connect script when a client connects.
|
||||||
|
Sets up firewall rules for pending connections.
|
||||||
|
"""
|
||||||
|
# Determine if this is a gateway or a user client
|
||||||
|
cn = request.common_name
|
||||||
|
vpn_ip = request.vpn_ip
|
||||||
|
|
||||||
|
# Check if it's a gateway profile
|
||||||
|
gateway_profile = db.query(VPNProfile).filter(
|
||||||
|
VPNProfile.cert_cn == cn
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if gateway_profile:
|
||||||
|
# Update gateway status
|
||||||
|
gateway = gateway_profile.gateway
|
||||||
|
gateway.is_online = True
|
||||||
|
gateway.vpn_ip = vpn_ip
|
||||||
|
gateway_profile.vpn_ip = vpn_ip
|
||||||
|
gateway_profile.last_connection = db.func.now()
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok", "type": "gateway", "gateway_id": gateway.id}
|
||||||
|
|
||||||
|
# Check if it's a client profile (user/technician)
|
||||||
|
client_profile = db.query(ClientVPNProfile).filter(
|
||||||
|
ClientVPNProfile.cert_cn == cn
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if client_profile:
|
||||||
|
# Update client profile with VPN IP
|
||||||
|
client_profile.vpn_ip = vpn_ip
|
||||||
|
client_profile.last_connection = db.func.now()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Find pending connections for this user and set firewall rules
|
||||||
|
pending_connections = db.query(ConnectionLog).filter(
|
||||||
|
ConnectionLog.user_id == client_profile.user_id,
|
||||||
|
ConnectionLog.disconnected_at.is_(None),
|
||||||
|
ConnectionLog.vpn_ip.is_(None) # Not yet processed
|
||||||
|
).all()
|
||||||
|
|
||||||
|
firewall = FirewallService()
|
||||||
|
rules_created = 0
|
||||||
|
|
||||||
|
for conn in pending_connections:
|
||||||
|
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first()
|
||||||
|
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
|
||||||
|
|
||||||
|
if endpoint and gateway and gateway.vpn_ip:
|
||||||
|
# Set firewall rule to allow client to reach endpoint via gateway
|
||||||
|
success = firewall.allow_connection(
|
||||||
|
client_vpn_ip=vpn_ip,
|
||||||
|
gateway_vpn_ip=gateway.vpn_ip,
|
||||||
|
target_ip=endpoint.internal_ip,
|
||||||
|
target_port=endpoint.port,
|
||||||
|
protocol=endpoint.protocol.value
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
conn.vpn_ip = vpn_ip
|
||||||
|
rules_created += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"type": "client",
|
||||||
|
"user_id": client_profile.user_id,
|
||||||
|
"rules_created": rules_created
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"status": "unknown_client", "common_name": cn}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vpn-servers/{server_id}/client-disconnected")
|
||||||
|
async def client_disconnected(
|
||||||
|
server_id: int,
|
||||||
|
request: ClientDisconnectedRequest,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Handle VPN client disconnection event.
|
||||||
|
|
||||||
|
Called by OpenVPN client-disconnect script when a client disconnects.
|
||||||
|
Cleans up firewall rules.
|
||||||
|
"""
|
||||||
|
cn = request.common_name
|
||||||
|
|
||||||
|
# Check if it's a gateway profile
|
||||||
|
gateway_profile = db.query(VPNProfile).filter(
|
||||||
|
VPNProfile.cert_cn == cn
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if gateway_profile:
|
||||||
|
# Mark gateway as offline
|
||||||
|
gateway = gateway_profile.gateway
|
||||||
|
gateway.is_online = False
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok", "type": "gateway", "gateway_id": gateway.id}
|
||||||
|
|
||||||
|
# Check if it's a client profile
|
||||||
|
client_profile = db.query(ClientVPNProfile).filter(
|
||||||
|
ClientVPNProfile.cert_cn == cn
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if client_profile:
|
||||||
|
vpn_ip = client_profile.vpn_ip
|
||||||
|
|
||||||
|
# Find active connections for this user and clean up firewall rules
|
||||||
|
if vpn_ip:
|
||||||
|
active_connections = db.query(ConnectionLog).filter(
|
||||||
|
ConnectionLog.user_id == client_profile.user_id,
|
||||||
|
ConnectionLog.disconnected_at.is_(None),
|
||||||
|
ConnectionLog.vpn_ip == vpn_ip
|
||||||
|
).all()
|
||||||
|
|
||||||
|
firewall = FirewallService()
|
||||||
|
|
||||||
|
for conn in active_connections:
|
||||||
|
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first()
|
||||||
|
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
|
||||||
|
|
||||||
|
if endpoint and gateway:
|
||||||
|
# Remove firewall rule
|
||||||
|
firewall.revoke_connection(
|
||||||
|
client_vpn_ip=vpn_ip,
|
||||||
|
gateway_vpn_ip=gateway.vpn_ip or "",
|
||||||
|
target_ip=endpoint.internal_ip,
|
||||||
|
target_port=endpoint.port,
|
||||||
|
protocol=endpoint.protocol.value
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark connection as disconnected
|
||||||
|
conn.disconnected_at = db.func.now()
|
||||||
|
|
||||||
|
# Clear VPN IP from profile
|
||||||
|
client_profile.vpn_ip = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "ok", "type": "client", "user_id": client_profile.user_id}
|
||||||
|
|
||||||
|
return {"status": "unknown_client", "common_name": cn}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from .access import UserGatewayAccess, UserEndpointAccess, ConnectionLog
|
||||||
from .certificate_authority import CertificateAuthority, CAStatus, CAAlgorithm
|
from .certificate_authority import CertificateAuthority, CAStatus, CAAlgorithm
|
||||||
from .vpn_server import VPNServer, VPNProtocol, VPNCipher, VPNAuth, VPNCompression, VPNServerStatus
|
from .vpn_server import VPNServer, VPNProtocol, VPNCipher, VPNAuth, VPNCompression, VPNServerStatus
|
||||||
from .vpn_profile import VPNProfile, VPNProfileStatus
|
from .vpn_profile import VPNProfile, VPNProfileStatus
|
||||||
|
from .client_vpn_profile import ClientVPNProfile, ClientVPNProfileStatus
|
||||||
from .vpn_connection_log import VPNConnectionLog
|
from .vpn_connection_log import VPNConnectionLog
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -32,5 +33,7 @@ __all__ = [
|
||||||
"VPNServerStatus",
|
"VPNServerStatus",
|
||||||
"VPNProfile",
|
"VPNProfile",
|
||||||
"VPNProfileStatus",
|
"VPNProfileStatus",
|
||||||
|
"ClientVPNProfile",
|
||||||
|
"ClientVPNProfileStatus",
|
||||||
"VPNConnectionLog",
|
"VPNConnectionLog",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ class CertificateAuthority(Base):
|
||||||
created_by = relationship("User", foreign_keys=[created_by_id])
|
created_by = relationship("User", foreign_keys=[created_by_id])
|
||||||
vpn_servers = relationship("VPNServer", back_populates="certificate_authority")
|
vpn_servers = relationship("VPNServer", back_populates="certificate_authority")
|
||||||
vpn_profiles = relationship("VPNProfile", back_populates="certificate_authority")
|
vpn_profiles = relationship("VPNProfile", back_populates="certificate_authority")
|
||||||
|
client_vpn_profiles = relationship("ClientVPNProfile", back_populates="certificate_authority")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<CertificateAuthority(id={self.id}, name='{self.name}', status='{self.status}')>"
|
return f"<CertificateAuthority(id={self.id}, name='{self.name}', status='{self.status}')>"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Client VPN Profile model for user/technician VPN connections."""
|
||||||
|
|
||||||
|
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 ClientVPNProfileStatus(str, PyEnum):
|
||||||
|
"""Client VPN Profile status."""
|
||||||
|
PENDING = "pending" # Certificate being generated
|
||||||
|
ACTIVE = "active" # Ready to use
|
||||||
|
EXPIRED = "expired" # Certificate expired
|
||||||
|
REVOKED = "revoked" # Certificate revoked
|
||||||
|
|
||||||
|
|
||||||
|
class ClientVPNProfile(Base):
|
||||||
|
"""VPN Profile for a user/technician to connect to the VPN server."""
|
||||||
|
|
||||||
|
__tablename__ = "client_vpn_profiles"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
vpn_server_id = Column(Integer, ForeignKey("vpn_servers.id"), nullable=False)
|
||||||
|
ca_id = Column(Integer, ForeignKey("certificate_authorities.id"), nullable=False)
|
||||||
|
|
||||||
|
# Certificate data
|
||||||
|
cert_cn = Column(String(255), nullable=False, unique=True) # Common Name
|
||||||
|
client_cert = Column(Text, nullable=True) # Client certificate PEM
|
||||||
|
client_key = Column(Text, nullable=True) # Client private key PEM
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = Column(Enum(ClientVPNProfileStatus), default=ClientVPNProfileStatus.PENDING)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Validity
|
||||||
|
valid_from = Column(DateTime, nullable=True)
|
||||||
|
valid_until = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# VPN IP assigned (updated when connected)
|
||||||
|
vpn_ip = Column(String(15), nullable=True)
|
||||||
|
|
||||||
|
# Tracking
|
||||||
|
last_connection = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="client_vpn_profile")
|
||||||
|
vpn_server = relationship("VPNServer", back_populates="client_vpn_profiles")
|
||||||
|
certificate_authority = relationship("CertificateAuthority", back_populates="client_vpn_profiles")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ClientVPNProfile(id={self.id}, user_id={self.user_id}, cert_cn='{self.cert_cn}')>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
"""Check if profile is ready for use."""
|
||||||
|
return (
|
||||||
|
self.status == ClientVPNProfileStatus.ACTIVE 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
|
||||||
|
|
@ -47,6 +47,12 @@ class User(Base):
|
||||||
primaryjoin="User.id == UserEndpointAccess.user_id"
|
primaryjoin="User.id == UserEndpointAccess.user_id"
|
||||||
)
|
)
|
||||||
connection_logs = relationship("ConnectionLog", back_populates="user")
|
connection_logs = relationship("ConnectionLog", back_populates="user")
|
||||||
|
client_vpn_profile = relationship(
|
||||||
|
"ClientVPNProfile",
|
||||||
|
back_populates="user",
|
||||||
|
uselist=False, # One-to-one relationship
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<User(id={self.id}, username='{self.username}', role='{self.role}')>"
|
return f"<User(id={self.id}, username='{self.username}', role='{self.role}')>"
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ class VPNServer(Base):
|
||||||
tenant = relationship("Tenant", back_populates="vpn_servers")
|
tenant = relationship("Tenant", back_populates="vpn_servers")
|
||||||
certificate_authority = relationship("CertificateAuthority", back_populates="vpn_servers")
|
certificate_authority = relationship("CertificateAuthority", back_populates="vpn_servers")
|
||||||
vpn_profiles = relationship("VPNProfile", back_populates="vpn_server")
|
vpn_profiles = relationship("VPNProfile", back_populates="vpn_server")
|
||||||
|
client_vpn_profiles = relationship("ClientVPNProfile", back_populates="vpn_server")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<VPNServer(id={self.id}, name='{self.name}', {self.hostname}:{self.port}/{self.protocol.value})>"
|
return f"<VPNServer(id={self.id}, name='{self.name}', {self.hostname}:{self.port}/{self.protocol.value})>"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from .firewall_service import FirewallService
|
||||||
from .certificate_service import CertificateService
|
from .certificate_service import CertificateService
|
||||||
from .vpn_server_service import VPNServerService
|
from .vpn_server_service import VPNServerService
|
||||||
from .vpn_profile_service import VPNProfileService
|
from .vpn_profile_service import VPNProfileService
|
||||||
|
from .client_vpn_profile_service import ClientVPNProfileService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuthService",
|
"AuthService",
|
||||||
|
|
@ -14,4 +15,5 @@ __all__ = [
|
||||||
"CertificateService",
|
"CertificateService",
|
||||||
"VPNServerService",
|
"VPNServerService",
|
||||||
"VPNProfileService",
|
"VPNProfileService",
|
||||||
|
"ClientVPNProfileService",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
"""Client VPN Profile management service for user/technician VPN connections."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models.client_vpn_profile import ClientVPNProfile, ClientVPNProfileStatus
|
||||||
|
from ..models.vpn_server import VPNServer
|
||||||
|
from ..models.user import User
|
||||||
|
from ..models.gateway import Gateway
|
||||||
|
from ..models.access import UserGatewayAccess
|
||||||
|
from ..models.certificate_authority import CertificateAuthority
|
||||||
|
from .certificate_service import CertificateService
|
||||||
|
from ..config import get_settings
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class ClientVPNProfileService:
|
||||||
|
"""Service for managing VPN profiles for users/technicians."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
self.cert_service = CertificateService(db)
|
||||||
|
|
||||||
|
def get_or_create_profile(self, user: User) -> Optional[ClientVPNProfile]:
|
||||||
|
"""Get existing profile for user or create a new one."""
|
||||||
|
# Check if user already has a profile
|
||||||
|
if user.client_vpn_profile and user.client_vpn_profile.is_ready:
|
||||||
|
return user.client_vpn_profile
|
||||||
|
|
||||||
|
# Find active VPN server (prefer primary, then any active)
|
||||||
|
server = self.db.query(VPNServer).filter(
|
||||||
|
VPNServer.is_active == True,
|
||||||
|
VPNServer.is_primary == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not server:
|
||||||
|
server = self.db.query(VPNServer).filter(
|
||||||
|
VPNServer.is_active == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not server:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not server.certificate_authority or not server.certificate_authority.is_ready:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create new profile
|
||||||
|
return self.create_profile(user, server.id)
|
||||||
|
|
||||||
|
def create_profile(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
vpn_server_id: int
|
||||||
|
) -> ClientVPNProfile:
|
||||||
|
"""Create a new VPN profile for a user."""
|
||||||
|
|
||||||
|
# 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
|
||||||
|
cert_cn = f"client-{user.username}-{user.id}"
|
||||||
|
|
||||||
|
# Check if profile already exists
|
||||||
|
existing = self.db.query(ClientVPNProfile).filter(
|
||||||
|
ClientVPNProfile.user_id == user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Delete old profile and create new
|
||||||
|
self.db.delete(existing)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
# Create profile
|
||||||
|
profile = ClientVPNProfile(
|
||||||
|
user_id=user.id,
|
||||||
|
vpn_server_id=vpn_server_id,
|
||||||
|
ca_id=server.ca_id,
|
||||||
|
cert_cn=cert_cn,
|
||||||
|
status=ClientVPNProfileStatus.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: ClientVPNProfile):
|
||||||
|
"""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 = ClientVPNProfileStatus.ACTIVE
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def generate_client_config(self, profile: ClientVPNProfile, split_tunnel: bool = True) -> str:
|
||||||
|
"""Generate OpenVPN client configuration (.ovpn) for a profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: The client VPN profile
|
||||||
|
split_tunnel: If True, only VPN traffic goes through tunnel (default).
|
||||||
|
If False, all traffic goes through VPN (no internet).
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not profile.is_ready:
|
||||||
|
raise ValueError("Profile is not ready")
|
||||||
|
|
||||||
|
server = profile.vpn_server
|
||||||
|
ca = profile.certificate_authority
|
||||||
|
|
||||||
|
config_lines = [
|
||||||
|
"# OpenVPN Client Configuration",
|
||||||
|
f"# User: {profile.user.username}",
|
||||||
|
f"# Server: {server.name}",
|
||||||
|
f"# Generated: {datetime.utcnow().isoformat()}",
|
||||||
|
f"# Split Tunneling: {'Enabled' if split_tunnel else 'Disabled'}",
|
||||||
|
"",
|
||||||
|
"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",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Split Tunneling Configuration
|
||||||
|
if split_tunnel:
|
||||||
|
config_lines.extend([
|
||||||
|
"# Split Tunneling - Only VPN traffic through tunnel",
|
||||||
|
"# Ignore any redirect-gateway push from server",
|
||||||
|
"pull-filter ignore \"redirect-gateway\"",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
# Add route for VPN network
|
||||||
|
vpn_network = server.vpn_network
|
||||||
|
vpn_netmask = server.vpn_netmask
|
||||||
|
config_lines.append(f"# Route for VPN network")
|
||||||
|
config_lines.append(f"route {vpn_network} {vpn_netmask}")
|
||||||
|
config_lines.append("")
|
||||||
|
|
||||||
|
# Add routes for gateway subnets the user has access to
|
||||||
|
routes_added = self._get_gateway_routes(profile.user)
|
||||||
|
if routes_added:
|
||||||
|
config_lines.append("# Routes for accessible gateway networks")
|
||||||
|
for route in routes_added:
|
||||||
|
config_lines.append(f"route {route['network']} {route['netmask']} # {route['name']}")
|
||||||
|
config_lines.append("")
|
||||||
|
|
||||||
|
# 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 _get_gateway_routes(self, user: User) -> list[dict]:
|
||||||
|
"""Get routes for all gateway subnets the user has access to."""
|
||||||
|
routes = []
|
||||||
|
seen_networks = set()
|
||||||
|
|
||||||
|
# Get gateways the user has access to
|
||||||
|
if user.is_admin:
|
||||||
|
# Admins have access to all gateways in their tenant
|
||||||
|
gateways = self.db.query(Gateway).filter(
|
||||||
|
Gateway.tenant_id == user.tenant_id
|
||||||
|
).all()
|
||||||
|
else:
|
||||||
|
# Regular users - check access table
|
||||||
|
access_entries = self.db.query(UserGatewayAccess).filter(
|
||||||
|
UserGatewayAccess.user_id == user.id
|
||||||
|
).all()
|
||||||
|
gateway_ids = [a.gateway_id for a in access_entries]
|
||||||
|
gateways = self.db.query(Gateway).filter(
|
||||||
|
Gateway.id.in_(gateway_ids)
|
||||||
|
).all() if gateway_ids else []
|
||||||
|
|
||||||
|
for gateway in gateways:
|
||||||
|
# Add route for gateway's VPN subnet if defined
|
||||||
|
if gateway.vpn_subnet and gateway.vpn_subnet not in seen_networks:
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(gateway.vpn_subnet, strict=False)
|
||||||
|
routes.append({
|
||||||
|
"network": str(network.network_address),
|
||||||
|
"netmask": str(network.netmask),
|
||||||
|
"name": gateway.name
|
||||||
|
})
|
||||||
|
seen_networks.add(gateway.vpn_subnet)
|
||||||
|
except ValueError:
|
||||||
|
pass # Invalid subnet, skip
|
||||||
|
|
||||||
|
return routes
|
||||||
|
|
||||||
|
def get_vpn_config_for_user(self, user: User) -> Optional[str]:
|
||||||
|
"""Get or create VPN config for a user."""
|
||||||
|
profile = self.get_or_create_profile(user)
|
||||||
|
if not profile:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.generate_client_config(profile)
|
||||||
|
|
||||||
|
def revoke_profile(self, profile: ClientVPNProfile, reason: str = "unspecified"):
|
||||||
|
"""Revoke a user's VPN profile certificate."""
|
||||||
|
if profile.client_cert:
|
||||||
|
self.cert_service.revoke_certificate(
|
||||||
|
ca=profile.certificate_authority,
|
||||||
|
cert_pem=profile.client_cert,
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
|
||||||
|
profile.status = ClientVPNProfileStatus.REVOKED
|
||||||
|
profile.is_active = False
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def renew_profile(self, profile: ClientVPNProfile):
|
||||||
|
"""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)
|
||||||
|
self.db.commit()
|
||||||
|
|
@ -7,8 +7,11 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..models.vpn_server import VPNServer, VPNServerStatus, VPNProtocol, VPNCipher, VPNAuth, VPNCompression
|
from ..models.vpn_server import VPNServer, VPNServerStatus, VPNProtocol, VPNCipher, VPNAuth, VPNCompression
|
||||||
from ..models.certificate_authority import CertificateAuthority
|
from ..models.certificate_authority import CertificateAuthority
|
||||||
|
from ..models.vpn_profile import VPNProfile
|
||||||
|
from ..models.gateway import Gateway
|
||||||
from .certificate_service import CertificateService
|
from .certificate_service import CertificateService
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
@ -156,6 +159,17 @@ class VPNServerService:
|
||||||
'push "dhcp-option DNS 8.8.8.8"',
|
'push "dhcp-option DNS 8.8.8.8"',
|
||||||
'push "dhcp-option DNS 8.8.4.4"',
|
'push "dhcp-option DNS 8.8.4.4"',
|
||||||
"",
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
# Routes for gateway subnets (required for iroute to work)
|
||||||
|
gateway_routes = self._get_gateway_subnet_routes(server)
|
||||||
|
if gateway_routes:
|
||||||
|
config_lines.append("# Routes for gateway subnets")
|
||||||
|
for route in gateway_routes:
|
||||||
|
config_lines.append(f"route {route['network']} {route['netmask']} # {route['gateway_name']}")
|
||||||
|
config_lines.append("")
|
||||||
|
|
||||||
|
config_lines.extend([
|
||||||
"# Security",
|
"# Security",
|
||||||
f"cipher {server.cipher.value}",
|
f"cipher {server.cipher.value}",
|
||||||
f"auth {server.auth.value}",
|
f"auth {server.auth.value}",
|
||||||
|
|
@ -201,6 +215,81 @@ class VPNServerService:
|
||||||
|
|
||||||
return "\n".join(config_lines)
|
return "\n".join(config_lines)
|
||||||
|
|
||||||
|
def _get_gateway_subnet_routes(self, server: VPNServer) -> list[dict]:
|
||||||
|
"""Get all gateway subnets that need routes on this server."""
|
||||||
|
routes = []
|
||||||
|
seen_networks = set()
|
||||||
|
|
||||||
|
# Find all VPN profiles that use this server
|
||||||
|
profiles = self.db.query(VPNProfile).filter(
|
||||||
|
VPNProfile.vpn_server_id == server.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for profile in profiles:
|
||||||
|
gateway = profile.gateway
|
||||||
|
if gateway and gateway.vpn_subnet and gateway.vpn_subnet not in seen_networks:
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(gateway.vpn_subnet, strict=False)
|
||||||
|
routes.append({
|
||||||
|
"network": str(network.network_address),
|
||||||
|
"netmask": str(network.netmask),
|
||||||
|
"gateway_name": gateway.name
|
||||||
|
})
|
||||||
|
seen_networks.add(gateway.vpn_subnet)
|
||||||
|
except ValueError:
|
||||||
|
pass # Invalid subnet, skip
|
||||||
|
|
||||||
|
return routes
|
||||||
|
|
||||||
|
def generate_ccd_file(self, profile: VPNProfile) -> str | None:
|
||||||
|
"""Generate CCD (Client Config Directory) file content for a gateway profile.
|
||||||
|
|
||||||
|
This file tells OpenVPN that traffic for the gateway's subnet
|
||||||
|
should be routed through this client.
|
||||||
|
"""
|
||||||
|
gateway = profile.gateway
|
||||||
|
if not gateway or not gateway.vpn_subnet:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# CCD file for gateway: {gateway.name}",
|
||||||
|
f"# Profile: {profile.name}",
|
||||||
|
f"# Common Name: {profile.cert_cn}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(gateway.vpn_subnet, strict=False)
|
||||||
|
# iroute tells OpenVPN to route this subnet through this client
|
||||||
|
lines.append(f"iroute {network.network_address} {network.netmask}")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Optional: assign static IP to this gateway
|
||||||
|
if profile.vpn_ip:
|
||||||
|
lines.append(f"# Static IP assignment (if needed)")
|
||||||
|
lines.append(f"# ifconfig-push {profile.vpn_ip} {profile.vpn_server.vpn_netmask}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def get_all_ccd_files(self, server: VPNServer) -> dict[str, str]:
|
||||||
|
"""Get all CCD files for a VPN server.
|
||||||
|
|
||||||
|
Returns a dict mapping cert_cn to CCD file content.
|
||||||
|
"""
|
||||||
|
ccd_files = {}
|
||||||
|
|
||||||
|
profiles = self.db.query(VPNProfile).filter(
|
||||||
|
VPNProfile.vpn_server_id == server.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for profile in profiles:
|
||||||
|
content = self.generate_ccd_file(profile)
|
||||||
|
if content:
|
||||||
|
ccd_files[profile.cert_cn] = content
|
||||||
|
|
||||||
|
return ccd_files
|
||||||
|
|
||||||
def get_connected_clients(self, server: VPNServer) -> list[dict]:
|
def get_connected_clients(self, server: VPNServer) -> list[dict]:
|
||||||
"""Get list of currently connected VPN clients."""
|
"""Get list of currently connected VPN clients."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Gateway Simulator Script
|
||||||
|
# ========================
|
||||||
|
# Konfiguriert ein Linux-System als VPN-Gateway, das Traffic vom
|
||||||
|
# VPN-Server ins lokale Netzwerk weiterleitet.
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# sudo ./gatewaysimulator.sh enable - Aktiviert Gateway-Funktion
|
||||||
|
# sudo ./gatewaysimulator.sh disable - Deaktiviert Gateway-Funktion
|
||||||
|
# sudo ./gatewaysimulator.sh status - Zeigt aktuellen Status
|
||||||
|
#
|
||||||
|
# Voraussetzungen:
|
||||||
|
# - OpenVPN-Verbindung zum Server muss aktiv sein
|
||||||
|
# - Script muss als root ausgeführt werden
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Farben für Output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Konfiguration - bei Bedarf anpassen
|
||||||
|
VPN_INTERFACE="tun0" # OpenVPN TUN Interface
|
||||||
|
LOCAL_INTERFACE="" # Wird automatisch erkannt (eth0, enp0s3, etc.)
|
||||||
|
VPN_NETWORK="10.8.0.0/24" # VPN Netzwerk (muss zum Server passen)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Hilfsfunktionen
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_root() {
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
log_error "Dieses Script muss als root ausgeführt werden!"
|
||||||
|
echo "Verwendung: sudo $0 {enable|disable|status}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_local_interface() {
|
||||||
|
# Finde das Haupt-Netzwerkinterface (nicht lo, tun, docker, etc.)
|
||||||
|
LOCAL_INTERFACE=$(ip route | grep default | head -1 | awk '{print $5}')
|
||||||
|
|
||||||
|
if [[ -z "$LOCAL_INTERFACE" ]]; then
|
||||||
|
log_error "Konnte lokales Netzwerkinterface nicht erkennen!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Erkanntes lokales Interface: $LOCAL_INTERFACE"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_local_network() {
|
||||||
|
# Ermittle das lokale Netzwerk
|
||||||
|
local ip_addr=$(ip -4 addr show "$LOCAL_INTERFACE" | grep -oP '(?<=inet\s)\d+(\.\d+){3}/\d+')
|
||||||
|
echo "$ip_addr"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_vpn_connection() {
|
||||||
|
if ! ip link show "$VPN_INTERFACE" &>/dev/null; then
|
||||||
|
log_warn "VPN Interface $VPN_INTERFACE nicht gefunden!"
|
||||||
|
log_warn "Stelle sicher, dass die OpenVPN-Verbindung aktiv ist."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local vpn_ip=$(ip -4 addr show "$VPN_INTERFACE" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}')
|
||||||
|
if [[ -n "$vpn_ip" ]]; then
|
||||||
|
log_info "VPN Interface aktiv: $VPN_INTERFACE mit IP $vpn_ip"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Gateway-Funktionen
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
enable_ip_forwarding() {
|
||||||
|
log_info "Aktiviere IP Forwarding..."
|
||||||
|
|
||||||
|
# Temporär aktivieren
|
||||||
|
echo 1 > /proc/sys/net/ipv4/ip_forward
|
||||||
|
|
||||||
|
# Permanent aktivieren
|
||||||
|
if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf; then
|
||||||
|
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
|
||||||
|
log_info "IP Forwarding permanent in /etc/sysctl.conf eingetragen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Auch für IPv6 (optional)
|
||||||
|
echo 1 > /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true
|
||||||
|
|
||||||
|
log_info "IP Forwarding aktiviert"
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_ip_forwarding() {
|
||||||
|
log_info "Deaktiviere IP Forwarding..."
|
||||||
|
echo 0 > /proc/sys/net/ipv4/ip_forward
|
||||||
|
echo 0 > /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true
|
||||||
|
log_info "IP Forwarding deaktiviert"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_iptables() {
|
||||||
|
log_info "Konfiguriere iptables Regeln..."
|
||||||
|
|
||||||
|
local local_net=$(get_local_network)
|
||||||
|
log_info "Lokales Netzwerk: $local_net"
|
||||||
|
|
||||||
|
# Erstelle eigene Chain für Gateway-Regeln (falls nicht vorhanden)
|
||||||
|
iptables -N MGUARD_GW 2>/dev/null || true
|
||||||
|
|
||||||
|
# Lösche vorhandene Regeln in unserer Chain
|
||||||
|
iptables -F MGUARD_GW
|
||||||
|
|
||||||
|
# Füge Chain in FORWARD ein (falls nicht vorhanden)
|
||||||
|
if ! iptables -C FORWARD -j MGUARD_GW 2>/dev/null; then
|
||||||
|
iptables -I FORWARD -j MGUARD_GW
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Erlaube Traffic vom VPN zum lokalen Netz
|
||||||
|
iptables -A MGUARD_GW -i "$VPN_INTERFACE" -o "$LOCAL_INTERFACE" -j ACCEPT
|
||||||
|
log_info " - VPN → Lokal: ACCEPT"
|
||||||
|
|
||||||
|
# Erlaube Rückverkehr (established, related)
|
||||||
|
iptables -A MGUARD_GW -i "$LOCAL_INTERFACE" -o "$VPN_INTERFACE" -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||||
|
log_info " - Lokal → VPN (established): ACCEPT"
|
||||||
|
|
||||||
|
# NAT/Masquerading für ausgehenden Traffic
|
||||||
|
if ! iptables -t nat -C POSTROUTING -s "$VPN_NETWORK" -o "$LOCAL_INTERFACE" -j MASQUERADE 2>/dev/null; then
|
||||||
|
iptables -t nat -A POSTROUTING -s "$VPN_NETWORK" -o "$LOCAL_INTERFACE" -j MASQUERADE
|
||||||
|
fi
|
||||||
|
log_info " - NAT/Masquerade für VPN-Traffic aktiviert"
|
||||||
|
|
||||||
|
log_info "iptables Regeln konfiguriert"
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_iptables() {
|
||||||
|
log_info "Entferne iptables Regeln..."
|
||||||
|
|
||||||
|
# Entferne NAT-Regel
|
||||||
|
iptables -t nat -D POSTROUTING -s "$VPN_NETWORK" -o "$LOCAL_INTERFACE" -j MASQUERADE 2>/dev/null || true
|
||||||
|
|
||||||
|
# Entferne FORWARD-Jump
|
||||||
|
iptables -D FORWARD -j MGUARD_GW 2>/dev/null || true
|
||||||
|
|
||||||
|
# Lösche und entferne Chain
|
||||||
|
iptables -F MGUARD_GW 2>/dev/null || true
|
||||||
|
iptables -X MGUARD_GW 2>/dev/null || true
|
||||||
|
|
||||||
|
log_info "iptables Regeln entfernt"
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Status-Anzeige
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
show_status() {
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Gateway Simulator Status"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# IP Forwarding
|
||||||
|
local ip_fwd=$(cat /proc/sys/net/ipv4/ip_forward)
|
||||||
|
if [[ "$ip_fwd" == "1" ]]; then
|
||||||
|
echo -e "IP Forwarding: ${GREEN}Aktiviert${NC}"
|
||||||
|
else
|
||||||
|
echo -e "IP Forwarding: ${RED}Deaktiviert${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# VPN Interface
|
||||||
|
if check_vpn_connection 2>/dev/null; then
|
||||||
|
local vpn_ip=$(ip -4 addr show "$VPN_INTERFACE" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}')
|
||||||
|
echo -e "VPN Interface: ${GREEN}$VPN_INTERFACE ($vpn_ip)${NC}"
|
||||||
|
else
|
||||||
|
echo -e "VPN Interface: ${RED}Nicht verbunden${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Lokales Interface
|
||||||
|
detect_local_interface 2>/dev/null
|
||||||
|
local local_ip=$(ip -4 addr show "$LOCAL_INTERFACE" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}')
|
||||||
|
echo -e "Lokales Interface: $LOCAL_INTERFACE ($local_ip)"
|
||||||
|
|
||||||
|
# iptables Regeln
|
||||||
|
echo ""
|
||||||
|
echo "iptables MGUARD_GW Chain:"
|
||||||
|
if iptables -L MGUARD_GW -n 2>/dev/null; then
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Chain nicht vorhanden${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "NAT/Masquerade Regeln:"
|
||||||
|
iptables -t nat -L POSTROUTING -n | grep -E "MASQUERADE|$VPN_NETWORK" || echo "Keine Masquerade-Regeln"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# Netzwerk-Test
|
||||||
|
echo ""
|
||||||
|
echo "Netzwerk-Erreichbarkeits-Test:"
|
||||||
|
echo "------------------------------"
|
||||||
|
|
||||||
|
# Test lokales Gateway (meist .1)
|
||||||
|
local gateway_ip=$(ip route | grep default | awk '{print $3}')
|
||||||
|
if ping -c 1 -W 2 "$gateway_ip" &>/dev/null; then
|
||||||
|
echo -e " Default Gateway ($gateway_ip): ${GREEN}Erreichbar${NC}"
|
||||||
|
else
|
||||||
|
echo -e " Default Gateway ($gateway_ip): ${RED}Nicht erreichbar${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Hauptprogramm
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
main() {
|
||||||
|
case "${1:-}" in
|
||||||
|
enable)
|
||||||
|
check_root
|
||||||
|
detect_local_interface
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Gateway Simulator wird aktiviert..."
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
enable_ip_forwarding
|
||||||
|
setup_iptables
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_info "Gateway-Funktion aktiviert!"
|
||||||
|
echo ""
|
||||||
|
echo "Dieses System leitet nun Traffic vom VPN-Server"
|
||||||
|
echo "ins lokale Netzwerk weiter."
|
||||||
|
echo ""
|
||||||
|
echo "VPN-Clients können jetzt auf folgende Geräte zugreifen:"
|
||||||
|
echo " - Alle Geräte im Netzwerk $(get_local_network)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Status anzeigen
|
||||||
|
show_status
|
||||||
|
;;
|
||||||
|
|
||||||
|
disable)
|
||||||
|
check_root
|
||||||
|
detect_local_interface
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Gateway Simulator wird deaktiviert..."
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
remove_iptables
|
||||||
|
disable_ip_forwarding
|
||||||
|
|
||||||
|
log_info "Gateway-Funktion deaktiviert!"
|
||||||
|
echo ""
|
||||||
|
;;
|
||||||
|
|
||||||
|
status)
|
||||||
|
detect_local_interface 2>/dev/null || true
|
||||||
|
show_status
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo ""
|
||||||
|
echo "Gateway Simulator für mGuard VPN"
|
||||||
|
echo "================================"
|
||||||
|
echo ""
|
||||||
|
echo "Verwendung: sudo $0 {enable|disable|status}"
|
||||||
|
echo ""
|
||||||
|
echo "Befehle:"
|
||||||
|
echo " enable - Aktiviert IP Forwarding und iptables Regeln"
|
||||||
|
echo " disable - Deaktiviert Gateway-Funktion"
|
||||||
|
echo " status - Zeigt aktuellen Status"
|
||||||
|
echo ""
|
||||||
|
echo "Dieses Script konfiguriert ein Linux-System als Gateway,"
|
||||||
|
echo "das VPN-Traffic ins lokale Netzwerk weiterleitet."
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Loading…
Reference in New Issue