diff --git a/README.md b/README.md index 11a5840..9171fca 100644 --- a/README.md +++ b/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 Die gesamte PKI (Zertifizierungsstellen, Zertifikate, VPN-Server) wird über die Web-Oberfläche verwaltet: diff --git a/client/config.py b/client/config.py index db61629..14b3deb 100644 --- a/client/config.py +++ b/client/config.py @@ -1,6 +1,7 @@ """Client configuration.""" import os +import sys from pathlib import Path # Application info @@ -10,13 +11,43 @@ APP_VERSION = "1.0.0" # Default server settings 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 - OPENVPN_EXE = r"C:\Program Files\OpenVPN\bin\openvpn.exe" + OPENVPN_SEARCH_PATHS = OPENVPN_PATHS_WINDOWS OPENVPN_CONFIG_DIR = Path.home() / "OpenVPN" / "config" -else: # Linux/Mac - OPENVPN_EXE = "/usr/sbin/openvpn" +elif sys.platform == 'darwin': # macOS + OPENVPN_SEARCH_PATHS = OPENVPN_PATHS_MACOS 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 OPENVPN_CONFIG_DIR.mkdir(parents=True, exist_ok=True) diff --git a/client/services/vpn_manager.py b/client/services/vpn_manager.py index bb5e50f..6c66edf 100644 --- a/client/services/vpn_manager.py +++ b/client/services/vpn_manager.py @@ -1,13 +1,15 @@ """OpenVPN process management.""" import os +import sys +import shutil import subprocess import tempfile from pathlib import Path from typing import Optional from dataclasses import dataclass -from config import OPENVPN_EXE, OPENVPN_CONFIG_DIR +from config import OPENVPN_SEARCH_PATHS, OPENVPN_CONFIG_DIR @dataclass @@ -25,27 +27,61 @@ class VPNManager: self.process: Optional[subprocess.Popen] = None self.config_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: """Check if OpenVPN is installed.""" - if os.name == 'nt': - return Path(OPENVPN_EXE).exists() - else: - try: - subprocess.run(["which", "openvpn"], capture_output=True, check=True) - return True - except subprocess.CalledProcessError: - return False + return self.find_openvpn_binary() is not None def connect(self, config_content: str) -> VPNStatus: """Connect using provided OpenVPN config.""" if self.process and self.process.poll() is None: return VPNStatus(connected=False, error="Already connected") - if not self.check_openvpn_installed(): + openvpn_binary = self.find_openvpn_binary() + if not openvpn_binary: return VPNStatus( 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 @@ -57,16 +93,15 @@ class VPNManager: try: if os.name == 'nt': - # Windows: Use OpenVPN GUI or direct call - # Note: Requires admin privileges + # Windows: Direct call (requires admin privileges) 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 ) else: - # Linux: Use sudo openvpn + # Linux/macOS: Use sudo openvpn 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, stderr=subprocess.DEVNULL ) @@ -142,3 +177,13 @@ class VPNManager: def is_connected(self) -> bool: """Check if VPN is connected.""" 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 + } diff --git a/openvpn/entrypoint.sh b/openvpn/entrypoint.sh index 51e1039..379eeed 100644 --- a/openvpn/entrypoint.sh +++ b/openvpn/entrypoint.sh @@ -63,6 +63,34 @@ fetch_active_servers() { 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 # ============================================================================= @@ -120,8 +148,9 @@ setup_server() { # CRL 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" + fetch_ccd_files "$server_id" "$server_dir/ccd" # Create status file location touch "$RUN_DIR/openvpn-$server_id.status" @@ -263,12 +292,16 @@ poll_for_changes() { fi done - # Update CRL for all running servers + # Update CRL and CCD files for all running servers for id in $api_server_ids; do 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 && \ mv "$SERVERS_DIR/$id/crl.pem.new" "$SERVERS_DIR/$id/crl.pem" || \ rm -f "$SERVERS_DIR/$id/crl.pem.new" + + # Update CCD files (gateway routes) + fetch_ccd_files "$id" "$SERVERS_DIR/$id/ccd" fi done } diff --git a/server/app/api/connections.py b/server/app/api/connections.py index 9ebc97c..992acbd 100644 --- a/server/app/api/connections.py +++ b/server/app/api/connections.py @@ -11,6 +11,7 @@ from ..models.user import User, UserRole from ..models.access import UserGatewayAccess, ConnectionLog from ..services.vpn_service import VPNService from ..services.firewall_service import FirewallService +from ..services.client_vpn_profile_service import ClientVPNProfileService from .deps import get_current_user router = APIRouter() @@ -92,9 +93,15 @@ def connect_to_endpoint( detail="Endpoint not found" ) - # NOTE: Dynamic VPN config generation has been replaced by VPN profiles. - # Gateways should have pre-provisioned VPN profiles. - # This endpoint now just logs the connection intent. + # Get or create VPN profile for user + client_profile_service = ClientVPNProfileService(db) + 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 connection = ConnectionLog( @@ -107,10 +114,14 @@ def connect_to_endpoint( db.commit() 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( success=True, - message="Connection logged. Use the gateway's VPN profile configuration to connect.", - vpn_config=None, # VPN config is now obtained through gateway VPN profiles + message="VPN configuration ready. Connect to access endpoint.", + vpn_config=vpn_config, target_ip=endpoint.internal_ip, target_port=endpoint.port, connection_id=connection.id diff --git a/server/app/api/internal.py b/server/app/api/internal.py index 11a7855..60e7375 100644 --- a/server/app/api/internal.py +++ b/server/app/api/internal.py @@ -9,12 +9,34 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Response, Query from sqlalchemy.orm import Session +from pydantic import BaseModel + from ..database import get_db from ..models.vpn_server import VPNServer, VPNServerStatus 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_sync_service import VPNSyncService 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_DIR = Path("/var/log/openvpn") @@ -361,3 +383,203 @@ async def debug_sync(db: Session = Depends(get_db)): "connected_clients": connected_clients, "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} diff --git a/server/app/models/__init__.py b/server/app/models/__init__.py index d8fa55b..cba3dab 100644 --- a/server/app/models/__init__.py +++ b/server/app/models/__init__.py @@ -8,6 +8,7 @@ from .access import UserGatewayAccess, UserEndpointAccess, ConnectionLog from .certificate_authority import CertificateAuthority, CAStatus, CAAlgorithm from .vpn_server import VPNServer, VPNProtocol, VPNCipher, VPNAuth, VPNCompression, VPNServerStatus from .vpn_profile import VPNProfile, VPNProfileStatus +from .client_vpn_profile import ClientVPNProfile, ClientVPNProfileStatus from .vpn_connection_log import VPNConnectionLog __all__ = [ @@ -32,5 +33,7 @@ __all__ = [ "VPNServerStatus", "VPNProfile", "VPNProfileStatus", + "ClientVPNProfile", + "ClientVPNProfileStatus", "VPNConnectionLog", ] diff --git a/server/app/models/certificate_authority.py b/server/app/models/certificate_authority.py index 9f58477..25b7997 100644 --- a/server/app/models/certificate_authority.py +++ b/server/app/models/certificate_authority.py @@ -69,6 +69,7 @@ class CertificateAuthority(Base): created_by = relationship("User", foreign_keys=[created_by_id]) vpn_servers = relationship("VPNServer", back_populates="certificate_authority") vpn_profiles = relationship("VPNProfile", back_populates="certificate_authority") + client_vpn_profiles = relationship("ClientVPNProfile", back_populates="certificate_authority") def __repr__(self): return f"" diff --git a/server/app/models/client_vpn_profile.py b/server/app/models/client_vpn_profile.py new file mode 100644 index 0000000..d6846bd --- /dev/null +++ b/server/app/models/client_vpn_profile.py @@ -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"" + + @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 diff --git a/server/app/models/user.py b/server/app/models/user.py index 18562cf..11f0b90 100644 --- a/server/app/models/user.py +++ b/server/app/models/user.py @@ -47,6 +47,12 @@ class User(Base): primaryjoin="User.id == UserEndpointAccess.user_id" ) 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): return f"" diff --git a/server/app/models/vpn_server.py b/server/app/models/vpn_server.py index 46439df..93e3ff3 100644 --- a/server/app/models/vpn_server.py +++ b/server/app/models/vpn_server.py @@ -106,6 +106,7 @@ class VPNServer(Base): tenant = relationship("Tenant", back_populates="vpn_servers") certificate_authority = relationship("CertificateAuthority", back_populates="vpn_servers") vpn_profiles = relationship("VPNProfile", back_populates="vpn_server") + client_vpn_profiles = relationship("ClientVPNProfile", back_populates="vpn_server") def __repr__(self): return f"" diff --git a/server/app/services/__init__.py b/server/app/services/__init__.py index 7821cb7..325b339 100644 --- a/server/app/services/__init__.py +++ b/server/app/services/__init__.py @@ -6,6 +6,7 @@ from .firewall_service import FirewallService from .certificate_service import CertificateService from .vpn_server_service import VPNServerService from .vpn_profile_service import VPNProfileService +from .client_vpn_profile_service import ClientVPNProfileService __all__ = [ "AuthService", @@ -14,4 +15,5 @@ __all__ = [ "CertificateService", "VPNServerService", "VPNProfileService", + "ClientVPNProfileService", ] diff --git a/server/app/services/client_vpn_profile_service.py b/server/app/services/client_vpn_profile_service.py new file mode 100644 index 0000000..c2e7937 --- /dev/null +++ b/server/app/services/client_vpn_profile_service.py @@ -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_cert.strip(), + "", + "", + ]) + + # Add client certificate + config_lines.extend([ + "", + profile.client_cert.strip(), + "", + "", + ]) + + # Add client key + config_lines.extend([ + "", + profile.client_key.strip(), + "", + "", + ]) + + # Add TLS-Auth key if enabled + if server.tls_auth_enabled and server.ta_key: + config_lines.extend([ + "key-direction 1", + "", + server.ta_key.strip(), + "", + ]) + + 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() diff --git a/server/app/services/vpn_server_service.py b/server/app/services/vpn_server_service.py index 8bde003..4858372 100644 --- a/server/app/services/vpn_server_service.py +++ b/server/app/services/vpn_server_service.py @@ -7,8 +7,11 @@ from sqlalchemy.orm import Session from ..models.vpn_server import VPNServer, VPNServerStatus, VPNProtocol, VPNCipher, VPNAuth, VPNCompression from ..models.certificate_authority import CertificateAuthority +from ..models.vpn_profile import VPNProfile +from ..models.gateway import Gateway from .certificate_service import CertificateService from ..config import get_settings +import ipaddress settings = get_settings() @@ -156,6 +159,17 @@ class VPNServerService: 'push "dhcp-option DNS 8.8.8.8"', '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", f"cipher {server.cipher.value}", f"auth {server.auth.value}", @@ -201,6 +215,81 @@ class VPNServerService: 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]: """Get list of currently connected VPN clients.""" try: diff --git a/tools/gatewaysimulator.sh b/tools/gatewaysimulator.sh new file mode 100755 index 0000000..a130fb8 --- /dev/null +++ b/tools/gatewaysimulator.sh @@ -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 "$@"