readme refreshed

This commit is contained in:
duffyduck 2026-02-05 08:50:11 +01:00
parent 1de7f5b593
commit 14b770af55
15 changed files with 1453 additions and 27 deletions

317
README.md
View File

@ -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:

View File

@ -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)

View File

@ -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
}

View File

@ -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
} }

View File

@ -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

View File

@ -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}

View File

@ -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",
] ]

View File

@ -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}')>"

View File

@ -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

View File

@ -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}')>"

View File

@ -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})>"

View File

@ -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",
] ]

View File

@ -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()

View File

@ -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:

306
tools/gatewaysimulator.sh Executable file
View File

@ -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 "$@"