From 6901dc369b0894ab159379a1f4339f68a7a20aae Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Mon, 2 Feb 2026 09:46:35 +0100 Subject: [PATCH] first commit --- README.md | 536 ++++++++++++++ client/config.py | 29 + client/main.py | 31 + client/requirements.txt | 14 + client/services/__init__.py | 6 + client/services/api_client.py | 175 +++++ client/services/vpn_manager.py | 144 ++++ client/ui/__init__.py | 6 + client/ui/login_dialog.py | 95 +++ client/ui/main_window.py | 387 ++++++++++ docker-compose.yml | 94 +++ mguard_router_typen.txt | 19 + openvpn/Dockerfile | 49 ++ openvpn/entrypoint.sh | 406 ++++++++++ openvpn/scripts/client-connect.sh | 23 + openvpn/scripts/client-disconnect.sh | 24 + openvpn/supervisord.conf | 22 + provisioning-tool/config_generator.py | 206 ++++++ provisioning-tool/main.py | 314 ++++++++ provisioning-tool/mguard_api.py | 175 +++++ provisioning-tool/requirements.txt | 14 + server/Dockerfile | 28 + server/app/__init__.py | 2 + server/app/api/__init__.py | 15 + server/app/api/auth.py | 57 ++ server/app/api/connections.py | 236 ++++++ server/app/api/deps.py | 87 +++ server/app/api/endpoints.py | 231 ++++++ server/app/api/gateways.py | 335 +++++++++ server/app/api/internal.py | 363 +++++++++ server/app/api/tenants.py | 103 +++ server/app/api/users.py | 176 +++++ server/app/config.py | 41 + server/app/database.py | 36 + server/app/main.py | 154 ++++ server/app/models/__init__.py | 36 + server/app/models/access.py | 78 ++ server/app/models/certificate_authority.py | 99 +++ server/app/models/endpoint.py | 87 +++ server/app/models/gateway.py | 91 +++ server/app/models/tenant.py | 28 + server/app/models/user.py | 62 ++ server/app/models/vpn_connection_log.py | 50 ++ server/app/models/vpn_profile.py | 90 +++ server/app/models/vpn_server.py | 130 ++++ server/app/schemas/__init__.py | 25 + server/app/schemas/endpoint.py | 76 ++ server/app/schemas/gateway.py | 82 ++ server/app/schemas/tenant.py | 33 + server/app/schemas/user.py | 63 ++ server/app/services/__init__.py | 17 + server/app/services/auth_service.py | 86 +++ server/app/services/certificate_service.py | 588 +++++++++++++++ server/app/services/firewall_service.py | 129 ++++ server/app/services/vpn_profile_service.py | 282 +++++++ server/app/services/vpn_server_service.py | 344 +++++++++ server/app/services/vpn_service.py | 95 +++ server/app/services/vpn_sync_service.py | 182 +++++ server/app/static/css/custom.css | 196 +++++ server/app/static/js/app.js | 82 ++ server/app/templates/applications/form.html | 73 ++ server/app/templates/applications/list.html | 68 ++ server/app/templates/auth/login.html | 64 ++ server/app/templates/base.html | 142 ++++ server/app/templates/connections/log.html | 64 ++ server/app/templates/dashboard/index.html | 139 ++++ server/app/templates/gateways/detail.html | 262 +++++++ server/app/templates/gateways/form.html | 106 +++ server/app/templates/gateways/list.html | 60 ++ .../templates/gateways/profile_detail.html | 231 ++++++ .../app/templates/gateways/profile_form.html | 151 ++++ server/app/templates/gateways/profiles.html | 155 ++++ server/app/templates/tenants/form.html | 51 ++ server/app/templates/tenants/list.html | 63 ++ server/app/templates/users/access.html | 134 ++++ server/app/templates/users/form.html | 99 +++ server/app/templates/users/list.html | 83 +++ server/app/templates/vpn_servers/clients.html | 70 ++ server/app/templates/vpn_servers/detail.html | 260 +++++++ server/app/templates/vpn_servers/form.html | 204 +++++ server/app/templates/vpn_servers/list.html | 93 +++ server/app/utils/__init__.py | 11 + server/app/utils/security.py | 75 ++ server/app/web/__init__.py | 23 + server/app/web/applications.py | 156 ++++ server/app/web/auth.py | 58 ++ server/app/web/ca.py | 301 ++++++++ server/app/web/connections.py | 27 + server/app/web/dashboard.py | 57 ++ server/app/web/deps.py | 61 ++ server/app/web/gateways.py | 250 +++++++ server/app/web/htmx.py | 698 ++++++++++++++++++ server/app/web/tenants.py | 151 ++++ server/app/web/users.py | 144 ++++ server/app/web/vpn_profiles.py | 394 ++++++++++ server/app/web/vpn_servers.py | 349 +++++++++ server/init.sql | 8 + server/requirements.txt | 31 + 98 files changed, 13030 insertions(+) create mode 100644 README.md create mode 100644 client/config.py create mode 100644 client/main.py create mode 100644 client/requirements.txt create mode 100644 client/services/__init__.py create mode 100644 client/services/api_client.py create mode 100644 client/services/vpn_manager.py create mode 100644 client/ui/__init__.py create mode 100644 client/ui/login_dialog.py create mode 100644 client/ui/main_window.py create mode 100644 docker-compose.yml create mode 100644 mguard_router_typen.txt create mode 100644 openvpn/Dockerfile create mode 100644 openvpn/entrypoint.sh create mode 100644 openvpn/scripts/client-connect.sh create mode 100644 openvpn/scripts/client-disconnect.sh create mode 100644 openvpn/supervisord.conf create mode 100644 provisioning-tool/config_generator.py create mode 100644 provisioning-tool/main.py create mode 100644 provisioning-tool/mguard_api.py create mode 100644 provisioning-tool/requirements.txt create mode 100644 server/Dockerfile create mode 100644 server/app/__init__.py create mode 100644 server/app/api/__init__.py create mode 100644 server/app/api/auth.py create mode 100644 server/app/api/connections.py create mode 100644 server/app/api/deps.py create mode 100644 server/app/api/endpoints.py create mode 100644 server/app/api/gateways.py create mode 100644 server/app/api/internal.py create mode 100644 server/app/api/tenants.py create mode 100644 server/app/api/users.py create mode 100644 server/app/config.py create mode 100644 server/app/database.py create mode 100644 server/app/main.py create mode 100644 server/app/models/__init__.py create mode 100644 server/app/models/access.py create mode 100644 server/app/models/certificate_authority.py create mode 100644 server/app/models/endpoint.py create mode 100644 server/app/models/gateway.py create mode 100644 server/app/models/tenant.py create mode 100644 server/app/models/user.py create mode 100644 server/app/models/vpn_connection_log.py create mode 100644 server/app/models/vpn_profile.py create mode 100644 server/app/models/vpn_server.py create mode 100644 server/app/schemas/__init__.py create mode 100644 server/app/schemas/endpoint.py create mode 100644 server/app/schemas/gateway.py create mode 100644 server/app/schemas/tenant.py create mode 100644 server/app/schemas/user.py create mode 100644 server/app/services/__init__.py create mode 100644 server/app/services/auth_service.py create mode 100644 server/app/services/certificate_service.py create mode 100644 server/app/services/firewall_service.py create mode 100644 server/app/services/vpn_profile_service.py create mode 100644 server/app/services/vpn_server_service.py create mode 100644 server/app/services/vpn_service.py create mode 100644 server/app/services/vpn_sync_service.py create mode 100644 server/app/static/css/custom.css create mode 100644 server/app/static/js/app.js create mode 100644 server/app/templates/applications/form.html create mode 100644 server/app/templates/applications/list.html create mode 100644 server/app/templates/auth/login.html create mode 100644 server/app/templates/base.html create mode 100644 server/app/templates/connections/log.html create mode 100644 server/app/templates/dashboard/index.html create mode 100644 server/app/templates/gateways/detail.html create mode 100644 server/app/templates/gateways/form.html create mode 100644 server/app/templates/gateways/list.html create mode 100644 server/app/templates/gateways/profile_detail.html create mode 100644 server/app/templates/gateways/profile_form.html create mode 100644 server/app/templates/gateways/profiles.html create mode 100644 server/app/templates/tenants/form.html create mode 100644 server/app/templates/tenants/list.html create mode 100644 server/app/templates/users/access.html create mode 100644 server/app/templates/users/form.html create mode 100644 server/app/templates/users/list.html create mode 100644 server/app/templates/vpn_servers/clients.html create mode 100644 server/app/templates/vpn_servers/detail.html create mode 100644 server/app/templates/vpn_servers/form.html create mode 100644 server/app/templates/vpn_servers/list.html create mode 100644 server/app/utils/__init__.py create mode 100644 server/app/utils/security.py create mode 100644 server/app/web/__init__.py create mode 100644 server/app/web/applications.py create mode 100644 server/app/web/auth.py create mode 100644 server/app/web/ca.py create mode 100644 server/app/web/connections.py create mode 100644 server/app/web/dashboard.py create mode 100644 server/app/web/deps.py create mode 100644 server/app/web/gateways.py create mode 100644 server/app/web/htmx.py create mode 100644 server/app/web/tenants.py create mode 100644 server/app/web/users.py create mode 100644 server/app/web/vpn_profiles.py create mode 100644 server/app/web/vpn_servers.py create mode 100644 server/init.sql create mode 100644 server/requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..b834cca --- /dev/null +++ b/README.md @@ -0,0 +1,536 @@ +# mGuard VPN Endpoint Server + +VPN-Management-System für Phoenix Contact mGuard Router mit Web-Oberfläche. + +## Übersicht + +Dieses System ermöglicht die zentrale Verwaltung von mGuard VPN-Gateways und den sicheren Fernzugriff auf Endpunkte (SPS, HMI, etc.) hinter diesen Gateways. + +### Komponenten + +| Komponente | Beschreibung | +|------------|--------------| +| **Server + Web-UI** | Docker-basiert mit FastAPI, MariaDB, OpenVPN und Web-Dashboard | +| **Desktop Client** | PyQt6 GUI-Anwendung für Techniker (Windows/Linux) | +| **Provisioning Tool** | CLI-Tool für Gateway-Provisionierung | + +### Unterstützte Router + +| Router | Firmware | Provisionierung | +|--------|----------|-----------------| +| FL MGUARD 2000/4000 | 10.5.x | REST API | +| FL MGUARD RS4000 | 8.9 | SSH / ATV-Datei | +| FL MGUARD 1000 | 1.x | REST API | + +--- + +## Architektur + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ DOCKER COMPOSE STACK │ +│ │ +│ ┌──────────────────────┐ ┌───────────────────────┐ │ +│ │ FastAPI Server │ │ MariaDB │ │ +│ │ :8000 │ │ :3306 │ │ +│ │ ├── REST API │ │ │ │ +│ │ ├── Web UI (HTMX) │ │ ├── CAs │ │ +│ │ └── PKI-Verwaltung │ │ ├── VPN Servers │ │ +│ └──────────┬───────────┘ │ ├── VPN Profiles │ │ +│ │ │ ├── Gateways │ │ +│ └──────────────┴──┴── Users │ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + Interne API (localhost) + │ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ OpenVPN Container (Host-Netzwerk) │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ supervisord │ │ +│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │ +│ │ │ openvpn-1 │ │ openvpn-2 │ │ openvpn-N │ │ │ +│ │ │ UDP:1194 │ │ TCP:443 │ │ UDP:1195 │ │ │ +│ │ │ (Produktion) │ │ (Firewall) │ │ (Backup) │ │ │ +│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Polling alle 30s: Neue Server automatisch starten/stoppen │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + Host-Ports direkt (kein Docker NAT) + │ + ┌─────────────┴─────────────┐ + ▼ ▼ +┌───────────────────┐ ┌─────────────────┐ +│ mGuard Gateway │ │ Web Browser │ +│ (beim Kunden) │ │ (Admin) │ +└───────────────────┘ └─────────────────┘ +``` + +### Multi-Server Architektur + +Der OpenVPN-Container verwaltet **automatisch mehrere VPN-Server-Instanzen**: + +- Läuft im **Host-Netzwerk-Modus** für direkte Port-Bindung +- Verwendet **supervisord** für Prozess-Management +- **Pollt alle 30 Sekunden** die API für neue/geänderte Server +- **Keine docker-compose.yml Änderungen** nötig beim Hinzufügen neuer VPN-Server! + +``` +Datenbank OpenVPN Container +┌─────────────────────┐ ┌─────────────────────┐ +│ VPN-Server │ │ supervisord │ +│ ├── ID: 1 (aktiv) │ ──Poll──▶ │ ├── openvpn-1 │ +│ ├── ID: 2 (aktiv) │ │ ├── openvpn-2 │ +│ └── ID: 3 (inaktiv)│ │ └── (kein 3) │ +└─────────────────────┘ └─────────────────────┘ +``` + +### PKI-Verwaltung über Web-UI + +Die gesamte PKI (Zertifizierungsstellen, Zertifikate, VPN-Server) wird über die Web-Oberfläche verwaltet: + +| Bereich | Beschreibung | +|---------|-------------- +| **Zertifizierungsstellen (CA)** | Eigene CA erstellen oder importieren | +| **VPN-Server** | Mehrere Server mit unterschiedlichen Ports/Protokollen | +| **VPN-Profile** | Pro Gateway mehrere Profile für Failover/Migration | + +--- + +## Schnellstart + +### Voraussetzungen + +- Docker & Docker Compose +- Mindestens 2 GB RAM für die Container +- Port 8000 (Web/API) und gewünschte VPN-Ports frei + +### 1. Installation + +```bash +# Repository klonen oder Dateien kopieren +cd endpoint-server-openvpn + +# Konfiguration erstellen +cp .env.example .env + +# .env bearbeiten und Passwörter anpassen! +nano .env +``` + +### 2. Container starten + +```bash +# Container bauen und starten +docker-compose up -d + +# Logs prüfen +docker-compose logs -f +``` + +### 3. Ersteinrichtung (Web-UI) + +Nach dem Start unter **http://localhost:8000/** einloggen. + +Standard-Login: +- **Benutzername:** `admin` +- **Passwort:** `changeme` (in .env ändern!) + +#### Schritt 1: CA erstellen + +1. Navigation: **VPN → Zertifizierungsstellen** +2. **Neue CA erstellen** klicken +3. Formular ausfüllen: + - Name: z.B. "Produktion CA" + - Organisation, Land, Stadt + - Schlüsselgröße: 4096 (empfohlen) oder 2048 + - Gültigkeit: 3650 Tage (10 Jahre) +4. **Erstellen** - DH-Parameter werden im Hintergrund generiert + +#### Schritt 2: VPN-Server erstellen + +1. Navigation: **VPN → VPN-Server** +2. **Neuer VPN-Server** klicken +3. Formular ausfüllen: + - Name: z.B. "Produktion UDP" + - CA auswählen (aus Schritt 1) + - Hostname: Öffentliche IP oder Domain + - Port: 1194 + - Protokoll: UDP (empfohlen) oder TCP +4. **Erstellen** - Server-Zertifikat wird automatisch generiert + +#### Schritt 3: Automatischer Start + +Der OpenVPN-Container erkennt neue Server automatisch (Polling alle 30s): + +```bash +# Logs prüfen - Server sollte automatisch starten +docker-compose logs -f openvpn + +# Ausgabe sollte zeigen: +# [2024-01-15 10:30:00] New server detected: 1 +# [2024-01-15 10:30:00] Setting up server 1 (Produktion UDP) on port 1194/udp +# [2024-01-15 10:30:01] Server 1 configured successfully +``` + +**Kein Container-Neustart nötig!** + +--- + +## Mehrere VPN-Server betreiben + +### Automatische Verwaltung + +Einfach weitere VPN-Server über die Web-UI erstellen: + +| Server | Port | Protokoll | Verwendung | +|--------|------|-----------|------------| +| Produktion UDP | 1194 | UDP | Standard, schnell | +| Produktion TCP | 443 | TCP | Firewall-Bypass | +| Backup | 1195 | UDP | Fallback-Server | + +Der Container startet diese automatisch - **keine Konfigurationsänderungen nötig!** + +### Firewall-Regeln + +Da der Container im Host-Netzwerk läuft, müssen die VPN-Ports auf dem Host freigegeben sein: + +```bash +# Beispiel für iptables +sudo iptables -A INPUT -p udp --dport 1194 -j ACCEPT +sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT +sudo iptables -A INPUT -p udp --dport 1195 -j ACCEPT + +# Oder via ufw +sudo ufw allow 1194/udp +sudo ufw allow 443/tcp +sudo ufw allow 1195/udp +``` + +--- + +## Gateway einrichten + +### 1. Gateway anlegen + +1. Navigation: **Gateways → Neues Gateway** +2. Formular ausfüllen: + - Name: z.B. "Kunde ABC - Türsteuerung Halle 1" + - Router-Typ: FL MGUARD 2000 + - Standort: Halle 1, Raum 102 + +### 2. VPN-Profil erstellen + +1. Gateway öffnen → **VPN-Profile** Tab +2. **Neues Profil** klicken +3. Formular: + - Name: z.B. "Produktion" + - VPN-Server: Server auswählen + - Priorität: 1 (primär) +4. **Erstellen** - Client-Zertifikat wird generiert + +### 3. Provisioning-Download + +1. VPN-Profil öffnen +2. **Konfiguration herunterladen** klicken +3. `.ovpn` Datei auf mGuard Router übertragen + +### Failover mit mehreren Profilen + +Für Ausfallsicherheit mehrere Profile mit unterschiedlichen Prioritäten: + +| Profil | VPN-Server | Priorität | Verwendung | +|--------|------------|-----------|------------| +| Produktion | Server 1 (UDP) | 1 | Primärer Server | +| Fallback | Server 2 (TCP) | 2 | Bei Ausfall von Server 1 | + +--- + +## Endpunkte definieren + +Endpunkte sind Geräte hinter dem Gateway (SPS, HMI, etc.): + +1. Gateway öffnen → **Endpunkte** Tab +2. **Endpunkt hinzufügen** +3. Ausfüllen: + - Name: HMI Türsteuerung + - IP-Adresse: 10.0.0.3 + - Port: 11740 + - Protokoll: TCP + - Anwendung: CoDeSys + +--- + +## Web-Oberfläche + +### Navigation + +| Bereich | Beschreibung | +|---------|-------------- +| **Dashboard** | Gateway-Status, aktive Verbindungen, Statistiken | +| **Gateways** | Gateway-Verwaltung, Endpunkte, VPN-Profile | +| **VPN → VPN-Server** | VPN-Instanzen verwalten, Status, Clients | +| **VPN → Zertifizierungsstellen** | CA erstellen/importieren, Zertifikate | +| **Benutzer** | Techniker anlegen, Rollen zuweisen | +| **Verbindungen** | Live-Anzeige aktiver Verbindungen, Historie | + +--- + +## Benutzer & Berechtigungen + +### Rollen + +| Rolle | Rechte | +|-------|--------| +| **super_admin** | Alle Mandanten, alle Gateways, CAs, VPN-Server | +| **admin** | Eigener Mandant: Gateways, Benutzer, Endpunkte | +| **technician** | Nur zugewiesene Gateways sehen und verbinden | +| **viewer** | Nur lesen, keine Verbindungen | + +--- + +## Wartung & Betrieb + +### Logs ansehen + +```bash +# Alle Logs +docker-compose logs -f + +# Nur API-Server +docker-compose logs -f api + +# Nur OpenVPN (alle Server) +docker-compose logs -f openvpn + +# Einzelner VPN-Server im Container +docker exec mguard-openvpn cat /var/log/openvpn/server-1.log +``` + +### Server-Status prüfen + +```bash +# Alle laufenden OpenVPN-Prozesse +docker exec mguard-openvpn supervisorctl status + +# Ausgabe: +# openvpn-1 RUNNING pid 123, uptime 0:05:30 +# openvpn-2 RUNNING pid 456, uptime 0:05:28 +``` + +### Einzelnen Server neustarten + +```bash +# Via supervisorctl +docker exec mguard-openvpn supervisorctl restart openvpn-1 +``` + +### Backup + +```bash +# Datenbank-Backup (enthält CAs, Zertifikate, Konfiguration) +docker-compose exec db mysqldump -u root -p mguard_vpn > backup.sql + +# Volumes auflisten +docker volume ls | grep mguard +``` + +**Wichtig:** Die PKI (CAs, Zertifikate, Private Keys) ist in der Datenbank gespeichert. Ein Datenbank-Backup sichert alles. + +### Update + +```bash +# Container stoppen +docker-compose down + +# Neue Version pullen/kopieren +git pull + +# Neu bauen und starten +docker-compose up -d --build +``` + +--- + +## Fehlerbehebung + +### OpenVPN startet nicht + +```bash +# Logs prüfen +docker-compose logs openvpn +``` + +Mögliche Ursachen: +- **"API not ready"**: API-Container noch nicht gestartet +- **"No active VPN servers found"**: VPN-Server in Web-UI erstellen +- **"Failed to fetch server config"**: Server nicht bereit (CA/Zertifikat fehlt) + +### Neuer VPN-Server wird nicht gestartet + +1. Prüfe ob Server in Web-UI als "Bereit" markiert ist +2. Warte 30 Sekunden (Polling-Intervall) +3. Prüfe Logs: `docker-compose logs -f openvpn` + +### Port bereits belegt + +```bash +# Prüfen welcher Prozess den Port nutzt +sudo netstat -tlnp | grep 1194 +sudo lsof -i :1194 + +# Falls nötig: Prozess beenden oder anderen Port in Web-UI wählen +``` + +### VPN-Server zeigt "Ausstehend" + +DH-Parameter werden noch generiert. Status in der VPN-Server-Übersicht prüfen. + +```bash +# CA-Status in Datenbank prüfen +docker-compose exec db mysql -u mguard -p -e \ + "SELECT name, status FROM mguard_vpn.certificate_authorities" +``` + +### Gateway verbindet nicht + +1. VPN-Profil für Gateway erstellt? +2. Provisioning-Datei heruntergeladen und importiert? +3. Firewall-Ports offen? +4. VPN-Server Status prüfen: **VPN → VPN-Server → Details** + +--- + +## Verzeichnisstruktur + +``` +endpoint-server-openvpn/ +├── docker-compose.yml # Container-Orchestrierung +├── .env.example # Konfigurationsvorlage +├── README.md # Diese Datei +│ +├── server/ # FastAPI Backend + Web-UI +│ ├── Dockerfile +│ ├── requirements.txt +│ └── app/ +│ ├── main.py # Einstiegspunkt +│ ├── api/ # REST API Routes +│ │ └── internal.py # Interne API für OpenVPN +│ ├── web/ # Web-UI Routes +│ │ ├── ca.py # CA-Verwaltung +│ │ ├── vpn_servers.py # VPN-Server +│ │ └── vpn_profiles.py # VPN-Profile +│ ├── templates/ # Jinja2 HTML-Templates +│ ├── models/ # Datenbank-Modelle +│ │ ├── certificate_authority.py +│ │ ├── vpn_server.py +│ │ └── vpn_profile.py +│ └── services/ # Business-Logik +│ ├── certificate_service.py +│ ├── vpn_server_service.py +│ └── vpn_profile_service.py +│ +├── openvpn/ # OpenVPN Multi-Server Container +│ ├── Dockerfile +│ ├── entrypoint.sh # Multi-Server Management Script +│ └── supervisord.conf # Prozess-Manager Konfiguration +│ +├── client/ # PyQt Desktop-Client +│ └── ... +│ +└── provisioning-tool/ # CLI Provisioning + └── ... +``` + +--- + +## Sicherheit + +- **TLS 1.2+** für VPN-Verbindungen +- **AES-256-GCM** Verschlüsselung (konfigurierbar) +- **JWT-Token** für API-Authentifizierung +- **Session-Cookies** für Web-UI +- **bcrypt** Password-Hashing +- **Multi-Tenant** Datenisolation +- **CRL** für Zertifikatswiderruf + +### Produktiv-Empfehlungen + +1. `.env` Passwörter ändern! +2. HTTPS-Reverse-Proxy (nginx/traefik) vorschalten +3. Firewall: Nur VPN-Ports und HTTPS nach außen +4. Regelmäßige Datenbank-Backups +5. Schlüsselgröße 4096-bit für CAs + +### Host-Netzwerk Hinweis + +Der OpenVPN-Container läuft im Host-Netzwerk-Modus. Das bedeutet: +- Direkte Port-Bindung (kein Docker NAT) +- Weniger Netzwerk-Overhead +- Container kann auf localhost:8000 zugreifen +- Firewall-Regeln auf Host-Ebene erforderlich + +--- + +## REST API + +Die API ist dokumentiert unter: **http://localhost:8000/api/docs** + +### Wichtige Endpoints + +| Methode | Endpoint | Beschreibung | +|---------|----------|--------------| +| POST | `/api/auth/login` | Login | +| GET | `/api/gateways` | Gateway-Liste | +| GET | `/api/gateways/{id}/profiles` | VPN-Profile eines Gateways | +| GET | `/api/gateways/{id}/profiles/{pid}/provision` | Provisioning-Download | + +### Interne API (für OpenVPN-Container) + +| Endpoint | Beschreibung | +|----------|--------------| +| `/api/internal/health` | Health Check | +| `/api/internal/vpn-servers/active` | Liste aller aktiven Server | +| `/api/internal/vpn-servers/{id}/config` | Server-Konfiguration | +| `/api/internal/vpn-servers/{id}/ca` | CA-Zertifikat | +| `/api/internal/vpn-servers/{id}/cert` | Server-Zertifikat | +| `/api/internal/vpn-servers/{id}/key` | Server Private Key | +| `/api/internal/vpn-servers/{id}/dh` | DH-Parameter | +| `/api/internal/vpn-servers/{id}/crl` | Revocation List | +| `/api/internal/vpn-servers/{id}/started` | Server-Start melden | +| `/api/internal/vpn-servers/{id}/stopped` | Server-Stop melden | + +--- + +## Umgebungsvariablen + +### OpenVPN Container + +| Variable | Standard | Beschreibung | +|----------|----------|--------------| +| `API_URL` | `http://127.0.0.1:8000/api/internal` | API-Endpunkt | +| `API_TIMEOUT` | `120` | Timeout beim Warten auf API (Sekunden) | +| `API_RETRY_INTERVAL` | `5` | Wiederholungsintervall (Sekunden) | +| `POLL_INTERVAL` | `30` | Polling-Intervall für neue Server (Sekunden) | + +--- + +## Container neu bauen + +```bash +# Stoppen +docker-compose down + +# Komplett neu bauen +docker-compose build --no-cache + +# Starten +docker-compose up -d +``` + +--- + +## Lizenz + +Proprietär - Nur für internen Gebrauch. diff --git a/client/config.py b/client/config.py new file mode 100644 index 0000000..db61629 --- /dev/null +++ b/client/config.py @@ -0,0 +1,29 @@ +"""Client configuration.""" + +import os +from pathlib import Path + +# Application info +APP_NAME = "mGuard VPN Client" +APP_VERSION = "1.0.0" + +# Default server settings +DEFAULT_SERVER_URL = "http://localhost:8000" + +# OpenVPN paths +if os.name == 'nt': # Windows + OPENVPN_EXE = r"C:\Program Files\OpenVPN\bin\openvpn.exe" + OPENVPN_CONFIG_DIR = Path.home() / "OpenVPN" / "config" +else: # Linux/Mac + OPENVPN_EXE = "/usr/sbin/openvpn" + OPENVPN_CONFIG_DIR = Path.home() / ".openvpn" + +# Ensure config directory exists +OPENVPN_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + +# Local storage +APP_DATA_DIR = Path.home() / ".mguard-vpn" +APP_DATA_DIR.mkdir(parents=True, exist_ok=True) + +# Settings file +SETTINGS_FILE = APP_DATA_DIR / "settings.json" diff --git a/client/main.py b/client/main.py new file mode 100644 index 0000000..97e9b14 --- /dev/null +++ b/client/main.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""mGuard VPN Client - Main Entry Point.""" + +import sys +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import Qt + +from config import APP_NAME +from ui.main_window import MainWindow + + +def main(): + """Main entry point.""" + # Enable high DPI scaling + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + + app = QApplication(sys.argv) + app.setApplicationName(APP_NAME) + app.setStyle("Fusion") + + # Create and show main window + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/client/requirements.txt b/client/requirements.txt new file mode 100644 index 0000000..0a01b14 --- /dev/null +++ b/client/requirements.txt @@ -0,0 +1,14 @@ +# PyQt GUI +PyQt6==6.6.1 + +# HTTP Client +httpx==0.26.0 + +# Configuration +python-dotenv==1.0.0 + +# Keyring for secure credential storage +keyring==24.3.0 + +# For Windows OpenVPN management +pywin32==306; sys_platform == 'win32' diff --git a/client/services/__init__.py b/client/services/__init__.py new file mode 100644 index 0000000..0f86a9d --- /dev/null +++ b/client/services/__init__.py @@ -0,0 +1,6 @@ +"""Client services.""" + +from .api_client import APIClient +from .vpn_manager import VPNManager + +__all__ = ["APIClient", "VPNManager"] diff --git a/client/services/api_client.py b/client/services/api_client.py new file mode 100644 index 0000000..becc79a --- /dev/null +++ b/client/services/api_client.py @@ -0,0 +1,175 @@ +"""REST API client for server communication.""" + +import httpx +from typing import Optional +from dataclasses import dataclass + + +@dataclass +class Gateway: + """Gateway data class.""" + id: int + name: str + description: Optional[str] + location: Optional[str] + router_type: str + is_online: bool + vpn_ip: Optional[str] + + +@dataclass +class Endpoint: + """Endpoint data class.""" + id: int + gateway_id: int + name: str + description: Optional[str] + internal_ip: str + port: int + protocol: str + application_name: Optional[str] + + +class APIClient: + """REST API client for mGuard VPN Server.""" + + def __init__(self, base_url: str): + self.base_url = base_url.rstrip('/') + self.access_token: Optional[str] = None + self.refresh_token: Optional[str] = None + self.client = httpx.Client(timeout=30.0) + + def _headers(self) -> dict: + """Get request headers with auth token.""" + headers = {"Content-Type": "application/json"} + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + return headers + + def _request(self, method: str, endpoint: str, **kwargs) -> dict: + """Make authenticated request.""" + url = f"{self.base_url}{endpoint}" + response = self.client.request(method, url, headers=self._headers(), **kwargs) + response.raise_for_status() + return response.json() if response.text else {} + + def login(self, username: str, password: str) -> bool: + """Login and store tokens.""" + try: + response = self.client.post( + f"{self.base_url}/api/auth/login", + json={"username": username, "password": password} + ) + response.raise_for_status() + data = response.json() + self.access_token = data["access_token"] + self.refresh_token = data["refresh_token"] + return True + except httpx.HTTPError: + return False + + def logout(self): + """Clear stored tokens.""" + self.access_token = None + self.refresh_token = None + + def refresh_access_token(self) -> bool: + """Refresh access token using refresh token.""" + if not self.refresh_token: + return False + try: + response = self.client.post( + f"{self.base_url}/api/auth/refresh", + params={"refresh_token": self.refresh_token} + ) + response.raise_for_status() + data = response.json() + self.access_token = data["access_token"] + self.refresh_token = data["refresh_token"] + return True + except httpx.HTTPError: + return False + + def get_current_user(self) -> Optional[dict]: + """Get current user information.""" + try: + return self._request("GET", "/api/auth/me") + except httpx.HTTPError: + return None + + def get_gateways(self) -> list[Gateway]: + """Get list of accessible gateways.""" + try: + data = self._request("GET", "/api/gateways") + return [ + Gateway( + id=g["id"], + name=g["name"], + description=g.get("description"), + location=g.get("location"), + router_type=g["router_type"], + is_online=g["is_online"], + vpn_ip=g.get("vpn_ip") + ) + for g in data + ] + except httpx.HTTPError: + return [] + + def get_gateways_status(self) -> list[dict]: + """Get online status of all gateways.""" + try: + return self._request("GET", "/api/gateways/status") + except httpx.HTTPError: + return [] + + def get_endpoints(self, gateway_id: int) -> list[Endpoint]: + """Get endpoints for a gateway.""" + try: + data = self._request("GET", f"/api/endpoints/gateway/{gateway_id}") + return [ + Endpoint( + id=e["id"], + gateway_id=e["gateway_id"], + name=e["name"], + description=e.get("description"), + internal_ip=e["internal_ip"], + port=e["port"], + protocol=e["protocol"], + application_name=e.get("application_name") + ) + for e in data + ] + except httpx.HTTPError: + return [] + + def connect(self, gateway_id: int, endpoint_id: int) -> dict: + """Request connection to endpoint.""" + try: + return self._request( + "POST", "/api/connections/connect", + json={"gateway_id": gateway_id, "endpoint_id": endpoint_id} + ) + except httpx.HTTPError as e: + return {"success": False, "message": str(e)} + + def disconnect(self, connection_id: int) -> dict: + """Disconnect from endpoint.""" + try: + return self._request( + "POST", "/api/connections/disconnect", + json={"connection_id": connection_id} + ) + except httpx.HTTPError as e: + return {"message": str(e)} + + def get_active_connections(self) -> list[dict]: + """Get list of active connections.""" + try: + return self._request("GET", "/api/connections/active") + except httpx.HTTPError: + return [] + + def close(self): + """Close HTTP client.""" + self.client.close() diff --git a/client/services/vpn_manager.py b/client/services/vpn_manager.py new file mode 100644 index 0000000..bb5e50f --- /dev/null +++ b/client/services/vpn_manager.py @@ -0,0 +1,144 @@ +"""OpenVPN process management.""" + +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Optional +from dataclasses import dataclass + +from config import OPENVPN_EXE, OPENVPN_CONFIG_DIR + + +@dataclass +class VPNStatus: + """VPN connection status.""" + connected: bool + vpn_ip: Optional[str] = None + error: Optional[str] = None + + +class VPNManager: + """Manages OpenVPN connections.""" + + def __init__(self): + self.process: Optional[subprocess.Popen] = None + self.config_file: Optional[Path] = None + self.log_file: Optional[Path] = None + + def check_openvpn_installed(self) -> bool: + """Check if OpenVPN is installed.""" + if os.name == 'nt': + return Path(OPENVPN_EXE).exists() + else: + try: + subprocess.run(["which", "openvpn"], capture_output=True, check=True) + return True + except subprocess.CalledProcessError: + return False + + def connect(self, config_content: str) -> VPNStatus: + """Connect using provided OpenVPN config.""" + if self.process and self.process.poll() is None: + return VPNStatus(connected=False, error="Already connected") + + if not self.check_openvpn_installed(): + return VPNStatus( + connected=False, + error="OpenVPN is not installed. Please install OpenVPN first." + ) + + # Write config to temp file + self.config_file = OPENVPN_CONFIG_DIR / "mguard-temp.ovpn" + self.config_file.write_text(config_content) + + # Log file + self.log_file = OPENVPN_CONFIG_DIR / "mguard.log" + + try: + if os.name == 'nt': + # Windows: Use OpenVPN GUI or direct call + # Note: Requires admin privileges + self.process = subprocess.Popen( + [OPENVPN_EXE, "--config", str(self.config_file), "--log", str(self.log_file)], + creationflags=subprocess.CREATE_NO_WINDOW + ) + else: + # Linux: Use sudo openvpn + self.process = subprocess.Popen( + ["sudo", "openvpn", "--config", str(self.config_file), "--log", str(self.log_file)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + # Wait a bit for connection + import time + time.sleep(3) + + if self.process.poll() is not None: + # Process ended, check for errors + if self.log_file.exists(): + log_content = self.log_file.read_text() + return VPNStatus(connected=False, error=f"Connection failed: {log_content[-500:]}") + return VPNStatus(connected=False, error="Connection failed") + + return VPNStatus(connected=True) + + except PermissionError: + return VPNStatus( + connected=False, + error="Permission denied. Run as administrator/root." + ) + except Exception as e: + return VPNStatus(connected=False, error=str(e)) + + def disconnect(self) -> VPNStatus: + """Disconnect VPN.""" + if not self.process: + return VPNStatus(connected=False) + + try: + self.process.terminate() + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + + self.process = None + + # Clean up temp config + if self.config_file and self.config_file.exists(): + self.config_file.unlink() + + return VPNStatus(connected=False) + + def get_status(self) -> VPNStatus: + """Get current VPN status.""" + if not self.process: + return VPNStatus(connected=False) + + if self.process.poll() is not None: + # Process has ended + self.process = None + return VPNStatus(connected=False) + + # Try to get VPN IP from log + vpn_ip = None + if self.log_file and self.log_file.exists(): + try: + log_content = self.log_file.read_text() + # Parse for IP assignment + for line in log_content.split('\n'): + if 'ifconfig' in line.lower() and 'netmask' in line.lower(): + parts = line.split() + for i, part in enumerate(parts): + if part == 'ifconfig' and i + 1 < len(parts): + vpn_ip = parts[i + 1] + break + except Exception: + pass + + return VPNStatus(connected=True, vpn_ip=vpn_ip) + + def is_connected(self) -> bool: + """Check if VPN is connected.""" + return self.process is not None and self.process.poll() is None diff --git a/client/ui/__init__.py b/client/ui/__init__.py new file mode 100644 index 0000000..d050131 --- /dev/null +++ b/client/ui/__init__.py @@ -0,0 +1,6 @@ +"""UI components.""" + +from .main_window import MainWindow +from .login_dialog import LoginDialog + +__all__ = ["MainWindow", "LoginDialog"] diff --git a/client/ui/login_dialog.py b/client/ui/login_dialog.py new file mode 100644 index 0000000..41321b5 --- /dev/null +++ b/client/ui/login_dialog.py @@ -0,0 +1,95 @@ +"""Login dialog for server authentication.""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, + QLineEdit, QPushButton, QLabel, QMessageBox, QComboBox +) +from PyQt6.QtCore import Qt + +from config import DEFAULT_SERVER_URL, APP_NAME + + +class LoginDialog(QDialog): + """Login dialog for user authentication.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(f"{APP_NAME} - Login") + self.setMinimumWidth(400) + self.setModal(True) + + self._setup_ui() + + def _setup_ui(self): + """Setup UI components.""" + layout = QVBoxLayout(self) + + # Title + title = QLabel(APP_NAME) + title.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(title) + + # Form + form = QFormLayout() + + # Server URL + self.server_input = QComboBox() + self.server_input.setEditable(True) + self.server_input.addItem(DEFAULT_SERVER_URL) + self.server_input.setCurrentText(DEFAULT_SERVER_URL) + form.addRow("Server:", self.server_input) + + # Username + self.username_input = QLineEdit() + self.username_input.setPlaceholderText("Enter username") + form.addRow("Username:", self.username_input) + + # Password + self.password_input = QLineEdit() + self.password_input.setPlaceholderText("Enter password") + self.password_input.setEchoMode(QLineEdit.EchoMode.Password) + form.addRow("Password:", self.password_input) + + layout.addLayout(form) + + # Error label + self.error_label = QLabel() + self.error_label.setStyleSheet("color: red;") + self.error_label.setVisible(False) + layout.addWidget(self.error_label) + + # Buttons + button_layout = QHBoxLayout() + button_layout.addStretch() + + self.login_button = QPushButton("Login") + self.login_button.setDefault(True) + self.login_button.clicked.connect(self.accept) + button_layout.addWidget(self.login_button) + + cancel_button = QPushButton("Cancel") + cancel_button.clicked.connect(self.reject) + button_layout.addWidget(cancel_button) + + layout.addLayout(button_layout) + + # Enter key handling + self.password_input.returnPressed.connect(self.login_button.click) + + def get_credentials(self) -> tuple[str, str, str]: + """Get entered credentials.""" + return ( + self.server_input.currentText().strip(), + self.username_input.text().strip(), + self.password_input.text() + ) + + def show_error(self, message: str): + """Show error message.""" + self.error_label.setText(message) + self.error_label.setVisible(True) + + def clear_error(self): + """Clear error message.""" + self.error_label.setVisible(False) diff --git a/client/ui/main_window.py b/client/ui/main_window.py new file mode 100644 index 0000000..136043e --- /dev/null +++ b/client/ui/main_window.py @@ -0,0 +1,387 @@ +"""Main application window.""" + +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QSplitter, QListWidget, QListWidgetItem, QLabel, + QPushButton, QGroupBox, QTextEdit, QStatusBar, + QMessageBox, QProgressDialog +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QIcon, QColor + +from config import APP_NAME, APP_VERSION +from services.api_client import APIClient, Gateway, Endpoint +from services.vpn_manager import VPNManager +from .login_dialog import LoginDialog + + +class MainWindow(QMainWindow): + """Main application window.""" + + def __init__(self): + super().__init__() + self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}") + self.setMinimumSize(900, 600) + + self.api_client: APIClient | None = None + self.vpn_manager = VPNManager() + self.current_gateway: Gateway | None = None + self.current_endpoint: Endpoint | None = None + self.current_connection_id: int | None = None + + self._setup_ui() + self._setup_timers() + + # Show login dialog on start + QTimer.singleShot(100, self._show_login) + + def _setup_ui(self): + """Setup UI components.""" + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + + # Toolbar + toolbar = QHBoxLayout() + self.user_label = QLabel("Not logged in") + toolbar.addWidget(self.user_label) + toolbar.addStretch() + + self.refresh_button = QPushButton("Refresh") + self.refresh_button.clicked.connect(self._refresh_data) + self.refresh_button.setEnabled(False) + toolbar.addWidget(self.refresh_button) + + self.logout_button = QPushButton("Logout") + self.logout_button.clicked.connect(self._logout) + self.logout_button.setEnabled(False) + toolbar.addWidget(self.logout_button) + + layout.addLayout(toolbar) + + # Main content splitter + splitter = QSplitter(Qt.Orientation.Horizontal) + + # Gateway list + gateway_group = QGroupBox("Gateways") + gateway_layout = QVBoxLayout(gateway_group) + self.gateway_list = QListWidget() + self.gateway_list.currentItemChanged.connect(self._on_gateway_selected) + gateway_layout.addWidget(self.gateway_list) + splitter.addWidget(gateway_group) + + # Endpoint list + endpoint_group = QGroupBox("Endpoints") + endpoint_layout = QVBoxLayout(endpoint_group) + self.endpoint_list = QListWidget() + self.endpoint_list.currentItemChanged.connect(self._on_endpoint_selected) + endpoint_layout.addWidget(self.endpoint_list) + splitter.addWidget(endpoint_group) + + # Connection panel + connection_group = QGroupBox("Connection") + connection_layout = QVBoxLayout(connection_group) + + self.gateway_info = QLabel("Select a gateway") + connection_layout.addWidget(self.gateway_info) + + self.endpoint_info = QLabel("Select an endpoint") + connection_layout.addWidget(self.endpoint_info) + + self.status_label = QLabel("Status: Disconnected") + self.status_label.setStyleSheet("font-weight: bold;") + connection_layout.addWidget(self.status_label) + + connection_layout.addStretch() + + # Connect button + button_layout = QHBoxLayout() + self.connect_button = QPushButton("Connect") + self.connect_button.setEnabled(False) + self.connect_button.clicked.connect(self._toggle_connection) + self.connect_button.setMinimumHeight(40) + button_layout.addWidget(self.connect_button) + connection_layout.addLayout(button_layout) + + # Log area + self.log_area = QTextEdit() + self.log_area.setReadOnly(True) + self.log_area.setMaximumHeight(150) + connection_layout.addWidget(self.log_area) + + splitter.addWidget(connection_group) + + # Set splitter sizes + splitter.setSizes([200, 200, 400]) + layout.addWidget(splitter) + + # Status bar + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage("Ready") + + def _setup_timers(self): + """Setup periodic timers.""" + # Refresh gateway status every 30 seconds + self.status_timer = QTimer() + self.status_timer.timeout.connect(self._refresh_gateway_status) + self.status_timer.setInterval(30000) + + def _show_login(self): + """Show login dialog.""" + dialog = LoginDialog(self) + + while True: + if dialog.exec() != LoginDialog.DialogCode.Accepted: + self.close() + return + + server, username, password = dialog.get_credentials() + + if not all([server, username, password]): + dialog.show_error("Please fill all fields") + continue + + # Try to connect + self.api_client = APIClient(server) + if self.api_client.login(username, password): + dialog.accept() + self._on_login_success(username) + break + else: + dialog.show_error("Login failed. Check credentials.") + + def _on_login_success(self, username: str): + """Handle successful login.""" + self.user_label.setText(f"Logged in as: {username}") + self.refresh_button.setEnabled(True) + self.logout_button.setEnabled(True) + self.status_bar.showMessage("Connected to server") + self._log("Connected to server") + + # Load data + self._refresh_data() + + # Start status timer + self.status_timer.start() + + def _logout(self): + """Logout and show login dialog.""" + # Disconnect VPN if connected + if self.vpn_manager.is_connected(): + self.vpn_manager.disconnect() + + if self.api_client: + self.api_client.logout() + self.api_client.close() + self.api_client = None + + # Clear UI + self.gateway_list.clear() + self.endpoint_list.clear() + self.user_label.setText("Not logged in") + self.refresh_button.setEnabled(False) + self.logout_button.setEnabled(False) + self.connect_button.setEnabled(False) + self.status_timer.stop() + + self._log("Logged out") + + # Show login dialog + QTimer.singleShot(100, self._show_login) + + def _refresh_data(self): + """Refresh all data from server.""" + if not self.api_client: + return + + self._log("Refreshing data...") + self.status_bar.showMessage("Refreshing...") + + # Load gateways + gateways = self.api_client.get_gateways() + self.gateway_list.clear() + + for gateway in gateways: + item = QListWidgetItem() + item.setText(f"{gateway.name}") + item.setData(Qt.ItemDataRole.UserRole, gateway) + + # Set color based on status + if gateway.is_online: + item.setForeground(QColor("green")) + item.setToolTip(f"Online - {gateway.location or 'No location'}") + else: + item.setForeground(QColor("gray")) + item.setToolTip("Offline") + + self.gateway_list.addItem(item) + + self.status_bar.showMessage(f"Loaded {len(gateways)} gateways") + self._log(f"Loaded {len(gateways)} gateways") + + def _refresh_gateway_status(self): + """Refresh only gateway online status.""" + if not self.api_client: + return + + status_list = self.api_client.get_gateways_status() + status_map = {s["id"]: s["is_online"] for s in status_list} + + for i in range(self.gateway_list.count()): + item = self.gateway_list.item(i) + gateway: Gateway = item.data(Qt.ItemDataRole.UserRole) + if gateway.id in status_map: + is_online = status_map[gateway.id] + if is_online: + item.setForeground(QColor("green")) + else: + item.setForeground(QColor("gray")) + + def _on_gateway_selected(self, current, previous): + """Handle gateway selection.""" + if not current: + self.endpoint_list.clear() + self.current_gateway = None + self.gateway_info.setText("Select a gateway") + return + + gateway: Gateway = current.data(Qt.ItemDataRole.UserRole) + self.current_gateway = gateway + + self.gateway_info.setText( + f"Gateway: {gateway.name}\n" + f"Type: {gateway.router_type}\n" + f"Status: {'Online' if gateway.is_online else 'Offline'}\n" + f"Location: {gateway.location or 'N/A'}" + ) + + # Load endpoints + self._load_endpoints(gateway.id) + + def _load_endpoints(self, gateway_id: int): + """Load endpoints for gateway.""" + if not self.api_client: + return + + endpoints = self.api_client.get_endpoints(gateway_id) + self.endpoint_list.clear() + + for endpoint in endpoints: + item = QListWidgetItem() + app_name = endpoint.application_name or "Custom" + item.setText(f"{endpoint.name} ({app_name})") + item.setToolTip(f"{endpoint.internal_ip}:{endpoint.port} ({endpoint.protocol})") + item.setData(Qt.ItemDataRole.UserRole, endpoint) + self.endpoint_list.addItem(item) + + def _on_endpoint_selected(self, current, previous): + """Handle endpoint selection.""" + if not current: + self.current_endpoint = None + self.endpoint_info.setText("Select an endpoint") + self.connect_button.setEnabled(False) + return + + endpoint: Endpoint = current.data(Qt.ItemDataRole.UserRole) + self.current_endpoint = endpoint + + self.endpoint_info.setText( + f"Endpoint: {endpoint.name}\n" + f"Address: {endpoint.internal_ip}:{endpoint.port}\n" + f"Protocol: {endpoint.protocol.upper()}\n" + f"Application: {endpoint.application_name or 'N/A'}" + ) + + # Enable connect button if gateway is online + if self.current_gateway and self.current_gateway.is_online: + self.connect_button.setEnabled(True) + else: + self.connect_button.setEnabled(False) + + def _toggle_connection(self): + """Toggle VPN connection.""" + if self.vpn_manager.is_connected(): + self._disconnect() + else: + self._connect() + + def _connect(self): + """Establish VPN connection.""" + if not self.api_client or not self.current_gateway or not self.current_endpoint: + return + + self._log(f"Connecting to {self.current_endpoint.name}...") + self.status_label.setText("Status: Connecting...") + self.connect_button.setEnabled(False) + + # Request connection from server + result = self.api_client.connect( + self.current_gateway.id, + self.current_endpoint.id + ) + + if not result.get("success"): + self._log(f"Error: {result.get('message')}") + self.status_label.setText("Status: Connection Failed") + self.connect_button.setEnabled(True) + return + + self.current_connection_id = result.get("connection_id") + vpn_config = result.get("vpn_config") + + if not vpn_config: + self._log("Error: No VPN config received") + self.status_label.setText("Status: Error") + self.connect_button.setEnabled(True) + return + + # Start VPN connection + status = self.vpn_manager.connect(vpn_config) + + if status.connected: + self._log("VPN connected!") + self.status_label.setText("Status: Connected") + self.status_label.setStyleSheet("font-weight: bold; color: green;") + self.connect_button.setText("Disconnect") + self.connect_button.setEnabled(True) + + self._log(f"Target: {result.get('target_ip')}:{result.get('target_port')}") + else: + self._log(f"VPN Error: {status.error}") + self.status_label.setText("Status: VPN Failed") + self.connect_button.setEnabled(True) + + def _disconnect(self): + """Disconnect VPN.""" + self._log("Disconnecting...") + + # Disconnect VPN + self.vpn_manager.disconnect() + + # Notify server + if self.api_client and self.current_connection_id: + self.api_client.disconnect(self.current_connection_id) + + self.current_connection_id = None + self.status_label.setText("Status: Disconnected") + self.status_label.setStyleSheet("font-weight: bold; color: black;") + self.connect_button.setText("Connect") + self._log("Disconnected") + + def _log(self, message: str): + """Add message to log area.""" + from datetime import datetime + timestamp = datetime.now().strftime("%H:%M:%S") + self.log_area.append(f"[{timestamp}] {message}") + + def closeEvent(self, event): + """Handle window close.""" + # Disconnect VPN if connected + if self.vpn_manager.is_connected(): + self.vpn_manager.disconnect() + + if self.api_client: + self.api_client.close() + + event.accept() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e025fc7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,94 @@ +version: '3.8' + +services: + # MariaDB Database + db: + image: mariadb:10.11 + container_name: mguard-db + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-changeme_root} + MYSQL_DATABASE: ${DB_NAME:-mguard_vpn} + MYSQL_USER: ${DB_USER:-mguard} + MYSQL_PASSWORD: ${DB_PASSWORD:-changeme_db} + volumes: + - db_data:/var/lib/mysql + - ./server/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + networks: + - mguard-network + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + + # FastAPI Server + Web UI + api: + build: + context: ./server + dockerfile: Dockerfile + container_name: mguard-api + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - DATABASE_URL=mysql+pymysql://${DB_USER:-mguard}:${DB_PASSWORD:-changeme_db}@db:3306/${DB_NAME:-mguard_vpn} + - SECRET_KEY=${SECRET_KEY:-change_me_in_production_use_openssl_rand_hex_32} + - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme} + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com} + - OPENVPN_MANAGEMENT_HOST=openvpn + - OPENVPN_MANAGEMENT_PORT=7505 + - VPN_SERVER_ADDRESS=${VPN_SERVER_ADDRESS:-vpn.example.com} + ports: + - "8000:8000" + volumes: + - ./server/app:/server/app + - openvpn_logs:/var/log/openvpn:ro + depends_on: + db: + condition: service_healthy + networks: + - mguard-network + + # OpenVPN Multi-Server Container (Host Network) + # Manages multiple VPN server instances dynamically based on database configuration. + # No need to edit docker-compose.yml when adding new VPN servers - just create them + # via the web UI and the container will automatically start them. + openvpn: + build: + context: ./openvpn + dockerfile: Dockerfile + container_name: mguard-openvpn + restart: unless-stopped + network_mode: host + privileged: true + cap_add: + - NET_ADMIN + environment: + # API runs on localhost:8000 from container's perspective (host network) + - API_URL=http://127.0.0.1:8000/api/internal + - API_TIMEOUT=120 + - API_RETRY_INTERVAL=5 + - POLL_INTERVAL=30 + volumes: + - openvpn_config:/etc/openvpn + - openvpn_logs:/var/log/openvpn + depends_on: + - api + +volumes: + db_data: + name: mguard_db_data + openvpn_config: + name: mguard_openvpn_config + openvpn_logs: + name: mguard_openvpn_logs + +networks: + mguard-network: + name: mguard_network + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 diff --git a/mguard_router_typen.txt b/mguard_router_typen.txt new file mode 100644 index 0000000..137c38d --- /dev/null +++ b/mguard_router_typen.txt @@ -0,0 +1,19 @@ +Dieser leigt vor mir + +FL MGUARD 2000/4000 +Web-based Management +mGuard 10.5.x + +Dieser wird auf den Schiffen verbaut: + +2025-06-05 +PHOENIX CONTACT 105661_de_19 +Konfigurieren der mGuard Security-Appliances (Referenzhandbuch) +Firmware 8.9 +UM DE MGUARD 8.9 +19 +— +Dieses Handbuch ist gültig für das mGuard Software-Release 8.9 bei Verwendung mit den folgenden +Geräten der mGuard-Familie (siehe „mGuard Firmware – Version 8.9.x - Release Notes“ für weitere +Informationen): +FL MGUARD RS4000 TX/TX VPN-M \ No newline at end of file diff --git a/openvpn/Dockerfile b/openvpn/Dockerfile new file mode 100644 index 0000000..41ab600 --- /dev/null +++ b/openvpn/Dockerfile @@ -0,0 +1,49 @@ +FROM alpine:3.19 + +LABEL maintainer="mGuard VPN Manager" +LABEL description="OpenVPN multi-server container with dynamic configuration" + +# Install packages +RUN apk add --no-cache \ + openvpn \ + bash \ + curl \ + iptables \ + ip6tables \ + supervisor \ + jq + +# Create directories +RUN mkdir -p \ + /etc/openvpn/servers \ + /etc/openvpn/scripts \ + /etc/openvpn/supervisor.d \ + /var/log/openvpn \ + /var/run/openvpn + +# Copy configuration and scripts +COPY supervisord.conf /etc/supervisord.conf +COPY entrypoint.sh /entrypoint.sh +COPY scripts/ /etc/openvpn/scripts/ + +# Make scripts executable and create log file with proper permissions +RUN chmod +x /entrypoint.sh /etc/openvpn/scripts/*.sh && \ + touch /var/log/openvpn/clients.log && \ + chmod 666 /var/log/openvpn/clients.log + +# Expose common VPN ports (actual ports depend on server configs) +# These are just defaults, actual binding happens via host network +EXPOSE 1194/udp +EXPOSE 1194/tcp +EXPOSE 443/tcp + +# Volumes for persistent data +VOLUME ["/etc/openvpn", "/var/log/openvpn"] + +# Environment variables +ENV API_URL="http://127.0.0.1:8000/api/internal" +ENV API_TIMEOUT="120" +ENV API_RETRY_INTERVAL="5" +ENV POLL_INTERVAL="30" + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/openvpn/entrypoint.sh b/openvpn/entrypoint.sh new file mode 100644 index 0000000..51e1039 --- /dev/null +++ b/openvpn/entrypoint.sh @@ -0,0 +1,406 @@ +#!/bin/bash +# ============================================================================= +# mGuard VPN - Multi-Server OpenVPN Entrypoint +# ============================================================================= +# This script manages multiple OpenVPN server instances dynamically. +# It polls the API for active servers and starts/stops them as needed. +# ============================================================================= + +set -e + +# Configuration +API_URL="${API_URL:-http://127.0.0.1:8000/api/internal}" +API_TIMEOUT="${API_TIMEOUT:-120}" +API_RETRY_INTERVAL="${API_RETRY_INTERVAL:-5}" +POLL_INTERVAL="${POLL_INTERVAL:-30}" + +# Directories +SERVERS_DIR="/etc/openvpn/servers" +SUPERVISOR_DIR="/etc/openvpn/supervisor.d" +LOG_DIR="/var/log/openvpn" +RUN_DIR="/var/run/openvpn" + +# Ensure directories exist (volumes may override Dockerfile-created dirs) +mkdir -p "$SERVERS_DIR" "$SUPERVISOR_DIR" "$LOG_DIR" "$RUN_DIR" + +# State tracking +RUNNING_SERVERS="" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +log_error() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >&2 +} + +# ============================================================================= +# Wait for API to be ready +# ============================================================================= +wait_for_api() { + log "Waiting for API at $API_URL..." + local attempts=0 + local max_attempts=$((API_TIMEOUT / API_RETRY_INTERVAL)) + + while [ $attempts -lt $max_attempts ]; do + if curl -sf "$API_URL/health" > /dev/null 2>&1; then + log "API is ready" + return 0 + fi + attempts=$((attempts + 1)) + log "API not ready, retrying in ${API_RETRY_INTERVAL}s... ($attempts/$max_attempts)" + sleep $API_RETRY_INTERVAL + done + + log_error "API not available after ${API_TIMEOUT}s" + return 1 +} + +# ============================================================================= +# Fetch active servers from API +# ============================================================================= +fetch_active_servers() { + curl -sf "$API_URL/vpn-servers/active" 2>/dev/null || echo "[]" +} + +# ============================================================================= +# Setup a single VPN server +# ============================================================================= +setup_server() { + local server_id="$1" + local server_name="$2" + local port="$3" + local protocol="$4" + local mgmt_port="$5" + + local server_dir="$SERVERS_DIR/$server_id" + + log "Setting up server $server_id ($server_name) on port $port/$protocol" + + # Create server directory + mkdir -p "$server_dir" + + # Fetch all required files + log " Fetching configuration files..." + + if ! curl -sf "$API_URL/vpn-servers/$server_id/config" > "$server_dir/server.conf"; then + log_error "Failed to fetch server config for $server_id" + return 1 + fi + + if ! curl -sf "$API_URL/vpn-servers/$server_id/ca" > "$server_dir/ca.crt"; then + log_error "Failed to fetch CA for $server_id" + return 1 + fi + + if ! curl -sf "$API_URL/vpn-servers/$server_id/cert" > "$server_dir/server.crt"; then + log_error "Failed to fetch server cert for $server_id" + return 1 + fi + + if ! curl -sf "$API_URL/vpn-servers/$server_id/key" > "$server_dir/server.key"; then + log_error "Failed to fetch server key for $server_id" + return 1 + fi + chmod 600 "$server_dir/server.key" + + if ! curl -sf "$API_URL/vpn-servers/$server_id/dh" > "$server_dir/dh.pem"; then + log_error "Failed to fetch DH params for $server_id" + return 1 + fi + + # TA key is optional + curl -sf "$API_URL/vpn-servers/$server_id/ta" > "$server_dir/ta.key" 2>/dev/null || true + if [ -f "$server_dir/ta.key" ] && [ -s "$server_dir/ta.key" ]; then + chmod 600 "$server_dir/ta.key" + else + rm -f "$server_dir/ta.key" + fi + + # CRL + curl -sf "$API_URL/vpn-servers/$server_id/crl" > "$server_dir/crl.pem" 2>/dev/null || true + + # Create client-config directory + mkdir -p "$server_dir/ccd" + + # Create status file location + touch "$RUN_DIR/openvpn-$server_id.status" + + # Create supervisor config for this server + cat > "$SUPERVISOR_DIR/openvpn-$server_id.conf" << EOF +[program:openvpn-$server_id] +command=/usr/sbin/openvpn --config $server_dir/server.conf +directory=$server_dir +autostart=true +autorestart=true +startsecs=5 +startretries=3 +exitcodes=0 +stopsignal=TERM +stopwaitsecs=10 +stdout_logfile=$LOG_DIR/server-$server_id.log +stdout_logfile_maxbytes=10MB +stdout_logfile_backups=3 +stderr_logfile=$LOG_DIR/server-$server_id-error.log +stderr_logfile_maxbytes=5MB +stderr_logfile_backups=2 +EOF + + log " Server $server_id configured successfully" + return 0 +} + +# ============================================================================= +# Remove a VPN server +# ============================================================================= +remove_server() { + local server_id="$1" + + log "Removing server $server_id..." + + # Stop the process via supervisorctl if supervisor is running + if [ -S /var/run/supervisor.sock ]; then + supervisorctl stop "openvpn-$server_id" 2>/dev/null || true + supervisorctl remove "openvpn-$server_id" 2>/dev/null || true + fi + + # Remove supervisor config + rm -f "$SUPERVISOR_DIR/openvpn-$server_id.conf" + + # Keep server directory for logs, but mark as inactive + # rm -rf "$SERVERS_DIR/$server_id" + + log " Server $server_id removed" +} + +# ============================================================================= +# Notify API that server started +# ============================================================================= +notify_started() { + local server_id="$1" + curl -sf -X POST "$API_URL/vpn-servers/$server_id/started" > /dev/null 2>&1 || true +} + +# ============================================================================= +# Notify API that server stopped +# ============================================================================= +notify_stopped() { + local server_id="$1" + curl -sf -X POST "$API_URL/vpn-servers/$server_id/stopped" > /dev/null 2>&1 || true +} + +# ============================================================================= +# Initial setup - configure all active servers +# ============================================================================= +initial_setup() { + log "Performing initial server setup..." + + local servers_json + servers_json=$(fetch_active_servers) + + if [ "$servers_json" = "[]" ]; then + log "No active VPN servers found. Waiting for configuration via web UI..." + return 0 + fi + + # Parse JSON and setup each ready server + echo "$servers_json" | jq -c '.[] | select(.is_ready == true and .has_ca == true and .has_cert == true)' | while read -r server; do + local id=$(echo "$server" | jq -r '.id') + local name=$(echo "$server" | jq -r '.name') + local port=$(echo "$server" | jq -r '.port') + local protocol=$(echo "$server" | jq -r '.protocol') + local mgmt_port=$(echo "$server" | jq -r '.management_port') + + if setup_server "$id" "$name" "$port" "$protocol" "$mgmt_port"; then + RUNNING_SERVERS="$RUNNING_SERVERS $id" + fi + done + + log "Initial setup complete. Running servers:$RUNNING_SERVERS" +} + +# ============================================================================= +# Poll for changes and update servers +# ============================================================================= +poll_for_changes() { + local servers_json + servers_json=$(fetch_active_servers) + + # Get list of ready server IDs from API + local api_server_ids + api_server_ids=$(echo "$servers_json" | jq -r '.[] | select(.is_ready == true and .has_ca == true and .has_cert == true) | .id' | sort) + + # Get list of currently configured servers + local current_server_ids + current_server_ids=$(ls -1 "$SUPERVISOR_DIR" 2>/dev/null | grep "^openvpn-" | sed 's/openvpn-\([0-9]*\)\.conf/\1/' | sort) + + # Find new servers to add + for id in $api_server_ids; do + if ! echo "$current_server_ids" | grep -q "^${id}$"; then + log "New server detected: $id" + local server + server=$(echo "$servers_json" | jq -c ".[] | select(.id == $id)") + local name=$(echo "$server" | jq -r '.name') + local port=$(echo "$server" | jq -r '.port') + local protocol=$(echo "$server" | jq -r '.protocol') + local mgmt_port=$(echo "$server" | jq -r '.management_port') + + if setup_server "$id" "$name" "$port" "$protocol" "$mgmt_port"; then + # Add to supervisor + if [ -S /var/run/supervisor.sock ]; then + supervisorctl reread > /dev/null 2>&1 || true + supervisorctl add "openvpn-$id" > /dev/null 2>&1 || true + fi + fi + fi + done + + # Find servers to remove (no longer active) + for id in $current_server_ids; do + if ! echo "$api_server_ids" | grep -q "^${id}$"; then + log "Server $id no longer active" + remove_server "$id" + fi + done + + # Update CRL for all running servers + for id in $api_server_ids; do + if [ -d "$SERVERS_DIR/$id" ]; then + curl -sf "$API_URL/vpn-servers/$id/crl" > "$SERVERS_DIR/$id/crl.pem.new" 2>/dev/null && \ + mv "$SERVERS_DIR/$id/crl.pem.new" "$SERVERS_DIR/$id/crl.pem" || \ + rm -f "$SERVERS_DIR/$id/crl.pem.new" + fi + done +} + +# ============================================================================= +# Setup iptables for NAT +# ============================================================================= +setup_iptables() { + log "Setting up iptables NAT rules..." + + # Enable IP forwarding (may fail in container, that's OK if host has it enabled) + if [ -w /proc/sys/net/ipv4/ip_forward ]; then + echo 1 > /proc/sys/net/ipv4/ip_forward + log " IP forwarding enabled" + else + log " IP forwarding: /proc/sys not writable (ensure host has net.ipv4.ip_forward=1)" + fi + + # Basic NAT masquerade (adjust interface as needed) + if iptables -t nat -C POSTROUTING -s 10.0.0.0/8 -j MASQUERADE 2>/dev/null; then + log " NAT masquerade rule already exists" + elif iptables -t nat -A POSTROUTING -s 10.0.0.0/8 -j MASQUERADE 2>/dev/null; then + log " NAT masquerade rule added" + else + log " WARNING: Could not add NAT masquerade rule" + fi + + log "iptables setup complete" +} + +# ============================================================================= +# Cleanup on exit +# ============================================================================= +cleanup() { + log "Shutting down..." + + # Notify API about all servers stopping + for conf in "$SUPERVISOR_DIR"/openvpn-*.conf; do + if [ -f "$conf" ]; then + local id=$(basename "$conf" | sed 's/openvpn-\([0-9]*\)\.conf/\1/') + notify_stopped "$id" + fi + done + + # Stop supervisor + if [ -S /var/run/supervisor.sock ]; then + supervisorctl shutdown 2>/dev/null || true + fi + + exit 0 +} + +# ============================================================================= +# Main +# ============================================================================= +main() { + log "=== mGuard VPN Multi-Server Container Starting ===" + log "API URL: $API_URL" + log "Poll Interval: ${POLL_INTERVAL}s" + + # Setup signal handlers + trap cleanup SIGTERM SIGINT + + # Wait for API + if ! wait_for_api; then + log_error "Cannot start without API" + exit 1 + fi + + # Setup iptables + setup_iptables + + # Initial setup of all servers + initial_setup + + # Check if any servers were configured + if [ -z "$(ls -A $SUPERVISOR_DIR 2>/dev/null)" ]; then + log "No VPN servers configured yet." + log "Please create a CA and VPN server via the web UI." + log "Container will poll every ${POLL_INTERVAL}s for new servers..." + + # Start supervisor anyway (will have no programs) + /usr/bin/supervisord -c /etc/supervisord.conf & + SUPERVISOR_PID=$! + + # Polling loop waiting for first server + while true; do + sleep $POLL_INTERVAL + poll_for_changes + + # Check if supervisor is still running + if ! kill -0 $SUPERVISOR_PID 2>/dev/null; then + log_error "Supervisor died unexpectedly" + exit 1 + fi + done + else + log "Starting supervisord with configured servers..." + + # Start supervisor in background + /usr/bin/supervisord -c /etc/supervisord.conf & + SUPERVISOR_PID=$! + + # Wait a moment for supervisor to start + sleep 3 + + # Notify API about started servers + for conf in "$SUPERVISOR_DIR"/openvpn-*.conf; do + if [ -f "$conf" ]; then + local id=$(basename "$conf" | sed 's/openvpn-\([0-9]*\)\.conf/\1/') + # Check if actually running + if supervisorctl status "openvpn-$id" 2>/dev/null | grep -q RUNNING; then + notify_started "$id" + fi + fi + done + + log "All servers started. Entering polling mode..." + + # Polling loop for changes + while true; do + sleep $POLL_INTERVAL + poll_for_changes + + # Check if supervisor is still running + if ! kill -0 $SUPERVISOR_PID 2>/dev/null; then + log_error "Supervisor died unexpectedly" + exit 1 + fi + done + fi +} + +# Run main +main diff --git a/openvpn/scripts/client-connect.sh b/openvpn/scripts/client-connect.sh new file mode 100644 index 0000000..83963f4 --- /dev/null +++ b/openvpn/scripts/client-connect.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# OpenVPN client-connect script +# Called when a client connects successfully + +# Environment variables provided by OpenVPN: +# - common_name: Client certificate CN +# - trusted_ip / untrusted_ip: Client's real IP +# - ifconfig_pool_remote_ip: Assigned VPN IP +# - dev: TUN/TAP device +# - time_unix: Connection timestamp + +# Log connection (optional - log file might not be writable) +echo "$(date '+%Y-%m-%d %H:%M:%S') CONNECT: CN=$common_name IP=$trusted_ip VPN_IP=$ifconfig_pool_remote_ip" >> /var/log/openvpn/clients.log 2>/dev/null || true + +# Notify API about connection (optional) +if [ -n "$API_URL" ]; then + curl -s -X POST "$API_URL/vpn-servers/${VPN_SERVER_ID:-1}/client-connected" \ + -H "Content-Type: application/json" \ + -d "{\"common_name\": \"$common_name\", \"real_ip\": \"$trusted_ip\", \"vpn_ip\": \"$ifconfig_pool_remote_ip\"}" \ + 2>/dev/null || true +fi + +exit 0 diff --git a/openvpn/scripts/client-disconnect.sh b/openvpn/scripts/client-disconnect.sh new file mode 100644 index 0000000..0fbf2ce --- /dev/null +++ b/openvpn/scripts/client-disconnect.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# OpenVPN client-disconnect script +# Called when a client disconnects + +# Environment variables provided by OpenVPN: +# - common_name: Client certificate CN +# - trusted_ip / untrusted_ip: Client's real IP +# - ifconfig_pool_remote_ip: Assigned VPN IP +# - bytes_received: Total bytes received from client +# - bytes_sent: Total bytes sent to client +# - time_duration: Connection duration in seconds + +# Log disconnection (optional - log file might not be writable) +echo "$(date '+%Y-%m-%d %H:%M:%S') DISCONNECT: CN=$common_name Duration=${time_duration}s RX=$bytes_received TX=$bytes_sent" >> /var/log/openvpn/clients.log 2>/dev/null || true + +# Notify API about disconnection (optional) +if [ -n "$API_URL" ]; then + curl -s -X POST "$API_URL/vpn-servers/${VPN_SERVER_ID:-1}/client-disconnected" \ + -H "Content-Type: application/json" \ + -d "{\"common_name\": \"$common_name\", \"bytes_received\": $bytes_received, \"bytes_sent\": $bytes_sent, \"duration\": $time_duration}" \ + 2>/dev/null || true +fi + +exit 0 diff --git a/openvpn/supervisord.conf b/openvpn/supervisord.conf new file mode 100644 index 0000000..38f05f5 --- /dev/null +++ b/openvpn/supervisord.conf @@ -0,0 +1,22 @@ +[supervisord] +nodaemon=true +logfile=/var/log/openvpn/supervisord.log +logfile_maxbytes=10MB +logfile_backups=3 +loglevel=info +pidfile=/var/run/supervisord.pid +user=root + +[unix_http_server] +file=/var/run/supervisor.sock +chmod=0700 + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +; Include dynamically generated OpenVPN server configs +[include] +files = /etc/openvpn/supervisor.d/*.conf diff --git a/provisioning-tool/config_generator.py b/provisioning-tool/config_generator.py new file mode 100644 index 0000000..403b257 --- /dev/null +++ b/provisioning-tool/config_generator.py @@ -0,0 +1,206 @@ +"""Configuration generator for mGuard routers.""" + +from dataclasses import dataclass +from typing import Optional +import json + + +@dataclass +class GatewayConfig: + """Gateway configuration data.""" + name: str + vpn_server: str + vpn_port: int + ca_cert: str + client_cert: str + client_key: str + ta_key: Optional[str] = None + + +class ConfigGenerator: + """Generate configuration files for mGuard routers.""" + + @staticmethod + def generate_openvpn_config(config: GatewayConfig) -> str: + """Generate OpenVPN client configuration. + + Args: + config: Gateway configuration data + + Returns: + OpenVPN config file content + """ + ovpn = f"""# OpenVPN Client Configuration +# Generated for: {config.name} + +client +dev tun +proto udp +remote {config.vpn_server} {config.vpn_port} +resolv-retry infinite +nobind +persist-key +persist-tun +remote-cert-tls server +cipher AES-256-GCM +auth SHA256 +verb 3 + + +{config.ca_cert} + + + +{config.client_cert} + + + +{config.client_key} + +""" + + if config.ta_key: + ovpn += f""" + +{config.ta_key} + +key-direction 1 +""" + + return ovpn + + @staticmethod + def generate_atv_vpn_section(config: GatewayConfig) -> str: + """Generate ATV configuration section for VPN. + + Note: This is a simplified version. Real ATV files have + a more complex structure that should be merged with existing config. + + Args: + config: Gateway configuration data + + Returns: + ATV config section + """ + # ATV is essentially a key-value format + # This is a simplified representation + atv_section = f""" +[vpn_client_1] +enabled = 1 +name = {config.name} +type = openvpn +remote = {config.vpn_server} +port = {config.vpn_port} +protocol = udp +cipher = AES-256-GCM +""" + return atv_section + + @staticmethod + def generate_mguard_script( + gateway_name: str, + endpoints: list[dict] + ) -> str: + """Generate mGuard CLI script for firewall configuration. + + Args: + gateway_name: Name of the gateway + endpoints: List of endpoint configurations + + Returns: + Shell script for mGuard CLI + """ + script = f"""#!/bin/bash +# Firewall configuration script for {gateway_name} +# Run this on the mGuard via SSH + +MBIN="/Packages/mguard-api_0/mbin" + +echo "Configuring firewall rules for {gateway_name}..." + +""" + for i, ep in enumerate(endpoints): + rule_name = f"endpoint_{i}_{ep['name'].replace(' ', '_')}" + script += f""" +# Rule for {ep['name']} +$MBIN/action fwrules/add \\ + --name "{rule_name}" \\ + --source "any" \\ + --destination "{ep['internal_ip']}" \\ + --port "{ep['port']}" \\ + --protocol "{ep['protocol']}" \\ + --action "accept" +""" + + script += """ +echo "Firewall rules configured." +$MBIN/action config/save +echo "Configuration saved." +""" + return script + + +def create_provisioning_package( + gateway_name: str, + vpn_server: str, + vpn_port: int, + ca_cert: str, + client_cert: str, + client_key: str, + endpoints: list[dict], + output_dir: str = "." +) -> dict: + """Create a complete provisioning package. + + Args: + gateway_name: Name of the gateway + vpn_server: VPN server address + vpn_port: VPN server port + ca_cert: CA certificate content + client_cert: Client certificate content + client_key: Client private key content + endpoints: List of endpoint configurations + output_dir: Output directory for files + + Returns: + Dictionary with file paths + """ + from pathlib import Path + + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + config = GatewayConfig( + name=gateway_name, + vpn_server=vpn_server, + vpn_port=vpn_port, + ca_cert=ca_cert, + client_cert=client_cert, + client_key=client_key + ) + + # Generate OpenVPN config + ovpn_content = ConfigGenerator.generate_openvpn_config(config) + ovpn_file = output_path / f"{gateway_name}.ovpn" + ovpn_file.write_text(ovpn_content) + + # Generate firewall script + fw_script = ConfigGenerator.generate_mguard_script(gateway_name, endpoints) + fw_file = output_path / f"{gateway_name}_firewall.sh" + fw_file.write_text(fw_script) + + # Generate info JSON + info = { + "gateway_name": gateway_name, + "vpn_server": vpn_server, + "vpn_port": vpn_port, + "endpoints": endpoints + } + info_file = output_path / f"{gateway_name}_info.json" + info_file.write_text(json.dumps(info, indent=2)) + + return { + "ovpn": str(ovpn_file), + "firewall_script": str(fw_file), + "info": str(info_file) + } diff --git a/provisioning-tool/main.py b/provisioning-tool/main.py new file mode 100644 index 0000000..bc7ea4d --- /dev/null +++ b/provisioning-tool/main.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""mGuard Provisioning Tool - CLI for gateway provisioning.""" + +import click +from rich.console import Console +from rich.table import Table + +from mguard_api import MGuardAPIClient +from config_generator import ConfigGenerator + +console = Console() + + +@click.group() +@click.option('--server', '-s', default='http://localhost:8000', help='API server URL') +@click.option('--username', '-u', prompt=True, help='Admin username') +@click.option('--password', '-p', prompt=True, hide_input=True, help='Admin password') +@click.pass_context +def cli(ctx, server, username, password): + """mGuard Gateway Provisioning Tool. + + Use this tool to provision and configure mGuard routers. + """ + ctx.ensure_object(dict) + + # Login to API + import httpx + client = httpx.Client(timeout=30.0) + + try: + response = client.post( + f"{server}/api/auth/login", + json={"username": username, "password": password} + ) + response.raise_for_status() + tokens = response.json() + + ctx.obj['server'] = server + ctx.obj['token'] = tokens['access_token'] + ctx.obj['client'] = client + + console.print("[green]Successfully authenticated[/green]") + + except httpx.HTTPError as e: + console.print(f"[red]Authentication failed: {e}[/red]") + raise click.Abort() + + +@cli.command() +@click.pass_context +def list_gateways(ctx): + """List all gateways.""" + client = ctx.obj['client'] + server = ctx.obj['server'] + token = ctx.obj['token'] + + response = client.get( + f"{server}/api/gateways", + headers={"Authorization": f"Bearer {token}"} + ) + gateways = response.json() + + table = Table(title="Gateways") + table.add_column("ID", style="cyan") + table.add_column("Name", style="green") + table.add_column("Type") + table.add_column("Status") + table.add_column("Provisioned") + + for gw in gateways: + status = "[green]Online[/green]" if gw['is_online'] else "[red]Offline[/red]" + prov = "[green]Yes[/green]" if gw['is_provisioned'] else "[yellow]No[/yellow]" + table.add_row( + str(gw['id']), + gw['name'], + gw['router_type'], + status, + prov + ) + + console.print(table) + + +@cli.command() +@click.argument('gateway_id', type=int) +@click.option('--output', '-o', default=None, help='Output file path') +@click.pass_context +def download_config(ctx, gateway_id, output): + """Download provisioning config for a gateway.""" + client = ctx.obj['client'] + server = ctx.obj['server'] + token = ctx.obj['token'] + + response = client.get( + f"{server}/api/gateways/{gateway_id}/provision", + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code != 200: + console.print(f"[red]Error: {response.text}[/red]") + return + + config = response.text + + if output: + with open(output, 'w') as f: + f.write(config) + console.print(f"[green]Config saved to {output}[/green]") + else: + output_file = f"gateway-{gateway_id}.ovpn" + with open(output_file, 'w') as f: + f.write(config) + console.print(f"[green]Config saved to {output_file}[/green]") + + +@cli.command() +@click.argument('gateway_id', type=int) +@click.argument('router_ip') +@click.option('--router-user', '-u', default='admin', help='Router username') +@click.option('--router-pass', '-p', prompt=True, hide_input=True, help='Router password') +@click.pass_context +def provision_online(ctx, gateway_id, router_ip, router_user, router_pass): + """Provision a gateway via network (REST API or SSH). + + GATEWAY_ID: ID of the gateway in the server database + ROUTER_IP: IP address of the mGuard router + """ + client = ctx.obj['client'] + server = ctx.obj['server'] + token = ctx.obj['token'] + + console.print(f"[yellow]Connecting to router at {router_ip}...[/yellow]") + + # Get gateway info + response = client.get( + f"{server}/api/gateways/{gateway_id}", + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code != 200: + console.print(f"[red]Error: Gateway not found[/red]") + return + + gateway = response.json() + firmware = gateway.get('firmware_version', '') + + # Determine provisioning method + if firmware and firmware.startswith('10.'): + console.print("[cyan]Using REST API provisioning (Firmware 10.x)[/cyan]") + _provision_rest_api(ctx, gateway, router_ip, router_user, router_pass) + else: + console.print("[cyan]Using SSH provisioning (Legacy firmware)[/cyan]") + _provision_ssh(ctx, gateway, router_ip, router_user, router_pass) + + +def _provision_rest_api(ctx, gateway, router_ip, router_user, router_pass): + """Provision via mGuard REST API.""" + mguard = MGuardAPIClient(router_ip, router_user, router_pass) + + try: + # Test connection + if not mguard.test_connection(): + console.print("[red]Cannot connect to router REST API[/red]") + return + + # Download VPN config from server + client = ctx.obj['client'] + server = ctx.obj['server'] + token = ctx.obj['token'] + + response = client.get( + f"{server}/api/gateways/{gateway['id']}/provision", + headers={"Authorization": f"Bearer {token}"} + ) + vpn_config = response.text + + # Apply VPN configuration + console.print("[yellow]Applying VPN configuration...[/yellow]") + if mguard.configure_vpn(vpn_config): + console.print("[green]VPN configuration applied successfully![/green]") + else: + console.print("[red]Failed to apply VPN configuration[/red]") + + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + + +def _provision_ssh(ctx, gateway, router_ip, router_user, router_pass): + """Provision via SSH (legacy routers).""" + import paramiko + + try: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(router_ip, username=router_user, password=router_pass, timeout=10) + + console.print("[green]SSH connection established[/green]") + + # Download VPN config from server + client = ctx.obj['client'] + server = ctx.obj['server'] + token = ctx.obj['token'] + + response = client.get( + f"{server}/api/gateways/{gateway['id']}/provision", + headers={"Authorization": f"Bearer {token}"} + ) + vpn_config = response.text + + # Upload config file + sftp = ssh.open_sftp() + with sftp.file('/tmp/vpn.ovpn', 'w') as f: + f.write(vpn_config) + + console.print("[yellow]VPN config uploaded[/yellow]") + + # Apply configuration (mGuard-specific commands) + stdin, stdout, stderr = ssh.exec_command( + '/Packages/mguard-api_0/mbin/action vpn/import /tmp/vpn.ovpn' + ) + result = stdout.read().decode() + + if 'error' in result.lower(): + console.print(f"[red]Error: {result}[/red]") + else: + console.print("[green]VPN configuration applied![/green]") + + ssh.close() + + except paramiko.AuthenticationException: + console.print("[red]SSH authentication failed[/red]") + except paramiko.SSHException as e: + console.print(f"[red]SSH error: {e}[/red]") + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + + +@cli.command() +@click.argument('config_file') +@click.argument('router_ip') +@click.option('--router-user', '-u', default='admin', help='Router username') +@click.option('--router-pass', '-p', prompt=True, hide_input=True, help='Router password') +def provision_offline(config_file, router_ip, router_user, router_pass): + """Provision a gateway using a downloaded config file. + + CONFIG_FILE: Path to the .ovpn or .atv config file + ROUTER_IP: IP address of the mGuard router (must be on same network) + """ + import os + from pathlib import Path + + config_path = Path(config_file) + if not config_path.exists(): + console.print(f"[red]Config file not found: {config_file}[/red]") + return + + console.print(f"[yellow]Loading config from {config_file}...[/yellow]") + config_content = config_path.read_text() + + # Determine file type + if config_file.endswith('.ovpn'): + console.print("[cyan]OpenVPN config detected[/cyan]") + elif config_file.endswith('.atv'): + console.print("[cyan]mGuard ATV config detected[/cyan]") + else: + console.print("[yellow]Unknown config format, attempting generic upload[/yellow]") + + # Try REST API first, then SSH + mguard = MGuardAPIClient(router_ip, router_user, router_pass) + + if mguard.test_connection(): + console.print("[cyan]Using REST API...[/cyan]") + if mguard.upload_config(config_content): + console.print("[green]Configuration uploaded successfully![/green]") + else: + console.print("[red]REST API upload failed[/red]") + else: + console.print("[cyan]REST API not available, trying SSH...[/cyan]") + import paramiko + + try: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(router_ip, username=router_user, password=router_pass, timeout=10) + + sftp = ssh.open_sftp() + remote_path = f'/tmp/{config_path.name}' + with sftp.file(remote_path, 'w') as f: + f.write(config_content) + + console.print(f"[green]Config uploaded to {remote_path}[/green]") + + # Apply based on file type + if config_file.endswith('.atv'): + stdin, stdout, stderr = ssh.exec_command( + f'/Packages/mguard-api_0/mbin/action config/restore {remote_path}' + ) + else: + stdin, stdout, stderr = ssh.exec_command( + f'/Packages/mguard-api_0/mbin/action vpn/import {remote_path}' + ) + + result = stdout.read().decode() + console.print(f"Result: {result}") + + ssh.close() + console.print("[green]Provisioning complete![/green]") + + except Exception as e: + console.print(f"[red]SSH provisioning failed: {e}[/red]") + + +if __name__ == '__main__': + cli(obj={}) diff --git a/provisioning-tool/mguard_api.py b/provisioning-tool/mguard_api.py new file mode 100644 index 0000000..d27284d --- /dev/null +++ b/provisioning-tool/mguard_api.py @@ -0,0 +1,175 @@ +"""mGuard REST API client for router configuration.""" + +import httpx +from typing import Optional +import urllib3 + +# Disable SSL warnings for self-signed certificates +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +class MGuardAPIClient: + """Client for mGuard REST API (Firmware 10.x+).""" + + def __init__(self, host: str, username: str, password: str, port: int = 443): + """Initialize mGuard API client. + + Args: + host: Router IP address or hostname + username: Admin username + password: Admin password + port: HTTPS port (default 443) + """ + self.base_url = f"https://{host}:{port}" + self.username = username + self.password = password + self.client = httpx.Client( + timeout=30.0, + verify=False, # mGuard uses self-signed certs + auth=(username, password) + ) + + def test_connection(self) -> bool: + """Test connection to mGuard REST API.""" + try: + response = self.client.get(f"{self.base_url}/api/v1/info") + return response.status_code == 200 + except httpx.HTTPError: + return False + + def get_system_info(self) -> Optional[dict]: + """Get system information.""" + try: + response = self.client.get(f"{self.base_url}/api/v1/info") + response.raise_for_status() + return response.json() + except httpx.HTTPError: + return None + + def get_configuration(self) -> Optional[dict]: + """Get current configuration.""" + try: + response = self.client.get(f"{self.base_url}/api/v1/configuration") + response.raise_for_status() + return response.json() + except httpx.HTTPError: + return None + + def configure_vpn(self, vpn_config: str) -> bool: + """Configure OpenVPN client. + + Args: + vpn_config: OpenVPN configuration content + + Returns: + True if successful + """ + try: + # The actual API endpoint depends on mGuard firmware version + # This is an example - actual implementation may vary + response = self.client.post( + f"{self.base_url}/api/v1/vpn/openvpn/client", + json={ + "enabled": True, + "config": vpn_config + } + ) + return response.status_code in (200, 201, 204) + except httpx.HTTPError: + return False + + def upload_config(self, config_content: str) -> bool: + """Upload configuration file. + + Args: + config_content: Configuration file content + + Returns: + True if successful + """ + try: + response = self.client.post( + f"{self.base_url}/api/v1/configuration", + content=config_content, + headers={"Content-Type": "application/octet-stream"} + ) + return response.status_code in (200, 201, 204) + except httpx.HTTPError: + return False + + def add_firewall_rule( + self, + name: str, + source: str, + destination: str, + port: int, + protocol: str = "tcp", + action: str = "accept" + ) -> bool: + """Add a firewall rule. + + Args: + name: Rule name + source: Source IP/network + destination: Destination IP/network + port: Destination port + protocol: tcp or udp + action: accept or drop + + Returns: + True if successful + """ + try: + response = self.client.post( + f"{self.base_url}/api/v1/firewall/rules", + json={ + "name": name, + "source": source, + "destination": destination, + "port": port, + "protocol": protocol, + "action": action, + "enabled": True + } + ) + return response.status_code in (200, 201, 204) + except httpx.HTTPError: + return False + + def remove_firewall_rule(self, rule_id: str) -> bool: + """Remove a firewall rule. + + Args: + rule_id: Rule ID to remove + + Returns: + True if successful + """ + try: + response = self.client.delete( + f"{self.base_url}/api/v1/firewall/rules/{rule_id}" + ) + return response.status_code in (200, 204) + except httpx.HTTPError: + return False + + def reboot(self) -> bool: + """Reboot the router.""" + try: + response = self.client.post(f"{self.base_url}/api/v1/actions/reboot") + return response.status_code in (200, 202, 204) + except httpx.HTTPError: + return False + + def get_vpn_status(self) -> Optional[dict]: + """Get VPN connection status.""" + try: + response = self.client.get(f"{self.base_url}/api/v1/vpn/status") + response.raise_for_status() + return response.json() + except httpx.HTTPError: + return None + + def close(self): + """Close the HTTP client.""" + self.client.close() diff --git a/provisioning-tool/requirements.txt b/provisioning-tool/requirements.txt new file mode 100644 index 0000000..af9d756 --- /dev/null +++ b/provisioning-tool/requirements.txt @@ -0,0 +1,14 @@ +# HTTP Client +httpx==0.26.0 +requests==2.31.0 + +# SSH for legacy routers +paramiko==3.4.0 + +# CLI Interface +click==8.1.7 +rich==13.7.0 + +# Configuration +python-dotenv==1.0.0 +pyyaml==6.0.1 diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..7d7a569 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +WORKDIR /server + +# Install system dependencies including Easy-RSA for certificate generation +RUN apt-get update && apt-get install -y \ + gcc \ + libmariadb-dev \ + pkg-config \ + iptables \ + easy-rsa \ + && rm -rf /var/lib/apt/lists/* \ + && ln -s /usr/share/easy-rsa/easyrsa /usr/local/bin/easyrsa + +# Copy requirements first for better caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code as package +COPY app/ /server/app/ + +# Create non-root user +RUN useradd -m -u 1000 appuser +# Note: Running as root for iptables access, but API endpoints are protected + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..e3a0de0 --- /dev/null +++ b/server/app/__init__.py @@ -0,0 +1,2 @@ +# mGuard VPN Endpoint Server +__version__ = "1.0.0" diff --git a/server/app/api/__init__.py b/server/app/api/__init__.py new file mode 100644 index 0000000..0fdba8c --- /dev/null +++ b/server/app/api/__init__.py @@ -0,0 +1,15 @@ +"""API routes.""" + +from fastapi import APIRouter +from . import auth, users, tenants, gateways, endpoints, connections + +# Create main API router +api_router = APIRouter(prefix="/api") + +# Include all route modules +api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"]) +api_router.include_router(tenants.router, prefix="/tenants", tags=["Tenants"]) +api_router.include_router(users.router, prefix="/users", tags=["Users"]) +api_router.include_router(gateways.router, prefix="/gateways", tags=["Gateways"]) +api_router.include_router(endpoints.router, prefix="/endpoints", tags=["Endpoints"]) +api_router.include_router(connections.router, prefix="/connections", tags=["Connections"]) diff --git a/server/app/api/auth.py b/server/app/api/auth.py new file mode 100644 index 0000000..86d572b --- /dev/null +++ b/server/app/api/auth.py @@ -0,0 +1,57 @@ +"""Authentication API routes.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from ..database import get_db +from ..schemas.user import UserLogin, Token, UserResponse +from ..services.auth_service import AuthService +from .deps import get_current_user +from ..models.user import User + +router = APIRouter() + + +@router.post("/login", response_model=Token) +def login( + credentials: UserLogin, + db: Session = Depends(get_db) +): + """Authenticate user and return JWT tokens.""" + auth_service = AuthService(db) + user = auth_service.authenticate_user(credentials.username, credentials.password) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"} + ) + + return auth_service.create_tokens(user) + + +@router.post("/refresh", response_model=Token) +def refresh_token( + refresh_token: str, + db: Session = Depends(get_db) +): + """Refresh access token using refresh token.""" + auth_service = AuthService(db) + tokens = auth_service.refresh_tokens(refresh_token) + + if not tokens: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + headers={"WWW-Authenticate": "Bearer"} + ) + + return tokens + + +@router.get("/me", response_model=UserResponse) +def get_current_user_info( + current_user: User = Depends(get_current_user) +): + """Get current authenticated user information.""" + return current_user diff --git a/server/app/api/connections.py b/server/app/api/connections.py new file mode 100644 index 0000000..9ebc97c --- /dev/null +++ b/server/app/api/connections.py @@ -0,0 +1,236 @@ +"""Connection management API routes.""" + +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, status, Response +from pydantic import BaseModel +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.endpoint import Endpoint +from ..models.gateway import Gateway +from ..models.user import User, UserRole +from ..models.access import UserGatewayAccess, ConnectionLog +from ..services.vpn_service import VPNService +from ..services.firewall_service import FirewallService +from .deps import get_current_user + +router = APIRouter() + + +class ConnectRequest(BaseModel): + """Request to establish connection to endpoint.""" + gateway_id: int + endpoint_id: int + + +class ConnectResponse(BaseModel): + """Response with connection details.""" + success: bool + message: str + vpn_config: str | None = None + target_ip: str | None = None + target_port: int | None = None + connection_id: int | None = None + + +class DisconnectRequest(BaseModel): + """Request to disconnect from endpoint.""" + connection_id: int + + +def user_has_gateway_access(db: Session, user: User, gateway_id: int) -> bool: + """Check if user has access to gateway.""" + if user.role in (UserRole.SUPER_ADMIN, UserRole.ADMIN): + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + if user.role == UserRole.SUPER_ADMIN: + return gateway is not None + return gateway and gateway.tenant_id == user.tenant_id + + access = db.query(UserGatewayAccess).filter( + UserGatewayAccess.user_id == user.id, + UserGatewayAccess.gateway_id == gateway_id + ).first() + return access is not None + + +@router.post("/connect", response_model=ConnectResponse) +def connect_to_endpoint( + request: ConnectRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Request connection to a specific endpoint through a gateway.""" + # Check gateway access + if not user_has_gateway_access(db, current_user, request.gateway_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No access to this gateway" + ) + + # Get gateway + gateway = db.query(Gateway).filter(Gateway.id == request.gateway_id).first() + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Gateway not found" + ) + + if not gateway.is_online: + return ConnectResponse( + success=False, + message="Gateway is offline" + ) + + # Get endpoint + endpoint = db.query(Endpoint).filter( + Endpoint.id == request.endpoint_id, + Endpoint.gateway_id == request.gateway_id + ).first() + + if not endpoint: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Endpoint not found" + ) + + # NOTE: Dynamic VPN config generation has been replaced by VPN profiles. + # Gateways should have pre-provisioned VPN profiles. + # This endpoint now just logs the connection intent. + + # Log connection + connection = ConnectionLog( + user_id=current_user.id, + gateway_id=gateway.id, + endpoint_id=endpoint.id, + client_ip=None # Will be updated when VPN connects + ) + db.add(connection) + db.commit() + db.refresh(connection) + + return ConnectResponse( + success=True, + message="Connection logged. Use the gateway's VPN profile configuration to connect.", + vpn_config=None, # VPN config is now obtained through gateway VPN profiles + target_ip=endpoint.internal_ip, + target_port=endpoint.port, + connection_id=connection.id + ) + + +@router.post("/disconnect") +def disconnect_from_endpoint( + request: DisconnectRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Disconnect from endpoint.""" + connection = db.query(ConnectionLog).filter( + ConnectionLog.id == request.connection_id, + ConnectionLog.user_id == current_user.id + ).first() + + if not connection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Connection not found" + ) + + if connection.disconnected_at: + return {"message": "Already disconnected"} + + # Revoke firewall rules if VPN IP is known + if connection.vpn_ip: + endpoint = db.query(Endpoint).filter(Endpoint.id == connection.endpoint_id).first() + gateway = db.query(Gateway).filter(Gateway.id == connection.gateway_id).first() + + if endpoint and gateway: + firewall = FirewallService() + firewall.revoke_connection( + client_vpn_ip=connection.vpn_ip, + gateway_vpn_ip=gateway.vpn_ip, + target_ip=endpoint.internal_ip, + target_port=endpoint.port, + protocol=endpoint.protocol.value + ) + + # Disconnect VPN client + vpn_service = VPNService() + client_cn = f"client-{current_user.username}-{current_user.id}" + vpn_service.disconnect_client(client_cn) + + # Update log + connection.disconnected_at = datetime.utcnow() + db.commit() + + return {"message": "Disconnected successfully"} + + +@router.get("/active") +def list_active_connections( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """List active connections for current user.""" + connections = db.query(ConnectionLog).filter( + ConnectionLog.user_id == current_user.id, + ConnectionLog.disconnected_at.is_(None) + ).all() + + result = [] + for conn in connections: + gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first() + endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first() + + result.append({ + "connection_id": conn.id, + "gateway_name": gateway.name if gateway else None, + "endpoint_name": endpoint.name if endpoint else None, + "endpoint_address": endpoint.address if endpoint else None, + "connected_at": conn.connected_at.isoformat(), + "vpn_ip": conn.vpn_ip + }) + + return result + + +@router.get("/history") +def get_connection_history( + skip: int = 0, + limit: int = 50, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get connection history for current user.""" + query = db.query(ConnectionLog).filter(ConnectionLog.user_id == current_user.id) + + # Admins can see all connections in their tenant + if current_user.is_admin: + if current_user.role == UserRole.SUPER_ADMIN: + query = db.query(ConnectionLog) + else: + # Filter by tenant's gateways + query = db.query(ConnectionLog).join( + Gateway, + Gateway.id == ConnectionLog.gateway_id + ).filter(Gateway.tenant_id == current_user.tenant_id) + + connections = query.order_by(ConnectionLog.connected_at.desc()).offset(skip).limit(limit).all() + + result = [] + for conn in connections: + user = db.query(User).filter(User.id == conn.user_id).first() + gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first() + endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first() + + result.append({ + "connection_id": conn.id, + "username": user.username if user else None, + "gateway_name": gateway.name if gateway else None, + "endpoint_name": endpoint.name if endpoint else None, + "connected_at": conn.connected_at.isoformat(), + "disconnected_at": conn.disconnected_at.isoformat() if conn.disconnected_at else None, + "duration_seconds": conn.duration_seconds, + "client_ip": conn.client_ip + }) + + return result diff --git a/server/app/api/deps.py b/server/app/api/deps.py new file mode 100644 index 0000000..954c434 --- /dev/null +++ b/server/app/api/deps.py @@ -0,0 +1,87 @@ +"""API dependencies for authentication and authorization.""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.user import User, UserRole +from ..utils.security import decode_token + +# Bearer token security scheme +security = HTTPBearer() + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + """Get current authenticated user from JWT token.""" + token = credentials.credentials + payload = decode_token(token) + + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"} + ) + + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type", + headers={"WWW-Authenticate": "Bearer"} + ) + + user_id = payload.get("sub") + user = db.query(User).filter(User.id == user_id).first() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is deactivated" + ) + + return user + + +def get_current_active_user( + current_user: User = Depends(get_current_user) +) -> User: + """Ensure user is active.""" + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user" + ) + return current_user + + +def require_admin( + current_user: User = Depends(get_current_user) +) -> User: + """Require admin role.""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required" + ) + return current_user + + +def require_super_admin( + current_user: User = Depends(get_current_user) +) -> User: + """Require super admin role.""" + if current_user.role != UserRole.SUPER_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Super admin privileges required" + ) + return current_user diff --git a/server/app/api/endpoints.py b/server/app/api/endpoints.py new file mode 100644 index 0000000..e4dfe32 --- /dev/null +++ b/server/app/api/endpoints.py @@ -0,0 +1,231 @@ +"""Endpoint management API routes.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.endpoint import Endpoint, ApplicationTemplate +from ..models.gateway import Gateway +from ..models.user import User, UserRole +from ..models.access import UserGatewayAccess, UserEndpointAccess +from ..schemas.endpoint import ( + EndpointCreate, EndpointUpdate, EndpointResponse, + ApplicationTemplateResponse +) +from .deps import get_current_user, require_admin + +router = APIRouter() + + +def user_has_gateway_access(db: Session, user: User, gateway_id: int) -> bool: + """Check if user has access to gateway.""" + if user.role == UserRole.SUPER_ADMIN: + return True + if user.role == UserRole.ADMIN: + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + return gateway and gateway.tenant_id == user.tenant_id + + access = db.query(UserGatewayAccess).filter( + UserGatewayAccess.user_id == user.id, + UserGatewayAccess.gateway_id == gateway_id + ).first() + return access is not None + + +@router.get("/templates", response_model=list[ApplicationTemplateResponse]) +def list_application_templates( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """List available application templates.""" + templates = db.query(ApplicationTemplate).all() + return templates + + +@router.get("/gateway/{gateway_id}", response_model=list[EndpointResponse]) +def list_endpoints_for_gateway( + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """List endpoints for a gateway.""" + if not user_has_gateway_access(db, current_user, gateway_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No access to this gateway" + ) + + endpoints = db.query(Endpoint).filter(Endpoint.gateway_id == gateway_id).all() + return endpoints + + +@router.post("/gateway/{gateway_id}", response_model=EndpointResponse, status_code=status.HTTP_201_CREATED) +def create_endpoint( + gateway_id: int, + endpoint_data: EndpointCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Create a new endpoint for a gateway.""" + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Gateway not found" + ) + + # Check tenant access + if current_user.role != UserRole.SUPER_ADMIN: + if gateway.tenant_id != current_user.tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot add endpoints to gateways from other tenants" + ) + + endpoint = Endpoint( + **endpoint_data.model_dump(), + gateway_id=gateway_id + ) + db.add(endpoint) + db.commit() + db.refresh(endpoint) + return endpoint + + +@router.get("/{endpoint_id}", response_model=EndpointResponse) +def get_endpoint( + endpoint_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get endpoint by ID.""" + endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first() + if not endpoint: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Endpoint not found" + ) + + if not user_has_gateway_access(db, current_user, endpoint.gateway_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No access to this endpoint" + ) + + return endpoint + + +@router.put("/{endpoint_id}", response_model=EndpointResponse) +def update_endpoint( + endpoint_id: int, + endpoint_data: EndpointUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Update endpoint.""" + endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first() + if not endpoint: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Endpoint not found" + ) + + gateway = db.query(Gateway).filter(Gateway.id == endpoint.gateway_id).first() + + # Check tenant access + if current_user.role != UserRole.SUPER_ADMIN: + if gateway.tenant_id != current_user.tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot modify endpoints from other tenants" + ) + + update_data = endpoint_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(endpoint, field, value) + + db.commit() + db.refresh(endpoint) + return endpoint + + +@router.delete("/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_endpoint( + endpoint_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Delete endpoint.""" + endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first() + if not endpoint: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Endpoint not found" + ) + + gateway = db.query(Gateway).filter(Gateway.id == endpoint.gateway_id).first() + + # Check tenant access + if current_user.role != UserRole.SUPER_ADMIN: + if gateway.tenant_id != current_user.tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot delete endpoints from other tenants" + ) + + db.delete(endpoint) + db.commit() + + +@router.post("/{endpoint_id}/access/{user_id}", status_code=status.HTTP_201_CREATED) +def grant_endpoint_access( + endpoint_id: int, + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Grant user access to specific endpoint (fine-grained control).""" + endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first() + if not endpoint: + raise HTTPException(status_code=404, detail="Endpoint not found") + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + existing = db.query(UserEndpointAccess).filter( + UserEndpointAccess.endpoint_id == endpoint_id, + UserEndpointAccess.user_id == user_id + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Access already granted") + + access = UserEndpointAccess( + user_id=user_id, + endpoint_id=endpoint_id, + granted_by_id=current_user.id + ) + db.add(access) + db.commit() + + return {"message": "Access granted"} + + +@router.delete("/{endpoint_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def revoke_endpoint_access( + endpoint_id: int, + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Revoke user access to endpoint.""" + access = db.query(UserEndpointAccess).filter( + UserEndpointAccess.endpoint_id == endpoint_id, + UserEndpointAccess.user_id == user_id + ).first() + + if not access: + raise HTTPException(status_code=404, detail="Access not found") + + db.delete(access) + db.commit() diff --git a/server/app/api/gateways.py b/server/app/api/gateways.py new file mode 100644 index 0000000..4876d0c --- /dev/null +++ b/server/app/api/gateways.py @@ -0,0 +1,335 @@ +"""Gateway management API routes.""" + +from fastapi import APIRouter, Depends, HTTPException, status, Response +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.gateway import Gateway +from ..models.user import User, UserRole +from ..models.access import UserGatewayAccess +from ..models.vpn_connection_log import VPNConnectionLog +from ..schemas.gateway import GatewayCreate, GatewayUpdate, GatewayResponse, GatewayStatus +from ..services.vpn_sync_service import VPNSyncService +from .deps import get_current_user, require_admin + +router = APIRouter() + + +def get_accessible_gateways_query(db: Session, user: User): + """Get query for gateways accessible by user.""" + if user.role == UserRole.SUPER_ADMIN: + return db.query(Gateway) + elif user.role == UserRole.ADMIN: + return db.query(Gateway).filter(Gateway.tenant_id == user.tenant_id) + else: + # Technicians and viewers only see assigned gateways + return db.query(Gateway).join( + UserGatewayAccess, + UserGatewayAccess.gateway_id == Gateway.id + ).filter(UserGatewayAccess.user_id == user.id) + + +@router.get("/", response_model=list[GatewayResponse]) +def list_gateways( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """List gateways accessible by current user.""" + query = get_accessible_gateways_query(db, current_user) + gateways = query.offset(skip).limit(limit).all() + return gateways + + +@router.get("/status", response_model=list[GatewayStatus]) +def get_gateways_status( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get online status of all accessible gateways.""" + query = get_accessible_gateways_query(db, current_user) + gateways = query.all() + + return [ + GatewayStatus( + id=gw.id, + name=gw.name, + is_online=gw.is_online, + last_seen=gw.last_seen, + vpn_ip=gw.vpn_ip + ) + for gw in gateways + ] + + +@router.post("/", response_model=GatewayResponse, status_code=status.HTTP_201_CREATED) +def create_gateway( + gateway_data: GatewayCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Create a new gateway (admin only).""" + # Determine tenant_id + if current_user.role == UserRole.SUPER_ADMIN: + # Super admin must specify tenant_id via query param or we use a default + tenant_id = current_user.tenant_id or 1 # Fallback + else: + tenant_id = current_user.tenant_id + + # Generate VPN certificate CN + vpn_cert_cn = f"gateway-{gateway_data.name.lower().replace(' ', '-')}" + + gateway = Gateway( + **gateway_data.model_dump(), + tenant_id=tenant_id, + vpn_cert_cn=vpn_cert_cn + ) + db.add(gateway) + db.commit() + db.refresh(gateway) + return gateway + + +@router.get("/{gateway_id}", response_model=GatewayResponse) +def get_gateway( + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get gateway by ID.""" + query = get_accessible_gateways_query(db, current_user) + gateway = query.filter(Gateway.id == gateway_id).first() + + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Gateway not found or access denied" + ) + + return gateway + + +@router.put("/{gateway_id}", response_model=GatewayResponse) +def update_gateway( + gateway_id: int, + gateway_data: GatewayUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Update gateway (admin only).""" + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Gateway not found" + ) + + # Check tenant access + if current_user.role != UserRole.SUPER_ADMIN: + if gateway.tenant_id != current_user.tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot modify gateways from other tenants" + ) + + update_data = gateway_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(gateway, field, value) + + db.commit() + db.refresh(gateway) + return gateway + + +@router.delete("/{gateway_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_gateway( + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Delete gateway (admin only).""" + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Gateway not found" + ) + + # Check tenant access + if current_user.role != UserRole.SUPER_ADMIN: + if gateway.tenant_id != current_user.tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot delete gateways from other tenants" + ) + + db.delete(gateway) + db.commit() + + +@router.get("/{gateway_id}/provision") +def download_provisioning_package( + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Download provisioning package for gateway. + + DEPRECATED: Use VPN profiles for provisioning. + GET /api/gateways/{gateway_id}/profiles/{profile_id}/provision + """ + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Gateway not found" + ) + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provisioning is now done through VPN profiles. " + "Please create a VPN profile for this gateway first, " + "then use GET /api/gateways/{gateway_id}/profiles/{profile_id}/provision" + ) + + +@router.post("/{gateway_id}/access/{user_id}", status_code=status.HTTP_201_CREATED) +def grant_gateway_access( + gateway_id: int, + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Grant user access to gateway.""" + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + if not gateway: + raise HTTPException(status_code=404, detail="Gateway not found") + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Check existing access + existing = db.query(UserGatewayAccess).filter( + UserGatewayAccess.gateway_id == gateway_id, + UserGatewayAccess.user_id == user_id + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Access already granted") + + access = UserGatewayAccess( + user_id=user_id, + gateway_id=gateway_id, + granted_by_id=current_user.id + ) + db.add(access) + db.commit() + + return {"message": "Access granted"} + + +@router.delete("/{gateway_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def revoke_gateway_access( + gateway_id: int, + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Revoke user access to gateway.""" + access = db.query(UserGatewayAccess).filter( + UserGatewayAccess.gateway_id == gateway_id, + UserGatewayAccess.user_id == user_id + ).first() + + if not access: + raise HTTPException(status_code=404, detail="Access not found") + + db.delete(access) + db.commit() + + +@router.get("/{gateway_id}/vpn-logs") +def get_gateway_vpn_logs( + gateway_id: int, + limit: int = 50, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get VPN connection logs for a gateway.""" + # Check access + query = get_accessible_gateways_query(db, current_user) + gateway = query.filter(Gateway.id == gateway_id).first() + + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Gateway not found or access denied" + ) + + sync_service = VPNSyncService(db) + logs = sync_service.get_gateway_connection_logs(gateway_id, limit=limit) + + return [ + { + "id": log.id, + "profile_id": log.vpn_profile_id, + "profile_name": log.vpn_profile.name if log.vpn_profile else None, + "server_id": log.vpn_server_id, + "server_name": log.vpn_server.name if log.vpn_server else None, + "common_name": log.common_name, + "real_address": log.real_address, + "vpn_ip": log.vpn_ip, + "connected_at": log.connected_at.isoformat() if log.connected_at else None, + "disconnected_at": log.disconnected_at.isoformat() if log.disconnected_at else None, + "duration_seconds": log.duration_seconds, + "bytes_received": log.bytes_received, + "bytes_sent": log.bytes_sent, + "is_active": log.is_active + } + for log in logs + ] + + +@router.get("/{gateway_id}/vpn-status") +def get_gateway_vpn_status( + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get current VPN connection status for a gateway.""" + # Check access + query = get_accessible_gateways_query(db, current_user) + gateway = query.filter(Gateway.id == gateway_id).first() + + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Gateway not found or access denied" + ) + + # Sync connections first + sync_service = VPNSyncService(db) + sync_service.sync_all_connections() + + # Get active connection + active_log = db.query(VPNConnectionLog).filter( + VPNConnectionLog.gateway_id == gateway_id, + VPNConnectionLog.disconnected_at.is_(None) + ).first() + + if active_log: + return { + "is_connected": True, + "profile_name": active_log.vpn_profile.name if active_log.vpn_profile else None, + "server_name": active_log.vpn_server.name if active_log.vpn_server else None, + "real_address": active_log.real_address, + "connected_since": active_log.connected_at.isoformat() if active_log.connected_at else None, + "bytes_received": active_log.bytes_received, + "bytes_sent": active_log.bytes_sent + } + else: + return { + "is_connected": False, + "last_connection": gateway.last_seen.isoformat() if gateway.last_seen else None + } diff --git a/server/app/api/internal.py b/server/app/api/internal.py new file mode 100644 index 0000000..11a7855 --- /dev/null +++ b/server/app/api/internal.py @@ -0,0 +1,363 @@ +"""Internal API endpoints for container-to-container communication. + +These endpoints are used by OpenVPN containers to fetch their configuration. +They should only be accessible from within the Docker network. +""" + +import os +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, Response, Query +from sqlalchemy.orm import Session + +from ..database import get_db +from ..models.vpn_server import VPNServer, VPNServerStatus +from ..models.vpn_profile import VPNProfile +from ..services.vpn_server_service import VPNServerService +from ..services.vpn_sync_service import VPNSyncService +from ..services.certificate_service import CertificateService + +# Log directory (shared volume with OpenVPN container) +LOG_DIR = Path("/var/log/openvpn") + +router = APIRouter(prefix="/api/internal", tags=["internal"]) + + +@router.get("/health") +async def health_check(): + """Health check endpoint for container startup.""" + return {"status": "healthy"} + + +@router.get("/vpn-servers/active") +async def get_active_servers(db: Session = Depends(get_db)): + """Get list of all active and ready VPN servers. + + Used by OpenVPN container to discover which servers to start. + """ + servers = db.query(VPNServer).filter( + VPNServer.is_active == True + ).all() + + return [ + { + "id": s.id, + "name": s.name, + "port": s.port, + "protocol": s.protocol.value, + "management_port": s.management_port, + "vpn_network": s.vpn_network, + "vpn_netmask": s.vpn_netmask, + "is_ready": s.is_ready, + "has_ca": s.certificate_authority is not None and s.certificate_authority.is_ready, + "has_cert": s.server_cert is not None and s.server_key is not None + } + for s in servers + ] + + +@router.get("/vpn-servers/{server_id}/config") +async def get_server_config( + server_id: int, + db: Session = Depends(get_db) +): + """Get OpenVPN server configuration file.""" + service = VPNServerService(db) + server = service.get_server_by_id(server_id) + + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + if not server.server_cert or not server.server_key: + raise HTTPException(status_code=400, detail="Server certificate not generated") + + config = service.generate_server_config(server) + + # Update server status + server.status = VPNServerStatus.STARTING + db.commit() + + return Response(content=config, media_type="text/plain") + + +@router.get("/vpn-servers/{server_id}/ca") +async def get_server_ca( + server_id: int, + db: Session = Depends(get_db) +): + """Get CA certificate for a VPN server.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + if not server.certificate_authority or not server.certificate_authority.ca_cert: + raise HTTPException(status_code=400, detail="CA not available") + + return Response( + content=server.certificate_authority.ca_cert, + media_type="application/x-pem-file" + ) + + +@router.get("/vpn-servers/{server_id}/cert") +async def get_server_cert( + server_id: int, + db: Session = Depends(get_db) +): + """Get server certificate.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + if not server.server_cert: + raise HTTPException(status_code=400, detail="Server certificate not generated") + + return Response( + content=server.server_cert, + media_type="application/x-pem-file" + ) + + +@router.get("/vpn-servers/{server_id}/key") +async def get_server_key( + server_id: int, + db: Session = Depends(get_db) +): + """Get server private key.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + if not server.server_key: + raise HTTPException(status_code=400, detail="Server key not generated") + + return Response( + content=server.server_key, + media_type="application/x-pem-file" + ) + + +@router.get("/vpn-servers/{server_id}/dh") +async def get_server_dh( + server_id: int, + db: Session = Depends(get_db) +): + """Get DH parameters.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + if not server.certificate_authority or not server.certificate_authority.dh_params: + raise HTTPException(status_code=400, detail="DH parameters not available") + + return Response( + content=server.certificate_authority.dh_params, + media_type="application/x-pem-file" + ) + + +@router.get("/vpn-servers/{server_id}/ta") +async def get_server_ta( + server_id: int, + db: Session = Depends(get_db) +): + """Get TLS-Auth key.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + if not server.ta_key: + raise HTTPException(status_code=400, detail="TA key not available") + + return Response( + content=server.ta_key, + media_type="text/plain" + ) + + +@router.get("/vpn-servers/{server_id}/crl") +async def get_server_crl( + server_id: int, + db: Session = Depends(get_db) +): + """Get Certificate Revocation List.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + if not server.certificate_authority: + raise HTTPException(status_code=400, detail="CA not available") + + cert_service = CertificateService(db) + crl = cert_service.get_crl(server.certificate_authority) + + return Response( + content=crl, + media_type="application/x-pem-file" + ) + + +@router.post("/vpn-servers/{server_id}/started") +async def notify_server_started( + server_id: int, + db: Session = Depends(get_db) +): + """Notify that server has started successfully.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + server.status = VPNServerStatus.RUNNING + db.commit() + + return {"status": "ok"} + + +@router.post("/vpn-servers/{server_id}/stopped") +async def notify_server_stopped( + server_id: int, + db: Session = Depends(get_db) +): + """Notify that server has stopped.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + server.status = VPNServerStatus.STOPPED + db.commit() + + return {"status": "ok"} + + +@router.get("/vpn-servers/{server_id}/logs") +async def get_server_logs( + server_id: int, + lines: int = Query(default=100, le=1000), + db: Session = Depends(get_db) +): + """Get OpenVPN server log file. + + Args: + server_id: VPN server ID + lines: Number of lines to return (max 1000) + """ + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + log_file = LOG_DIR / f"server-{server_id}.log" + + if not log_file.exists(): + return {"lines": [], "message": "Log file not found"} + + try: + # Read last N lines + with open(log_file, 'r') as f: + all_lines = f.readlines() + log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines + + return { + "server_id": server_id, + "server_name": server.name, + "total_lines": len(all_lines), + "returned_lines": len(log_lines), + "lines": [line.rstrip() for line in log_lines] + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error reading log: {str(e)}") + + +@router.get("/vpn-servers/{server_id}/logs/raw") +async def get_server_logs_raw( + server_id: int, + lines: int = Query(default=100, le=5000), + db: Session = Depends(get_db) +): + """Get OpenVPN server log file as plain text.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + raise HTTPException(status_code=404, detail="Server not found") + + log_file = LOG_DIR / f"server-{server_id}.log" + + if not log_file.exists(): + return Response(content="Log file not found", media_type="text/plain") + + try: + with open(log_file, 'r') as f: + all_lines = f.readlines() + log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines + + return Response( + content="".join(log_lines), + media_type="text/plain" + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error reading log: {str(e)}") + + +@router.get("/logs/supervisord") +async def get_supervisord_logs( + lines: int = Query(default=100, le=1000) +): + """Get supervisord log file.""" + log_file = LOG_DIR / "supervisord.log" + + if not log_file.exists(): + return {"lines": [], "message": "Supervisord log not found"} + + try: + with open(log_file, 'r') as f: + all_lines = f.readlines() + log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines + + return { + "total_lines": len(all_lines), + "returned_lines": len(log_lines), + "lines": [line.rstrip() for line in log_lines] + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error reading log: {str(e)}") + + +@router.get("/debug/sync") +async def debug_sync(db: Session = Depends(get_db)): + """Debug endpoint to check VPN sync status.""" + sync_service = VPNSyncService(db) + vpn_service = VPNServerService(db) + + # Get all profiles with their CNs + profiles = db.query(VPNProfile).all() + profile_cns = [{"id": p.id, "name": p.name, "cert_cn": p.cert_cn, "gateway_id": p.gateway_id} for p in profiles] + + # Get connected clients from all servers + servers = db.query(VPNServer).filter(VPNServer.is_active == True).all() + connected_clients = [] + + for server in servers: + try: + clients = vpn_service.get_connected_clients(server) + for client in clients: + client['server_id'] = server.id + client['server_name'] = server.name + connected_clients.append(client) + except Exception as e: + connected_clients.append({"server_id": server.id, "error": str(e)}) + + # Run sync and get result + sync_result = sync_service.sync_all_connections() + + return { + "profiles": profile_cns, + "connected_clients": connected_clients, + "sync_result": sync_result + } diff --git a/server/app/api/tenants.py b/server/app/api/tenants.py new file mode 100644 index 0000000..0cf5cb9 --- /dev/null +++ b/server/app/api/tenants.py @@ -0,0 +1,103 @@ +"""Tenant management API routes.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.tenant import Tenant +from ..models.user import User +from ..schemas.tenant import TenantCreate, TenantUpdate, TenantResponse +from .deps import require_super_admin + +router = APIRouter() + + +@router.get("/", response_model=list[TenantResponse]) +def list_tenants( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin) +): + """List all tenants (super admin only).""" + tenants = db.query(Tenant).offset(skip).limit(limit).all() + return tenants + + +@router.post("/", response_model=TenantResponse, status_code=status.HTTP_201_CREATED) +def create_tenant( + tenant_data: TenantCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin) +): + """Create a new tenant (super admin only).""" + # Check if name already exists + existing = db.query(Tenant).filter(Tenant.name == tenant_data.name).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tenant with this name already exists" + ) + + tenant = Tenant(**tenant_data.model_dump()) + db.add(tenant) + db.commit() + db.refresh(tenant) + return tenant + + +@router.get("/{tenant_id}", response_model=TenantResponse) +def get_tenant( + tenant_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin) +): + """Get tenant by ID (super admin only).""" + tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found" + ) + return tenant + + +@router.put("/{tenant_id}", response_model=TenantResponse) +def update_tenant( + tenant_id: int, + tenant_data: TenantUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin) +): + """Update tenant (super admin only).""" + tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found" + ) + + update_data = tenant_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(tenant, field, value) + + db.commit() + db.refresh(tenant) + return tenant + + +@router.delete("/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_tenant( + tenant_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin) +): + """Delete tenant (super admin only).""" + tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found" + ) + + db.delete(tenant) + db.commit() diff --git a/server/app/api/users.py b/server/app/api/users.py new file mode 100644 index 0000000..7d50b7b --- /dev/null +++ b/server/app/api/users.py @@ -0,0 +1,176 @@ +"""User management API routes.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.user import User, UserRole +from ..schemas.user import UserCreate, UserUpdate, UserResponse +from ..utils.security import get_password_hash +from .deps import get_current_user, require_admin + +router = APIRouter() + + +@router.get("/", response_model=list[UserResponse]) +def list_users( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """List users (admins see their tenant's users, super admins see all).""" + query = db.query(User) + + # Filter by tenant for non-super-admins + if current_user.role != UserRole.SUPER_ADMIN: + query = query.filter(User.tenant_id == current_user.tenant_id) + + users = query.offset(skip).limit(limit).all() + return users + + +@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def create_user( + user_data: UserCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Create a new user.""" + # Check permissions + if current_user.role != UserRole.SUPER_ADMIN: + # Regular admins can only create users in their own tenant + if user_data.tenant_id != current_user.tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot create users in other tenants" + ) + # Cannot create super admins + if user_data.role == UserRole.SUPER_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot create super admin users" + ) + + # Check if username exists + existing = db.query(User).filter(User.username == user_data.username).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already exists" + ) + + # Check if email exists + existing = db.query(User).filter(User.email == user_data.email).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already exists" + ) + + user = User( + username=user_data.username, + email=user_data.email, + password_hash=get_password_hash(user_data.password), + full_name=user_data.full_name, + role=user_data.role, + tenant_id=user_data.tenant_id + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.get("/{user_id}", response_model=UserResponse) +def get_user( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Get user by ID.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Check tenant access + if current_user.role != UserRole.SUPER_ADMIN: + if user.tenant_id != current_user.tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot access users from other tenants" + ) + + return user + + +@router.put("/{user_id}", response_model=UserResponse) +def update_user( + user_id: int, + user_data: UserUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Update user.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Check tenant access + if current_user.role != UserRole.SUPER_ADMIN: + if user.tenant_id != current_user.tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot modify users from other tenants" + ) + + update_data = user_data.model_dump(exclude_unset=True) + + # Hash password if provided + if "password" in update_data: + update_data["password_hash"] = get_password_hash(update_data.pop("password")) + + for field, value in update_data.items(): + setattr(user, field, value) + + db.commit() + db.refresh(user) + return user + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """Delete user.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Cannot delete yourself + if user.id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete your own account" + ) + + # Check tenant access + if current_user.role != UserRole.SUPER_ADMIN: + if user.tenant_id != current_user.tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot delete users from other tenants" + ) + + db.delete(user) + db.commit() diff --git a/server/app/config.py b/server/app/config.py new file mode 100644 index 0000000..66d5057 --- /dev/null +++ b/server/app/config.py @@ -0,0 +1,41 @@ +"""Application configuration using Pydantic settings.""" + +from functools import lru_cache +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # Database + database_url: str = "mysql+pymysql://mguard:password@localhost:3306/mguard_vpn" + + # Security + secret_key: str = "change-me-in-production" + algorithm: str = "HS256" + access_token_expire_minutes: int = 60 * 24 # 24 hours + refresh_token_expire_days: int = 7 + + # OpenVPN Management Interface + openvpn_management_host: str = "openvpn" + openvpn_management_port: int = 7505 + + # VPN Network + vpn_network: str = "10.8.0.0" + vpn_netmask: str = "255.255.255.0" + vpn_server_address: str = "vpn.example.com" # External address for clients to connect + + # Admin defaults + admin_username: str = "admin" + admin_password: str = "changeme" + admin_email: str = "admin@example.com" + + class Config: + env_file = ".env" + case_sensitive = False + + +@lru_cache() +def get_settings() -> Settings: + """Get cached settings instance.""" + return Settings() diff --git a/server/app/database.py b/server/app/database.py new file mode 100644 index 0000000..263a4fb --- /dev/null +++ b/server/app/database.py @@ -0,0 +1,36 @@ +"""Database connection and session management.""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from .config import get_settings + +settings = get_settings() + +# Create engine with connection pooling +engine = create_engine( + settings.database_url, + pool_pre_ping=True, + pool_recycle=3600, + echo=False # Set to True for SQL debugging +) + +# Session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base class for models +Base = declarative_base() + + +def get_db(): + """Dependency for getting database session.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Initialize database tables.""" + from . import models # noqa: F401 + Base.metadata.create_all(bind=engine) diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000..eb35b0e --- /dev/null +++ b/server/app/main.py @@ -0,0 +1,154 @@ +"""FastAPI main application entry point.""" + +from contextlib import asynccontextmanager +from pathlib import Path +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware +from sqlalchemy.orm import Session + +from .config import get_settings +from .database import engine, Base, SessionLocal +from .api import api_router +from .api.internal import router as internal_router +from .web import web_router +from .models.user import User, UserRole +from .models.tenant import Tenant +from .models.endpoint import ApplicationTemplate, DEFAULT_APPLICATION_TEMPLATES +from .utils.security import get_password_hash + +settings = get_settings() + +# Template directory +TEMPLATE_DIR = Path(__file__).parent / "templates" +STATIC_DIR = Path(__file__).parent / "static" + + +def init_db(): + """Initialize database with tables and default data.""" + # Create all tables + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + try: + # Create default tenant if none exists + default_tenant = db.query(Tenant).first() + if not default_tenant: + default_tenant = Tenant( + name="Default", + description="Default tenant" + ) + db.add(default_tenant) + db.commit() + db.refresh(default_tenant) + print("Created default tenant") + + # Create admin user if none exists + admin_user = db.query(User).filter(User.role == UserRole.SUPER_ADMIN).first() + if not admin_user: + admin_user = User( + username=settings.admin_username, + email=settings.admin_email, + password_hash=get_password_hash(settings.admin_password), + role=UserRole.SUPER_ADMIN, + tenant_id=None # Super admin has no tenant + ) + db.add(admin_user) + db.commit() + print(f"Created admin user: {settings.admin_username}") + + # Seed application templates + existing_templates = db.query(ApplicationTemplate).count() + if existing_templates == 0: + for template_data in DEFAULT_APPLICATION_TEMPLATES: + template = ApplicationTemplate(**template_data) + db.add(template) + db.commit() + print(f"Seeded {len(DEFAULT_APPLICATION_TEMPLATES)} application templates") + + finally: + db.close() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan events.""" + # Startup + print("Starting mGuard VPN Endpoint Server...") + init_db() + yield + # Shutdown + print("Shutting down mGuard VPN Endpoint Server...") + + +# Create FastAPI application +app = FastAPI( + title="mGuard VPN Endpoint Server", + description=""" + VPN management system for Phoenix Contact mGuard routers. + + ## Features + - Multi-tenant gateway management + - Endpoint configuration (IP + Port) + - User access control + - VPN connection management + - Connection logging and auditing + """, + version="1.0.0", + lifespan=lifespan, + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json" +) + +# Session middleware for web UI +app.add_middleware( + SessionMiddleware, + secret_key=settings.secret_key, + session_cookie="mguard_session", + max_age=86400 * 7, # 7 days +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Configure properly in production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount static files +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + +# Setup Jinja2 templates +templates = Jinja2Templates(directory=str(TEMPLATE_DIR)) +app.state.templates = templates + +# Include API router +app.include_router(api_router) + +# Include Internal API router (for container-to-container communication) +app.include_router(internal_router) + +# Include Web router +app.include_router(web_router) + + +@app.get("/") +def root(): + """Root endpoint with API information.""" + return { + "name": "mGuard VPN Endpoint Server", + "version": "1.0.0", + "docs": "/docs", + "health": "/health" + } + + +@app.get("/health") +def health_check(): + """Health check endpoint.""" + return {"status": "healthy"} diff --git a/server/app/models/__init__.py b/server/app/models/__init__.py new file mode 100644 index 0000000..d8fa55b --- /dev/null +++ b/server/app/models/__init__.py @@ -0,0 +1,36 @@ +"""SQLAlchemy models for mGuard VPN Endpoint Server.""" + +from .tenant import Tenant +from .user import User +from .gateway import Gateway, RouterType +from .endpoint import Endpoint, ApplicationTemplate +from .access import UserGatewayAccess, UserEndpointAccess, ConnectionLog +from .certificate_authority import CertificateAuthority, CAStatus, CAAlgorithm +from .vpn_server import VPNServer, VPNProtocol, VPNCipher, VPNAuth, VPNCompression, VPNServerStatus +from .vpn_profile import VPNProfile, VPNProfileStatus +from .vpn_connection_log import VPNConnectionLog + +__all__ = [ + "Tenant", + "User", + "Gateway", + "RouterType", + "Endpoint", + "ApplicationTemplate", + "UserGatewayAccess", + "UserEndpointAccess", + "ConnectionLog", + # PKI & VPN + "CertificateAuthority", + "CAStatus", + "CAAlgorithm", + "VPNServer", + "VPNProtocol", + "VPNCipher", + "VPNAuth", + "VPNCompression", + "VPNServerStatus", + "VPNProfile", + "VPNProfileStatus", + "VPNConnectionLog", +] diff --git a/server/app/models/access.py b/server/app/models/access.py new file mode 100644 index 0000000..86d8e54 --- /dev/null +++ b/server/app/models/access.py @@ -0,0 +1,78 @@ +"""Access control and logging models.""" + +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from ..database import Base + + +class UserGatewayAccess(Base): + """User access to gateways.""" + + __tablename__ = "user_gateway_access" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + gateway_id = Column(Integer, ForeignKey("gateways.id"), nullable=False) + granted_at = Column(DateTime, default=datetime.utcnow, nullable=False) + granted_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + + # Relationships + user = relationship("User", foreign_keys=[user_id], back_populates="gateway_access") + gateway = relationship("Gateway", back_populates="user_access") + granted_by = relationship("User", foreign_keys=[granted_by_id]) + + def __repr__(self): + return f"" + + +class UserEndpointAccess(Base): + """User access to specific endpoints (optional fine-grained control).""" + + __tablename__ = "user_endpoint_access" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + endpoint_id = Column(Integer, ForeignKey("endpoints.id"), nullable=False) + granted_at = Column(DateTime, default=datetime.utcnow, nullable=False) + granted_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + + # Relationships + user = relationship("User", foreign_keys=[user_id], back_populates="endpoint_access") + endpoint = relationship("Endpoint", back_populates="user_access") + granted_by = relationship("User", foreign_keys=[granted_by_id]) + + def __repr__(self): + return f"" + + +class ConnectionLog(Base): + """Log of VPN connections for auditing.""" + + __tablename__ = "connection_logs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + gateway_id = Column(Integer, ForeignKey("gateways.id"), nullable=False) + endpoint_id = Column(Integer, ForeignKey("endpoints.id"), nullable=True) + + # Connection details + client_ip = Column(String(45), nullable=True) # Client's real IP + vpn_ip = Column(String(45), nullable=True) # Assigned VPN IP + connected_at = Column(DateTime, default=datetime.utcnow, nullable=False) + disconnected_at = Column(DateTime, nullable=True) + + # Relationships + user = relationship("User", back_populates="connection_logs") + gateway = relationship("Gateway", back_populates="connection_logs") + endpoint = relationship("Endpoint", back_populates="connection_logs") + + def __repr__(self): + return f"" + + @property + def duration_seconds(self) -> int | None: + """Get connection duration in seconds.""" + if self.disconnected_at: + return int((self.disconnected_at - self.connected_at).total_seconds()) + return None diff --git a/server/app/models/certificate_authority.py b/server/app/models/certificate_authority.py new file mode 100644 index 0000000..9f58477 --- /dev/null +++ b/server/app/models/certificate_authority.py @@ -0,0 +1,99 @@ +"""Certificate Authority model for PKI management.""" + +from datetime import datetime +from enum import Enum as PyEnum +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, Text +from sqlalchemy.orm import relationship +from ..database import Base + + +class CAStatus(str, PyEnum): + """Certificate Authority status.""" + PENDING = "pending" # DH parameters being generated + ACTIVE = "active" + EXPIRED = "expired" + REVOKED = "revoked" + + +class CAAlgorithm(str, PyEnum): + """Key algorithm for CA.""" + RSA = "rsa" + ECDSA = "ecdsa" + + +class CertificateAuthority(Base): + """Certificate Authority for issuing VPN certificates.""" + + __tablename__ = "certificate_authorities" + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True) # NULL for global CA + + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + + # Certificate data (PEM encoded) + ca_cert = Column(Text, nullable=True) # CA certificate + ca_key = Column(Text, nullable=True) # CA private key (encrypted) + + # DH parameters (shared across servers using this CA) + dh_params = Column(Text, nullable=True) # Pre-generated DH parameters + dh_generating = Column(Boolean, default=False) # DH generation in progress + + # Key configuration + key_size = Column(Integer, default=4096) + algorithm = Column(Enum(CAAlgorithm), default=CAAlgorithm.RSA) + + # Validity + valid_from = Column(DateTime, nullable=True) + valid_until = Column(DateTime, nullable=True) + + # Status + is_default = Column(Boolean, default=False) # Default CA for new certificates + status = Column(Enum(CAStatus), default=CAStatus.PENDING) + + # CRL (Certificate Revocation List) + crl = Column(Text, nullable=True) + crl_updated_at = Column(DateTime, nullable=True) + + # Serial number tracking + next_serial = Column(Integer, default=1) + + # Audit + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + + # Relationships + tenant = relationship("Tenant", back_populates="certificate_authorities") + created_by = relationship("User", foreign_keys=[created_by_id]) + vpn_servers = relationship("VPNServer", back_populates="certificate_authority") + vpn_profiles = relationship("VPNProfile", back_populates="certificate_authority") + + def __repr__(self): + return f"" + + @property + def is_ready(self) -> bool: + """Check if CA is ready for issuing certificates.""" + return ( + self.status == CAStatus.ACTIVE and + self.ca_cert is not None and + self.ca_key is not None and + self.dh_params is not None + ) + + @property + def is_expired(self) -> bool: + """Check if CA certificate is expired.""" + if self.valid_until: + return datetime.utcnow() > self.valid_until + return False + + @property + def days_until_expiry(self) -> int | None: + """Days until CA expires.""" + if self.valid_until: + delta = self.valid_until - datetime.utcnow() + return max(0, delta.days) + return None diff --git a/server/app/models/endpoint.py b/server/app/models/endpoint.py new file mode 100644 index 0000000..9ba785b --- /dev/null +++ b/server/app/models/endpoint.py @@ -0,0 +1,87 @@ +"""Endpoint model for devices behind gateways.""" + +from datetime import datetime +from enum import Enum as PyEnum +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Text +from sqlalchemy.orm import relationship +from ..database import Base + + +class Protocol(str, PyEnum): + """Network protocol for endpoint access.""" + TCP = "tcp" + UDP = "udp" + + +class Endpoint(Base): + """Endpoint model representing a device/service behind a gateway.""" + + __tablename__ = "endpoints" + + id = Column(Integer, primary_key=True, index=True) + gateway_id = Column(Integer, ForeignKey("gateways.id"), nullable=False) + + # Endpoint info + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + + # Network configuration + internal_ip = Column(String(45), nullable=False) # IP in customer network + port = Column(Integer, nullable=False) + protocol = Column(Enum(Protocol), default=Protocol.TCP, nullable=False) + + # Application info + application_name = Column(String(100), nullable=True) # e.g., "CoDeSys", "SSH", "HTTP" + application_template_id = Column(Integer, ForeignKey("application_templates.id"), nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + gateway = relationship("Gateway", back_populates="endpoints") + user_access = relationship("UserEndpointAccess", back_populates="endpoint", cascade="all, delete-orphan") + application_template = relationship("ApplicationTemplate") + connection_logs = relationship("ConnectionLog", back_populates="endpoint") + + def __repr__(self): + return f"" + + @property + def address(self) -> str: + """Get full address string.""" + return f"{self.internal_ip}:{self.port}" + + +class ApplicationTemplate(Base): + """Pre-defined application templates with default ports.""" + + __tablename__ = "application_templates" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False, unique=True) + description = Column(Text, nullable=True) + default_port = Column(Integer, nullable=False) + protocol = Column(Enum(Protocol), default=Protocol.TCP, nullable=False) + icon = Column(String(100), nullable=True) # Icon name for client UI + + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + def __repr__(self): + return f"" + + +# Default application templates to be seeded +DEFAULT_APPLICATION_TEMPLATES = [ + {"name": "CoDeSys", "description": "CoDeSys Runtime/Gateway", "default_port": 11740, "protocol": "tcp"}, + {"name": "CoDeSys Gateway", "description": "CoDeSys Gateway Service", "default_port": 1217, "protocol": "tcp"}, + {"name": "SSH", "description": "Secure Shell", "default_port": 22, "protocol": "tcp"}, + {"name": "HTTP", "description": "Web Interface", "default_port": 80, "protocol": "tcp"}, + {"name": "HTTPS", "description": "Secure Web Interface", "default_port": 443, "protocol": "tcp"}, + {"name": "VNC", "description": "Virtual Network Computing", "default_port": 5900, "protocol": "tcp"}, + {"name": "RDP", "description": "Remote Desktop Protocol", "default_port": 3389, "protocol": "tcp"}, + {"name": "Modbus TCP", "description": "Modbus over TCP/IP", "default_port": 502, "protocol": "tcp"}, + {"name": "OPC UA", "description": "OPC Unified Architecture", "default_port": 4840, "protocol": "tcp"}, + {"name": "MQTT", "description": "Message Queue Telemetry Transport", "default_port": 1883, "protocol": "tcp"}, + {"name": "S7 Communication", "description": "Siemens S7 Protocol", "default_port": 102, "protocol": "tcp"}, +] diff --git a/server/app/models/gateway.py b/server/app/models/gateway.py new file mode 100644 index 0000000..1c90236 --- /dev/null +++ b/server/app/models/gateway.py @@ -0,0 +1,91 @@ +"""Gateway model for mGuard routers.""" + +from datetime import datetime +from enum import Enum as PyEnum +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, Text +from sqlalchemy.orm import relationship +from ..database import Base + + +class RouterType(str, PyEnum): + """Supported mGuard router types.""" + FL_MGUARD_2000 = "FL_MGUARD_2000" + FL_MGUARD_4000 = "FL_MGUARD_4000" + FL_MGUARD_RS4000 = "FL_MGUARD_RS4000" + FL_MGUARD_1000 = "FL_MGUARD_1000" + + +class ProvisioningMethod(str, PyEnum): + """Method used to provision the gateway.""" + REST_API = "rest_api" # For firmware 10.5.x+ + SSH = "ssh" # For older firmware + ATV_FILE = "atv_file" # Offline via config file + + +class Gateway(Base): + """Gateway model representing an mGuard router.""" + + __tablename__ = "gateways" + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False) + + # Basic info + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + location = Column(String(255), nullable=True) # Physical location + + # Router details + serial_number = Column(String(100), nullable=True, unique=True) + router_type = Column(Enum(RouterType), nullable=False) + firmware_version = Column(String(50), nullable=True) + provisioning_method = Column(Enum(ProvisioningMethod), default=ProvisioningMethod.ATV_FILE) + + # VPN configuration + vpn_ip = Column(String(45), nullable=True, unique=True) # IPv4/IPv6 + vpn_cert_cn = Column(String(255), nullable=True, unique=True) # Certificate Common Name + vpn_subnet = Column(String(50), nullable=True) # Network behind gateway, e.g., "10.0.0.0/24" + + # Status + is_online = Column(Boolean, default=False, nullable=False) + is_provisioned = Column(Boolean, default=False, nullable=False) + last_seen = Column(DateTime, nullable=True) + last_config_update = Column(DateTime, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + tenant = relationship("Tenant", back_populates="gateways") + endpoints = relationship("Endpoint", back_populates="gateway", cascade="all, delete-orphan") + user_access = relationship("UserGatewayAccess", back_populates="gateway", cascade="all, delete-orphan") + connection_logs = relationship("ConnectionLog", back_populates="gateway") + vpn_profiles = relationship("VPNProfile", back_populates="gateway", cascade="all, delete-orphan", order_by="VPNProfile.priority") + + def __repr__(self): + return f"" + + @property + def supports_rest_api(self) -> bool: + """Check if gateway supports REST API (firmware 10.x+).""" + if not self.firmware_version: + return False + try: + major_version = int(self.firmware_version.split('.')[0]) + return major_version >= 10 + except (ValueError, IndexError): + return False + + @property + def primary_profile(self): + """Get the primary VPN profile (highest priority).""" + active_profiles = [p for p in self.vpn_profiles if p.is_active] + if active_profiles: + return min(active_profiles, key=lambda p: p.priority) + return None + + @property + def has_vpn_profiles(self) -> bool: + """Check if gateway has any VPN profiles.""" + return len(self.vpn_profiles) > 0 diff --git a/server/app/models/tenant.py b/server/app/models/tenant.py new file mode 100644 index 0000000..b440f67 --- /dev/null +++ b/server/app/models/tenant.py @@ -0,0 +1,28 @@ +"""Tenant model for multi-tenant support.""" + +from datetime import datetime +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text +from sqlalchemy.orm import relationship +from ..database import Base + + +class Tenant(Base): + """Tenant/Customer model for multi-tenant separation.""" + + __tablename__ = "tenants" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False, unique=True) + description = Column(Text, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + users = relationship("User", back_populates="tenant", cascade="all, delete-orphan") + gateways = relationship("Gateway", back_populates="tenant", cascade="all, delete-orphan") + certificate_authorities = relationship("CertificateAuthority", back_populates="tenant", cascade="all, delete-orphan") + vpn_servers = relationship("VPNServer", back_populates="tenant", cascade="all, delete-orphan") + + def __repr__(self): + return f"" diff --git a/server/app/models/user.py b/server/app/models/user.py new file mode 100644 index 0000000..18562cf --- /dev/null +++ b/server/app/models/user.py @@ -0,0 +1,62 @@ +"""User model with role-based access control.""" + +from datetime import datetime +from enum import Enum as PyEnum +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum +from sqlalchemy.orm import relationship +from ..database import Base + + +class UserRole(str, PyEnum): + """User roles for access control.""" + SUPER_ADMIN = "super_admin" # Can manage all tenants + ADMIN = "admin" # Can manage own tenant + TECHNICIAN = "technician" # Can connect to assigned gateways + VIEWER = "viewer" # Read-only access + + +class User(Base): + """User model with multi-tenant support.""" + + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True) # NULL for super_admin + username = Column(String(255), nullable=False, unique=True, index=True) + password_hash = Column(String(255), nullable=False) + email = Column(String(255), nullable=False, unique=True) + full_name = Column(String(255), nullable=True) + role = Column(Enum(UserRole), default=UserRole.TECHNICIAN, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + last_login = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + tenant = relationship("Tenant", back_populates="users") + gateway_access = relationship( + "UserGatewayAccess", + back_populates="user", + cascade="all, delete-orphan", + primaryjoin="User.id == UserGatewayAccess.user_id" + ) + endpoint_access = relationship( + "UserEndpointAccess", + back_populates="user", + cascade="all, delete-orphan", + primaryjoin="User.id == UserEndpointAccess.user_id" + ) + connection_logs = relationship("ConnectionLog", back_populates="user") + + def __repr__(self): + return f"" + + @property + def is_super_admin(self) -> bool: + """Check if user is super admin.""" + return self.role == UserRole.SUPER_ADMIN + + @property + def is_admin(self) -> bool: + """Check if user has admin privileges.""" + return self.role in (UserRole.SUPER_ADMIN, UserRole.ADMIN) diff --git a/server/app/models/vpn_connection_log.py b/server/app/models/vpn_connection_log.py new file mode 100644 index 0000000..765e4b3 --- /dev/null +++ b/server/app/models/vpn_connection_log.py @@ -0,0 +1,50 @@ +"""VPN Connection Log model for tracking profile connection history.""" + +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, BigInteger +from sqlalchemy.orm import relationship +from ..database import Base + + +class VPNConnectionLog(Base): + """Log of VPN profile connections.""" + + __tablename__ = "vpn_connection_logs" + + id = Column(Integer, primary_key=True, index=True) + vpn_profile_id = Column(Integer, ForeignKey("vpn_profiles.id"), nullable=False) + vpn_server_id = Column(Integer, ForeignKey("vpn_servers.id"), nullable=False) + gateway_id = Column(Integer, ForeignKey("gateways.id"), nullable=False) + + # Connection info + common_name = Column(String(255), nullable=False) + real_address = Column(String(255), nullable=True) # IP:Port + vpn_ip = Column(String(15), nullable=True) # Assigned VPN IP + + # Timestamps + connected_at = Column(DateTime, default=datetime.utcnow, nullable=False) + disconnected_at = Column(DateTime, nullable=True) + + # Traffic stats (updated on disconnect) + bytes_received = Column(BigInteger, default=0) + bytes_sent = Column(BigInteger, default=0) + + # Relationships + vpn_profile = relationship("VPNProfile") + vpn_server = relationship("VPNServer") + gateway = relationship("Gateway") + + def __repr__(self): + return f"" + + @property + def duration_seconds(self) -> int | None: + """Connection duration in seconds.""" + if self.disconnected_at: + return int((self.disconnected_at - self.connected_at).total_seconds()) + return None + + @property + def is_active(self) -> bool: + """Check if connection is still active.""" + return self.disconnected_at is None diff --git a/server/app/models/vpn_profile.py b/server/app/models/vpn_profile.py new file mode 100644 index 0000000..9a626b4 --- /dev/null +++ b/server/app/models/vpn_profile.py @@ -0,0 +1,90 @@ +"""VPN Profile model for gateway VPN configurations.""" + +from datetime import datetime +from enum import Enum as PyEnum +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, Text +from sqlalchemy.orm import relationship +from ..database import Base + + +class VPNProfileStatus(str, PyEnum): + """VPN Profile status.""" + PENDING = "pending" # Certificate being generated + ACTIVE = "active" # Ready to use + PROVISIONED = "provisioned" # Downloaded/deployed to gateway + EXPIRED = "expired" # Certificate expired + REVOKED = "revoked" # Certificate revoked + + +class VPNProfile(Base): + """VPN Profile for a gateway - links gateway to VPN server.""" + + __tablename__ = "vpn_profiles" + + id = Column(Integer, primary_key=True, index=True) + gateway_id = Column(Integer, ForeignKey("gateways.id"), nullable=False) + vpn_server_id = Column(Integer, ForeignKey("vpn_servers.id"), nullable=False) + ca_id = Column(Integer, ForeignKey("certificate_authorities.id"), nullable=False) + + # Profile info + name = Column(String(255), nullable=False) # e.g., "Produktion", "Fallback" + description = Column(Text, nullable=True) + + # Certificate data + cert_cn = Column(String(255), nullable=False) # Common Name + client_cert = Column(Text, nullable=True) # Client certificate PEM + client_key = Column(Text, nullable=True) # Client private key PEM + + # Priority for failover (1 = highest priority) + priority = Column(Integer, default=1) + + # Status + status = Column(Enum(VPNProfileStatus), default=VPNProfileStatus.PENDING) + is_active = Column(Boolean, default=True) + + # Validity + valid_from = Column(DateTime, nullable=True) + valid_until = Column(DateTime, nullable=True) + + # Provisioning tracking + provisioned_at = Column(DateTime, nullable=True) + last_connection = Column(DateTime, nullable=True) + + # VPN IP assigned to this profile (if static) + vpn_ip = Column(String(15), nullable=True) + + # Audit + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + gateway = relationship("Gateway", back_populates="vpn_profiles") + vpn_server = relationship("VPNServer", back_populates="vpn_profiles") + certificate_authority = relationship("CertificateAuthority", back_populates="vpn_profiles") + + def __repr__(self): + return f"" + + @property + def is_ready(self) -> bool: + """Check if profile is ready for provisioning.""" + return ( + self.status in (VPNProfileStatus.ACTIVE, VPNProfileStatus.PROVISIONED) and + self.client_cert is not None and + self.client_key is not None + ) + + @property + def is_expired(self) -> bool: + """Check if certificate is expired.""" + if self.valid_until: + return datetime.utcnow() > self.valid_until + return False + + @property + def days_until_expiry(self) -> int | None: + """Days until certificate expires.""" + if self.valid_until: + delta = self.valid_until - datetime.utcnow() + return max(0, delta.days) + return None diff --git a/server/app/models/vpn_server.py b/server/app/models/vpn_server.py new file mode 100644 index 0000000..46439df --- /dev/null +++ b/server/app/models/vpn_server.py @@ -0,0 +1,130 @@ +"""VPN Server model for managing multiple OpenVPN instances.""" + +from datetime import datetime +from enum import Enum as PyEnum +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, Text +from sqlalchemy.orm import relationship +from ..database import Base + + +class VPNProtocol(str, PyEnum): + """VPN protocol type.""" + UDP = "udp" + TCP = "tcp" + + +class VPNCipher(str, PyEnum): + """VPN cipher options.""" + AES_256_GCM = "AES-256-GCM" + AES_128_GCM = "AES-128-GCM" + AES_256_CBC = "AES-256-CBC" + CHACHA20_POLY1305 = "CHACHA20-POLY1305" + + +class VPNAuth(str, PyEnum): + """VPN auth digest options.""" + SHA256 = "SHA256" + SHA384 = "SHA384" + SHA512 = "SHA512" + + +class VPNCompression(str, PyEnum): + """VPN compression options.""" + NONE = "none" + LZ4 = "lz4" + LZ4_V2 = "lz4-v2" + LZO = "lzo" + + +class VPNServerStatus(str, PyEnum): + """VPN Server status.""" + PENDING = "pending" # Server created but not started + STARTING = "starting" # Container starting + RUNNING = "running" # Server running + STOPPED = "stopped" # Server stopped + ERROR = "error" # Error state + + +class VPNServer(Base): + """VPN Server instance configuration.""" + + __tablename__ = "vpn_servers" + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True) # NULL for global + ca_id = Column(Integer, ForeignKey("certificate_authorities.id"), nullable=False) + + # Basic info + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + + # Network configuration + hostname = Column(String(255), nullable=False) # External hostname/IP + port = Column(Integer, default=1194, nullable=False) + protocol = Column(Enum(VPNProtocol), default=VPNProtocol.UDP, nullable=False) + + # VPN network + vpn_network = Column(String(18), default="10.8.0.0", nullable=False) # CIDR or IP + vpn_netmask = Column(String(15), default="255.255.255.0", nullable=False) + + # Server certificate (PEM encoded) + server_cert = Column(Text, nullable=True) + server_key = Column(Text, nullable=True) + ta_key = Column(Text, nullable=True) # TLS-Auth key + + # Security settings + cipher = Column(Enum(VPNCipher), default=VPNCipher.AES_256_GCM) + auth = Column(Enum(VPNAuth), default=VPNAuth.SHA256) + tls_version_min = Column(String(10), default="1.2") + tls_auth_enabled = Column(Boolean, default=True) + + # Performance settings + compression = Column(Enum(VPNCompression), default=VPNCompression.NONE) + max_clients = Column(Integer, default=100) + keepalive_interval = Column(Integer, default=10) # seconds + keepalive_timeout = Column(Integer, default=60) # seconds + + # Docker settings + docker_container_name = Column(String(255), nullable=True) + management_port = Column(Integer, default=7505) + + # Status + status = Column(Enum(VPNServerStatus), default=VPNServerStatus.PENDING) + is_active = Column(Boolean, default=True) + is_primary = Column(Boolean, default=False) # Primary server for tenant + auto_start = Column(Boolean, default=True) # Start on system boot + + # Statistics (updated periodically) + connected_clients = Column(Integer, default=0) + last_status_check = Column(DateTime, nullable=True) + + # Audit + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + tenant = relationship("Tenant", back_populates="vpn_servers") + certificate_authority = relationship("CertificateAuthority", back_populates="vpn_servers") + vpn_profiles = relationship("VPNProfile", back_populates="vpn_server") + + def __repr__(self): + return f"" + + @property + def is_ready(self) -> bool: + """Check if server is ready to accept connections.""" + return ( + self.server_cert is not None and + self.server_key is not None and + self.certificate_authority is not None and + self.certificate_authority.is_ready + ) + + @property + def connection_string(self) -> str: + """Get connection string for display.""" + return f"{self.hostname}:{self.port}/{self.protocol.value.upper()}" + + def get_docker_port_mapping(self) -> dict: + """Get Docker port mapping for this server.""" + return {f"{self.port}/{self.protocol.value}": self.port} diff --git a/server/app/schemas/__init__.py b/server/app/schemas/__init__.py new file mode 100644 index 0000000..a1afc27 --- /dev/null +++ b/server/app/schemas/__init__.py @@ -0,0 +1,25 @@ +"""Pydantic schemas for API request/response validation.""" + +from .user import ( + UserCreate, UserUpdate, UserResponse, UserLogin, + Token, TokenPayload +) +from .tenant import TenantCreate, TenantUpdate, TenantResponse +from .gateway import GatewayCreate, GatewayUpdate, GatewayResponse, GatewayStatus +from .endpoint import ( + EndpointCreate, EndpointUpdate, EndpointResponse, + ApplicationTemplateResponse +) + +__all__ = [ + # User + "UserCreate", "UserUpdate", "UserResponse", "UserLogin", + "Token", "TokenPayload", + # Tenant + "TenantCreate", "TenantUpdate", "TenantResponse", + # Gateway + "GatewayCreate", "GatewayUpdate", "GatewayResponse", "GatewayStatus", + # Endpoint + "EndpointCreate", "EndpointUpdate", "EndpointResponse", + "ApplicationTemplateResponse", +] diff --git a/server/app/schemas/endpoint.py b/server/app/schemas/endpoint.py new file mode 100644 index 0000000..10535e2 --- /dev/null +++ b/server/app/schemas/endpoint.py @@ -0,0 +1,76 @@ +"""Endpoint-related Pydantic schemas.""" + +from datetime import datetime +from pydantic import BaseModel, Field, field_validator +import ipaddress +from ..models.endpoint import Protocol + + +class EndpointBase(BaseModel): + """Base endpoint schema.""" + name: str = Field(..., min_length=2, max_length=255) + description: str | None = None + internal_ip: str = Field(..., description="IP address in customer network") + port: int = Field(..., ge=1, le=65535) + protocol: Protocol = Protocol.TCP + application_name: str | None = None + + @field_validator('internal_ip') + @classmethod + def validate_ip(cls, v: str) -> str: + try: + ipaddress.ip_address(v) + return v + except ValueError as e: + raise ValueError(f"Invalid IP address: {e}") + + +class EndpointCreate(EndpointBase): + """Schema for creating an endpoint.""" + application_template_id: int | None = None + + +class EndpointUpdate(BaseModel): + """Schema for updating an endpoint.""" + name: str | None = Field(None, min_length=2, max_length=255) + description: str | None = None + internal_ip: str | None = None + port: int | None = Field(None, ge=1, le=65535) + protocol: Protocol | None = None + application_name: str | None = None + + @field_validator('internal_ip') + @classmethod + def validate_ip(cls, v: str | None) -> str | None: + if v is None: + return v + try: + ipaddress.ip_address(v) + return v + except ValueError as e: + raise ValueError(f"Invalid IP address: {e}") + + +class EndpointResponse(EndpointBase): + """Schema for endpoint response.""" + id: int + gateway_id: int + application_template_id: int | None + created_at: datetime + updated_at: datetime | None + + class Config: + from_attributes = True + + +class ApplicationTemplateResponse(BaseModel): + """Schema for application template response.""" + id: int + name: str + description: str | None + default_port: int + protocol: Protocol + icon: str | None + + class Config: + from_attributes = True diff --git a/server/app/schemas/gateway.py b/server/app/schemas/gateway.py new file mode 100644 index 0000000..f5f43d5 --- /dev/null +++ b/server/app/schemas/gateway.py @@ -0,0 +1,82 @@ +"""Gateway-related Pydantic schemas.""" + +from datetime import datetime +from pydantic import BaseModel, Field, field_validator +import ipaddress +from ..models.gateway import RouterType, ProvisioningMethod + + +class GatewayBase(BaseModel): + """Base gateway schema.""" + name: str = Field(..., min_length=2, max_length=255) + description: str | None = None + location: str | None = None + router_type: RouterType + firmware_version: str | None = None + vpn_subnet: str | None = Field(None, description="Network behind gateway, e.g., 10.0.0.0/24") + + @field_validator('vpn_subnet') + @classmethod + def validate_subnet(cls, v: str | None) -> str | None: + if v is None: + return v + try: + ipaddress.ip_network(v, strict=False) + return v + except ValueError as e: + raise ValueError(f"Invalid subnet format: {e}") + + +class GatewayCreate(GatewayBase): + """Schema for creating a gateway.""" + serial_number: str | None = None + provisioning_method: ProvisioningMethod = ProvisioningMethod.ATV_FILE + + +class GatewayUpdate(BaseModel): + """Schema for updating a gateway.""" + name: str | None = Field(None, min_length=2, max_length=255) + description: str | None = None + location: str | None = None + serial_number: str | None = None + firmware_version: str | None = None + vpn_subnet: str | None = None + provisioning_method: ProvisioningMethod | None = None + + @field_validator('vpn_subnet') + @classmethod + def validate_subnet(cls, v: str | None) -> str | None: + if v is None: + return v + try: + ipaddress.ip_network(v, strict=False) + return v + except ValueError as e: + raise ValueError(f"Invalid subnet format: {e}") + + +class GatewayResponse(GatewayBase): + """Schema for gateway response.""" + id: int + tenant_id: int + serial_number: str | None + provisioning_method: ProvisioningMethod + vpn_ip: str | None + vpn_cert_cn: str | None + is_online: bool + is_provisioned: bool + last_seen: datetime | None + created_at: datetime + updated_at: datetime | None + + class Config: + from_attributes = True + + +class GatewayStatus(BaseModel): + """Schema for gateway online status.""" + id: int + name: str + is_online: bool + last_seen: datetime | None + vpn_ip: str | None diff --git a/server/app/schemas/tenant.py b/server/app/schemas/tenant.py new file mode 100644 index 0000000..eb80ba1 --- /dev/null +++ b/server/app/schemas/tenant.py @@ -0,0 +1,33 @@ +"""Tenant-related Pydantic schemas.""" + +from datetime import datetime +from pydantic import BaseModel, Field + + +class TenantBase(BaseModel): + """Base tenant schema.""" + name: str = Field(..., min_length=2, max_length=255) + description: str | None = None + + +class TenantCreate(TenantBase): + """Schema for creating a tenant.""" + pass + + +class TenantUpdate(BaseModel): + """Schema for updating a tenant.""" + name: str | None = Field(None, min_length=2, max_length=255) + description: str | None = None + is_active: bool | None = None + + +class TenantResponse(TenantBase): + """Schema for tenant response.""" + id: int + is_active: bool + created_at: datetime + updated_at: datetime | None + + class Config: + from_attributes = True diff --git a/server/app/schemas/user.py b/server/app/schemas/user.py new file mode 100644 index 0000000..efde9da --- /dev/null +++ b/server/app/schemas/user.py @@ -0,0 +1,63 @@ +"""User-related Pydantic schemas.""" + +from datetime import datetime +from pydantic import BaseModel, EmailStr, Field +from ..models.user import UserRole + + +class UserBase(BaseModel): + """Base user schema with common fields.""" + username: str = Field(..., min_length=3, max_length=255) + email: EmailStr + full_name: str | None = None + role: UserRole = UserRole.TECHNICIAN + + +class UserCreate(UserBase): + """Schema for creating a new user.""" + password: str = Field(..., min_length=8) + tenant_id: int | None = None + + +class UserUpdate(BaseModel): + """Schema for updating a user.""" + email: EmailStr | None = None + full_name: str | None = None + role: UserRole | None = None + is_active: bool | None = None + password: str | None = Field(None, min_length=8) + + +class UserResponse(UserBase): + """Schema for user response.""" + id: int + tenant_id: int | None + is_active: bool + last_login: datetime | None + created_at: datetime + updated_at: datetime | None + + class Config: + from_attributes = True + + +class UserLogin(BaseModel): + """Schema for login request.""" + username: str + password: str + + +class Token(BaseModel): + """Schema for JWT token response.""" + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class TokenPayload(BaseModel): + """Schema for decoded JWT payload.""" + sub: int # user_id + username: str + role: str + tenant_id: int | None + exp: datetime diff --git a/server/app/services/__init__.py b/server/app/services/__init__.py new file mode 100644 index 0000000..7821cb7 --- /dev/null +++ b/server/app/services/__init__.py @@ -0,0 +1,17 @@ +"""Business logic services.""" + +from .auth_service import AuthService +from .vpn_service import VPNService +from .firewall_service import FirewallService +from .certificate_service import CertificateService +from .vpn_server_service import VPNServerService +from .vpn_profile_service import VPNProfileService + +__all__ = [ + "AuthService", + "VPNService", + "FirewallService", + "CertificateService", + "VPNServerService", + "VPNProfileService", +] diff --git a/server/app/services/auth_service.py b/server/app/services/auth_service.py new file mode 100644 index 0000000..0aaf729 --- /dev/null +++ b/server/app/services/auth_service.py @@ -0,0 +1,86 @@ +"""Authentication service.""" + +from datetime import datetime +from sqlalchemy.orm import Session +from ..models.user import User +from ..schemas.user import UserCreate, Token +from ..utils.security import ( + verify_password, get_password_hash, + create_access_token, create_refresh_token, decode_token +) + + +class AuthService: + """Service for authentication operations.""" + + def __init__(self, db: Session): + self.db = db + + def authenticate_user(self, username: str, password: str) -> User | None: + """Authenticate user with username and password.""" + user = self.db.query(User).filter(User.username == username).first() + if not user: + return None + if not verify_password(password, user.password_hash): + return None + if not user.is_active: + return None + + # Update last login + user.last_login = datetime.utcnow() + self.db.commit() + + return user + + def create_tokens(self, user: User) -> Token: + """Create access and refresh tokens for user.""" + access_token = create_access_token( + user_id=user.id, + username=user.username, + role=user.role.value, + tenant_id=user.tenant_id + ) + refresh_token = create_refresh_token(user_id=user.id) + + return Token( + access_token=access_token, + refresh_token=refresh_token + ) + + def refresh_tokens(self, refresh_token: str) -> Token | None: + """Refresh access token using refresh token.""" + payload = decode_token(refresh_token) + if not payload: + return None + if payload.get("type") != "refresh": + return None + + user_id = payload.get("sub") + user = self.db.query(User).filter(User.id == user_id).first() + if not user or not user.is_active: + return None + + return self.create_tokens(user) + + def create_user(self, user_data: UserCreate) -> User: + """Create a new user.""" + user = User( + username=user_data.username, + email=user_data.email, + password_hash=get_password_hash(user_data.password), + full_name=user_data.full_name, + role=user_data.role, + tenant_id=user_data.tenant_id + ) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def get_user_by_id(self, user_id: int) -> User | None: + """Get user by ID.""" + return self.db.query(User).filter(User.id == user_id).first() + + def get_user_by_username(self, username: str) -> User | None: + """Get user by username.""" + return self.db.query(User).filter(User.username == username).first() diff --git a/server/app/services/certificate_service.py b/server/app/services/certificate_service.py new file mode 100644 index 0000000..3156eda --- /dev/null +++ b/server/app/services/certificate_service.py @@ -0,0 +1,588 @@ +"""Certificate management service for PKI operations.""" + +import os +import subprocess +import threading +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional +from cryptography import x509 +from cryptography.x509.oid import NameOID, ExtensionOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.backends import default_backend +from sqlalchemy.orm import Session + +from ..models.certificate_authority import CertificateAuthority, CAStatus +from ..models.vpn_profile import VPNProfile, VPNProfileStatus +from ..config import get_settings + +settings = get_settings() + + +class CertificateService: + """Service for managing certificates and CAs.""" + + def __init__(self, db: Session): + self.db = db + + def create_ca( + self, + name: str, + key_size: int = 4096, + validity_days: int = 3650, + organization: str = "mGuard VPN", + country: str = "DE", + state: str = "NRW", + city: str = "Dortmund", + tenant_id: Optional[int] = None, + created_by_id: Optional[int] = None, + is_default: bool = False + ) -> CertificateAuthority: + """Create a new Certificate Authority.""" + + # Generate CA private key + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=key_size, + backend=default_backend() + ) + + # Build CA certificate + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, country), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, state), + x509.NameAttribute(NameOID.LOCALITY_NAME, city), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization), + x509.NameAttribute(NameOID.COMMON_NAME, f"{name} CA"), + ]) + + valid_from = datetime.utcnow() + valid_until = valid_from + timedelta(days=validity_days) + + ca_cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(valid_from) + .not_valid_after(valid_until) + .add_extension( + x509.BasicConstraints(ca=True, path_length=0), + critical=True, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_cert_sign=True, + crl_sign=True, + key_encipherment=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()), + critical=False, + ) + .sign(private_key, hashes.SHA256(), default_backend()) + ) + + # Serialize to PEM + ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode('utf-8') + ca_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ).decode('utf-8') + + # If this is default, unset other defaults for this tenant + if is_default: + self.db.query(CertificateAuthority).filter( + CertificateAuthority.tenant_id == tenant_id, + CertificateAuthority.is_default == True + ).update({"is_default": False}) + + # Create CA record + ca = CertificateAuthority( + name=name, + tenant_id=tenant_id, + ca_cert=ca_cert_pem, + ca_key=ca_key_pem, + key_size=key_size, + valid_from=valid_from, + valid_until=valid_until, + is_default=is_default, + status=CAStatus.PENDING, # Will be ACTIVE after DH generation + created_by_id=created_by_id, + dh_generating=True + ) + + self.db.add(ca) + self.db.commit() + self.db.refresh(ca) + + # Start DH parameter generation in background + self._generate_dh_async(ca.id, key_size) + + return ca + + def _generate_dh_async(self, ca_id: int, key_size: int): + """Generate DH parameters in background thread.""" + def generate(): + try: + # Use openssl for DH generation (faster than pure Python) + result = subprocess.run( + ["openssl", "dhparam", "-out", "-", str(key_size)], + capture_output=True, + text=True, + timeout=3600 # 1 hour max + ) + + if result.returncode == 0: + dh_pem = result.stdout + # Update CA in database (new session needed for thread) + from ..database import SessionLocal + db = SessionLocal() + try: + ca = db.query(CertificateAuthority).filter( + CertificateAuthority.id == ca_id + ).first() + if ca: + ca.dh_params = dh_pem + ca.dh_generating = False + ca.status = CAStatus.ACTIVE + db.commit() + finally: + db.close() + except Exception as e: + # Log error and mark CA as failed + from ..database import SessionLocal + db = SessionLocal() + try: + ca = db.query(CertificateAuthority).filter( + CertificateAuthority.id == ca_id + ).first() + if ca: + ca.dh_generating = False + ca.description = f"DH generation failed: {str(e)}" + db.commit() + finally: + db.close() + + thread = threading.Thread(target=generate, daemon=True) + thread.start() + + def import_ca( + self, + name: str, + ca_cert_pem: str, + ca_key_pem: str, + dh_params_pem: Optional[str] = None, + tenant_id: Optional[int] = None, + created_by_id: Optional[int] = None + ) -> CertificateAuthority: + """Import an existing CA from PEM files.""" + + # Parse certificate to extract metadata + ca_cert = x509.load_pem_x509_certificate( + ca_cert_pem.encode('utf-8'), + default_backend() + ) + + # Validate it's a CA certificate + try: + basic_constraints = ca_cert.extensions.get_extension_for_oid( + ExtensionOID.BASIC_CONSTRAINTS + ) + if not basic_constraints.value.ca: + raise ValueError("Certificate is not a CA certificate") + except x509.extensions.ExtensionNotFound: + raise ValueError("Certificate missing BasicConstraints extension") + + # Get key size from private key + private_key = serialization.load_pem_private_key( + ca_key_pem.encode('utf-8'), + password=None, + backend=default_backend() + ) + key_size = private_key.key_size + + ca = CertificateAuthority( + name=name, + tenant_id=tenant_id, + ca_cert=ca_cert_pem, + ca_key=ca_key_pem, + dh_params=dh_params_pem, + key_size=key_size, + valid_from=ca_cert.not_valid_before_utc, + valid_until=ca_cert.not_valid_after_utc, + status=CAStatus.ACTIVE if dh_params_pem else CAStatus.PENDING, + created_by_id=created_by_id, + dh_generating=dh_params_pem is None + ) + + self.db.add(ca) + self.db.commit() + self.db.refresh(ca) + + # Generate DH if not provided + if not dh_params_pem: + self._generate_dh_async(ca.id, key_size) + + return ca + + def generate_server_certificate( + self, + ca: CertificateAuthority, + common_name: str, + validity_days: int = 825 + ) -> dict: + """Generate a server certificate from the CA.""" + + if not ca.is_ready: + raise ValueError("CA is not ready for issuing certificates") + + # Load CA key and cert + ca_key = serialization.load_pem_private_key( + ca.ca_key.encode('utf-8'), + password=None, + backend=default_backend() + ) + ca_cert = x509.load_pem_x509_certificate( + ca.ca_cert.encode('utf-8'), + default_backend() + ) + + # Generate server private key + server_key = rsa.generate_private_key( + public_exponent=65537, + key_size=ca.key_size, + backend=default_backend() + ) + + # Build server certificate + valid_from = datetime.utcnow() + valid_until = valid_from + timedelta(days=validity_days) + + subject = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ]) + + server_cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(ca_cert.subject) + .public_key(server_key.public_key()) + .serial_number(ca.next_serial) + .not_valid_before(valid_from) + .not_valid_after(valid_until) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=True, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + key_cert_sign=False, + crl_sign=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ + x509.oid.ExtendedKeyUsageOID.SERVER_AUTH, + ]), + critical=False, + ) + .add_extension( + x509.SubjectAlternativeName([ + x509.DNSName(common_name), + ]), + critical=False, + ) + .sign(ca_key, hashes.SHA256(), default_backend()) + ) + + # Increment serial + ca.next_serial += 1 + self.db.commit() + + # Serialize to PEM + cert_pem = server_cert.public_bytes(serialization.Encoding.PEM).decode('utf-8') + key_pem = server_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ).decode('utf-8') + + return { + "cert": cert_pem, + "key": key_pem, + "valid_from": valid_from, + "valid_until": valid_until, + "serial": ca.next_serial - 1 + } + + def generate_client_certificate( + self, + ca: CertificateAuthority, + common_name: str, + validity_days: int = 365 + ) -> dict: + """Generate a client certificate from the CA.""" + + if not ca.is_ready: + raise ValueError("CA is not ready for issuing certificates") + + # Load CA key and cert + ca_key = serialization.load_pem_private_key( + ca.ca_key.encode('utf-8'), + password=None, + backend=default_backend() + ) + ca_cert = x509.load_pem_x509_certificate( + ca.ca_cert.encode('utf-8'), + default_backend() + ) + + # Generate client private key + client_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, # Client keys can be smaller + backend=default_backend() + ) + + # Build client certificate + valid_from = datetime.utcnow() + valid_until = valid_from + timedelta(days=validity_days) + + subject = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ]) + + client_cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(ca_cert.subject) + .public_key(client_key.public_key()) + .serial_number(ca.next_serial) + .not_valid_before(valid_from) + .not_valid_after(valid_until) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=True, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + key_cert_sign=False, + crl_sign=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ + x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH, + ]), + critical=False, + ) + .sign(ca_key, hashes.SHA256(), default_backend()) + ) + + # Increment serial + ca.next_serial += 1 + self.db.commit() + + # Serialize to PEM + cert_pem = client_cert.public_bytes(serialization.Encoding.PEM).decode('utf-8') + key_pem = client_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ).decode('utf-8') + + return { + "cert": cert_pem, + "key": key_pem, + "valid_from": valid_from, + "valid_until": valid_until, + "serial": ca.next_serial - 1 + } + + def generate_ta_key(self) -> str: + """Generate TLS-Auth key using OpenVPN.""" + try: + result = subprocess.run( + ["openvpn", "--genkey", "secret", "/dev/stdout"], + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode == 0: + return result.stdout + else: + raise RuntimeError(f"Failed to generate TA key: {result.stderr}") + except FileNotFoundError: + # OpenVPN not installed, generate a simple key + import secrets + key_data = secrets.token_hex(256) + return f"""# +# 2048 bit OpenVPN static key +# +-----BEGIN OpenVPN Static key V1----- +{key_data[:64]} +{key_data[64:128]} +{key_data[128:192]} +{key_data[192:256]} +{key_data[256:320]} +{key_data[320:384]} +{key_data[384:448]} +{key_data[448:512]} +-----END OpenVPN Static key V1----- +""" + + def revoke_certificate( + self, + ca: CertificateAuthority, + cert_pem: str, + reason: str = "unspecified" + ) -> bool: + """Revoke a certificate and update CRL.""" + # Parse certificate to get serial number + cert = x509.load_pem_x509_certificate( + cert_pem.encode('utf-8'), + default_backend() + ) + + # Load CA key + ca_key = serialization.load_pem_private_key( + ca.ca_key.encode('utf-8'), + password=None, + backend=default_backend() + ) + + # Load existing CRL or create new one + revoked_certs = [] + if ca.crl: + try: + existing_crl = x509.load_pem_x509_crl( + ca.crl.encode('utf-8'), + default_backend() + ) + for revoked in existing_crl: + revoked_certs.append(revoked) + except Exception: + pass + + # Add new revoked certificate + revoked_cert = ( + x509.RevokedCertificateBuilder() + .serial_number(cert.serial_number) + .revocation_date(datetime.utcnow()) + .build() + ) + revoked_certs.append(revoked_cert) + + # Load CA cert + ca_cert = x509.load_pem_x509_certificate( + ca.ca_cert.encode('utf-8'), + default_backend() + ) + + # Build new CRL + crl_builder = ( + x509.CertificateRevocationListBuilder() + .issuer_name(ca_cert.subject) + .last_update(datetime.utcnow()) + .next_update(datetime.utcnow() + timedelta(days=180)) + ) + + for revoked in revoked_certs: + crl_builder = crl_builder.add_revoked_certificate(revoked) + + crl = crl_builder.sign(ca_key, hashes.SHA256(), default_backend()) + + # Update CA + ca.crl = crl.public_bytes(serialization.Encoding.PEM).decode('utf-8') + ca.crl_updated_at = datetime.utcnow() + self.db.commit() + + return True + + def get_crl(self, ca: CertificateAuthority) -> str: + """Get the CRL for a CA, generating if needed.""" + if not ca.crl: + # Generate empty CRL + ca_key = serialization.load_pem_private_key( + ca.ca_key.encode('utf-8'), + password=None, + backend=default_backend() + ) + ca_cert = x509.load_pem_x509_certificate( + ca.ca_cert.encode('utf-8'), + default_backend() + ) + + crl = ( + x509.CertificateRevocationListBuilder() + .issuer_name(ca_cert.subject) + .last_update(datetime.utcnow()) + .next_update(datetime.utcnow() + timedelta(days=180)) + .sign(ca_key, hashes.SHA256(), default_backend()) + ) + + ca.crl = crl.public_bytes(serialization.Encoding.PEM).decode('utf-8') + ca.crl_updated_at = datetime.utcnow() + self.db.commit() + + return ca.crl + + def get_expiring_certificates( + self, + ca: CertificateAuthority, + days: int = 30 + ) -> list[VPNProfile]: + """Get profiles with certificates expiring within given days.""" + threshold = datetime.utcnow() + timedelta(days=days) + + return self.db.query(VPNProfile).filter( + VPNProfile.ca_id == ca.id, + VPNProfile.valid_until <= threshold, + VPNProfile.status == VPNProfileStatus.ACTIVE + ).all() + + def get_default_ca(self, tenant_id: Optional[int] = None) -> Optional[CertificateAuthority]: + """Get the default CA for a tenant (or global).""" + ca = self.db.query(CertificateAuthority).filter( + CertificateAuthority.tenant_id == tenant_id, + CertificateAuthority.is_default == True, + CertificateAuthority.status == CAStatus.ACTIVE + ).first() + + if not ca and tenant_id: + # Fall back to global CA + ca = self.db.query(CertificateAuthority).filter( + CertificateAuthority.tenant_id == None, + CertificateAuthority.is_default == True, + CertificateAuthority.status == CAStatus.ACTIVE + ).first() + + return ca diff --git a/server/app/services/firewall_service.py b/server/app/services/firewall_service.py new file mode 100644 index 0000000..f866e0c --- /dev/null +++ b/server/app/services/firewall_service.py @@ -0,0 +1,129 @@ +"""Firewall management service for dynamic iptables rules.""" + +import subprocess +from typing import Literal + + +class FirewallService: + """Service for managing iptables firewall rules.""" + + # Chain name for our VPN rules + VPN_CHAIN = "MGUARD_VPN" + + def __init__(self): + self._ensure_chain_exists() + + def _run_iptables(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess: + """Run iptables command.""" + cmd = ["iptables"] + args + return subprocess.run(cmd, capture_output=True, text=True, check=check) + + def _ensure_chain_exists(self): + """Ensure our custom chain exists.""" + # Check if chain exists + result = self._run_iptables(["-L", self.VPN_CHAIN], check=False) + if result.returncode != 0: + # Create chain + self._run_iptables(["-N", self.VPN_CHAIN], check=False) + # Add jump to our chain from FORWARD + self._run_iptables(["-I", "FORWARD", "-j", self.VPN_CHAIN], check=False) + + def allow_connection( + self, + client_vpn_ip: str, + gateway_vpn_ip: str, + target_ip: str, + target_port: int, + protocol: Literal["tcp", "udp"] = "tcp" + ) -> bool: + """Allow connection from client through gateway to target endpoint.""" + try: + # Rule 1: Allow client to reach target through gateway + self._run_iptables([ + "-A", self.VPN_CHAIN, + "-s", client_vpn_ip, + "-d", target_ip, + "-p", protocol, + "--dport", str(target_port), + "-j", "ACCEPT" + ]) + + # Rule 2: Allow return traffic + self._run_iptables([ + "-A", self.VPN_CHAIN, + "-s", target_ip, + "-d", client_vpn_ip, + "-p", protocol, + "--sport", str(target_port), + "-j", "ACCEPT" + ]) + + # Add NAT/masquerade if needed for routing through gateway + self._run_iptables([ + "-t", "nat", + "-A", "POSTROUTING", + "-s", client_vpn_ip, + "-d", target_ip, + "-j", "MASQUERADE" + ], check=False) + + return True + except subprocess.CalledProcessError: + return False + + def revoke_connection( + self, + client_vpn_ip: str, + gateway_vpn_ip: str, + target_ip: str, + target_port: int, + protocol: Literal["tcp", "udp"] = "tcp" + ) -> bool: + """Remove firewall rules for a connection.""" + try: + # Remove forward rules + self._run_iptables([ + "-D", self.VPN_CHAIN, + "-s", client_vpn_ip, + "-d", target_ip, + "-p", protocol, + "--dport", str(target_port), + "-j", "ACCEPT" + ], check=False) + + self._run_iptables([ + "-D", self.VPN_CHAIN, + "-s", target_ip, + "-d", client_vpn_ip, + "-p", protocol, + "--sport", str(target_port), + "-j", "ACCEPT" + ], check=False) + + # Remove NAT rule + self._run_iptables([ + "-t", "nat", + "-D", "POSTROUTING", + "-s", client_vpn_ip, + "-d", target_ip, + "-j", "MASQUERADE" + ], check=False) + + return True + except subprocess.CalledProcessError: + return False + + def list_rules(self) -> list[str]: + """List all rules in our VPN chain.""" + result = self._run_iptables(["-L", self.VPN_CHAIN, "-n", "-v"], check=False) + if result.returncode == 0: + return result.stdout.strip().split('\n') + return [] + + def flush_rules(self) -> bool: + """Remove all rules from our VPN chain.""" + try: + self._run_iptables(["-F", self.VPN_CHAIN]) + return True + except subprocess.CalledProcessError: + return False diff --git a/server/app/services/vpn_profile_service.py b/server/app/services/vpn_profile_service.py new file mode 100644 index 0000000..24bb038 --- /dev/null +++ b/server/app/services/vpn_profile_service.py @@ -0,0 +1,282 @@ +"""VPN Profile management service.""" + +from datetime import datetime +from typing import Optional +from sqlalchemy.orm import Session + +from ..models.vpn_profile import VPNProfile, VPNProfileStatus +from ..models.vpn_server import VPNServer +from ..models.gateway import Gateway +from ..models.certificate_authority import CertificateAuthority +from .certificate_service import CertificateService +from ..config import get_settings + +settings = get_settings() + + +class VPNProfileService: + """Service for managing VPN profiles for gateways.""" + + def __init__(self, db: Session): + self.db = db + self.cert_service = CertificateService(db) + + def create_profile( + self, + gateway_id: int, + vpn_server_id: int, + name: str, + priority: int = 1, + description: Optional[str] = None + ) -> VPNProfile: + """Create a new VPN profile for a gateway.""" + + # Get gateway + gateway = self.db.query(Gateway).filter( + Gateway.id == gateway_id + ).first() + + if not gateway: + raise ValueError(f"Gateway with id {gateway_id} not found") + + # Get VPN server + server = self.db.query(VPNServer).filter( + VPNServer.id == vpn_server_id + ).first() + + if not server: + raise ValueError(f"VPN Server with id {vpn_server_id} not found") + + if not server.certificate_authority.is_ready: + raise ValueError("CA is not ready (DH parameters may still be generating)") + + # Generate unique common name + base_cn = f"gw-{gateway.name.lower().replace(' ', '-')}" + profile_count = self.db.query(VPNProfile).filter( + VPNProfile.gateway_id == gateway_id + ).count() + + cert_cn = f"{base_cn}-{profile_count + 1}" + + # Create profile + profile = VPNProfile( + gateway_id=gateway_id, + vpn_server_id=vpn_server_id, + ca_id=server.ca_id, + name=name, + description=description, + cert_cn=cert_cn, + priority=priority, + status=VPNProfileStatus.PENDING + ) + + self.db.add(profile) + self.db.commit() + self.db.refresh(profile) + + # Generate client certificate + self._generate_client_cert(profile) + + return profile + + def _generate_client_cert(self, profile: VPNProfile): + """Generate client certificate for profile.""" + ca = profile.certificate_authority + + cert_data = self.cert_service.generate_client_certificate( + ca=ca, + common_name=profile.cert_cn + ) + + profile.client_cert = cert_data["cert"] + profile.client_key = cert_data["key"] + profile.valid_from = cert_data["valid_from"] + profile.valid_until = cert_data["valid_until"] + profile.status = VPNProfileStatus.ACTIVE + + self.db.commit() + + def generate_client_config(self, profile: VPNProfile) -> str: + """Generate OpenVPN client configuration (.ovpn) for a profile.""" + + if not profile.is_ready: + raise ValueError("Profile is not ready for provisioning") + + server = profile.vpn_server + ca = profile.certificate_authority + + config_lines = [ + "# OpenVPN Client Configuration", + f"# Profile: {profile.name}", + f"# Gateway: {profile.gateway.name}", + f"# Server: {server.name}", + f"# Generated: {datetime.utcnow().isoformat()}", + "", + "client", + "dev tun", + f"proto {server.protocol.value}", + f"remote {server.hostname} {server.port}", + "", + "resolv-retry infinite", + "nobind", + "persist-key", + "persist-tun", + "", + "remote-cert-tls server", + f"cipher {server.cipher.value}", + f"auth {server.auth.value}", + "", + "verb 3", + "", + ] + + # Add CA certificate + config_lines.extend([ + "", + ca.ca_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 provision_profile(self, profile: VPNProfile) -> str: + """Mark profile as provisioned and return config.""" + config = self.generate_client_config(profile) + + profile.status = VPNProfileStatus.PROVISIONED + profile.provisioned_at = datetime.utcnow() + profile.gateway.is_provisioned = True + + self.db.commit() + + return config + + def set_priority(self, profile: VPNProfile, new_priority: int): + """Set profile priority and reorder others if needed.""" + gateway_id = profile.gateway_id + + # Get all profiles for this gateway ordered by priority + profiles = self.db.query(VPNProfile).filter( + VPNProfile.gateway_id == gateway_id, + VPNProfile.id != profile.id + ).order_by(VPNProfile.priority).all() + + # Update priorities + current_priority = 1 + for p in profiles: + if current_priority == new_priority: + current_priority += 1 + p.priority = current_priority + current_priority += 1 + + profile.priority = new_priority + self.db.commit() + + def get_profiles_for_gateway(self, gateway_id: int) -> list[VPNProfile]: + """Get all VPN profiles for a gateway, ordered by priority.""" + return self.db.query(VPNProfile).filter( + VPNProfile.gateway_id == gateway_id + ).order_by(VPNProfile.priority).all() + + def get_active_profiles_for_gateway(self, gateway_id: int) -> list[VPNProfile]: + """Get active VPN profiles for a gateway.""" + return self.db.query(VPNProfile).filter( + VPNProfile.gateway_id == gateway_id, + VPNProfile.is_active == True, + VPNProfile.status.in_([VPNProfileStatus.ACTIVE, VPNProfileStatus.PROVISIONED]) + ).order_by(VPNProfile.priority).all() + + def revoke_profile(self, profile: VPNProfile, reason: str = "unspecified"): + """Revoke a VPN profile's certificate.""" + if profile.client_cert: + self.cert_service.revoke_certificate( + ca=profile.certificate_authority, + cert_pem=profile.client_cert, + reason=reason + ) + + profile.status = VPNProfileStatus.REVOKED + profile.is_active = False + self.db.commit() + + def renew_profile(self, profile: VPNProfile): + """Renew the certificate for a profile.""" + # Revoke old certificate + if profile.client_cert: + self.cert_service.revoke_certificate( + ca=profile.certificate_authority, + cert_pem=profile.client_cert, + reason="superseded" + ) + + # Generate new certificate + self._generate_client_cert(profile) + + # Reset provisioned status + profile.status = VPNProfileStatus.ACTIVE + profile.provisioned_at = None + self.db.commit() + + def get_profile_by_id(self, profile_id: int) -> Optional[VPNProfile]: + """Get a VPN profile by ID.""" + return self.db.query(VPNProfile).filter( + VPNProfile.id == profile_id + ).first() + + def delete_profile(self, profile: VPNProfile): + """Delete a VPN profile.""" + # Revoke certificate first + if profile.client_cert and profile.status != VPNProfileStatus.REVOKED: + self.cert_service.revoke_certificate( + ca=profile.certificate_authority, + cert_pem=profile.client_cert, + reason="cessationOfOperation" + ) + + self.db.delete(profile) + self.db.commit() + + def generate_all_configs_for_gateway(self, gateway_id: int) -> list[tuple[str, str]]: + """Generate configs for all active profiles of a gateway. + + Returns list of tuples: (filename, config_content) + """ + profiles = self.get_active_profiles_for_gateway(gateway_id) + configs = [] + + for profile in profiles: + try: + config = self.generate_client_config(profile) + filename = f"{profile.gateway.name}-{profile.name}.ovpn" + filename = filename.lower().replace(' ', '-') + configs.append((filename, config)) + except Exception: + continue + + return configs diff --git a/server/app/services/vpn_server_service.py b/server/app/services/vpn_server_service.py new file mode 100644 index 0000000..8bde003 --- /dev/null +++ b/server/app/services/vpn_server_service.py @@ -0,0 +1,344 @@ +"""VPN Server management service.""" + +import socket +from datetime import datetime +from typing import Optional +from sqlalchemy.orm import Session + +from ..models.vpn_server import VPNServer, VPNServerStatus, VPNProtocol, VPNCipher, VPNAuth, VPNCompression +from ..models.certificate_authority import CertificateAuthority +from .certificate_service import CertificateService +from ..config import get_settings + +settings = get_settings() + + +class VPNServerService: + """Service for managing VPN server instances.""" + + def __init__(self, db: Session): + self.db = db + self.cert_service = CertificateService(db) + + def create_server( + self, + name: str, + hostname: str, + ca_id: int, + port: int = 1194, + protocol: VPNProtocol = VPNProtocol.UDP, + vpn_network: str = "10.8.0.0", + vpn_netmask: str = "255.255.255.0", + cipher: VPNCipher = VPNCipher.AES_256_GCM, + auth: VPNAuth = VPNAuth.SHA256, + tls_version_min: str = "1.2", + compression: VPNCompression = VPNCompression.NONE, + max_clients: int = 100, + keepalive_interval: int = 10, + keepalive_timeout: int = 60, + management_port: int = 7505, + is_primary: bool = False, + tenant_id: Optional[int] = None, + description: Optional[str] = None + ) -> VPNServer: + """Create a new VPN server instance.""" + + # Get CA + ca = self.db.query(CertificateAuthority).filter( + CertificateAuthority.id == ca_id + ).first() + + if not ca: + raise ValueError(f"CA with id {ca_id} not found") + + if not ca.is_ready: + raise ValueError("CA is not ready (DH parameters may still be generating)") + + # Generate container name + existing_count = self.db.query(VPNServer).count() + container_name = f"mguard-openvpn-{existing_count + 1}" + + # If setting as primary, unset other primaries + if is_primary: + self.db.query(VPNServer).filter( + VPNServer.tenant_id == tenant_id, + VPNServer.is_primary == True + ).update({"is_primary": False}) + + # Create server record + server = VPNServer( + name=name, + description=description, + hostname=hostname, + port=port, + protocol=protocol, + vpn_network=vpn_network, + vpn_netmask=vpn_netmask, + cipher=cipher, + auth=auth, + tls_version_min=tls_version_min, + compression=compression, + max_clients=max_clients, + keepalive_interval=keepalive_interval, + keepalive_timeout=keepalive_timeout, + management_port=management_port, + docker_container_name=container_name, + ca_id=ca_id, + tenant_id=tenant_id, + is_primary=is_primary, + status=VPNServerStatus.PENDING + ) + + self.db.add(server) + self.db.commit() + self.db.refresh(server) + + # Generate server certificate + self._generate_server_cert(server) + + return server + + def _generate_server_cert(self, server: VPNServer): + """Generate server certificate and TA key.""" + ca = server.certificate_authority + + # Generate server certificate + cert_data = self.cert_service.generate_server_certificate( + ca=ca, + common_name=f"{server.name}-server" + ) + + server.server_cert = cert_data["cert"] + server.server_key = cert_data["key"] + + # Generate TLS-Auth key + server.ta_key = self.cert_service.generate_ta_key() + + self.db.commit() + + def generate_server_config(self, server: VPNServer) -> str: + """Generate OpenVPN server configuration file.""" + if not server.certificate_authority: + raise ValueError("Server has no CA assigned") + + config_lines = [ + "# OpenVPN Server Configuration", + f"# Server: {server.name}", + f"# Generated: {datetime.utcnow().isoformat()}", + "", + "# Basic settings", + f"port {server.port}", + f"proto {server.protocol.value}", + "dev tun", + "", + "# Certificates", + "ca /etc/openvpn/ca.crt", + "cert /etc/openvpn/server.crt", + "key /etc/openvpn/server.key", + "dh /etc/openvpn/dh.pem", + "crl-verify /etc/openvpn/crl.pem", + "", + "# TLS Auth", + ] + + if server.tls_auth_enabled and server.ta_key: + config_lines.append("tls-auth /etc/openvpn/ta.key 0") + + config_lines.extend([ + f"tls-version-min {server.tls_version_min}", + "", + "# Network", + f"server {server.vpn_network} {server.vpn_netmask}", + "topology subnet", + "", + "# Routing", + 'push "redirect-gateway def1 bypass-dhcp"', + 'push "dhcp-option DNS 8.8.8.8"', + 'push "dhcp-option DNS 8.8.4.4"', + "", + "# Security", + f"cipher {server.cipher.value}", + f"auth {server.auth.value}", + "", + "# Performance", + f"keepalive {server.keepalive_interval} {server.keepalive_timeout}", + f"max-clients {server.max_clients}", + ]) + + # Compression handling + # Note: OpenVPN 2.5+ deprecates compression due to VORACLE attack + if server.compression != VPNCompression.NONE: + config_lines.append(f"compress {server.compression.value}") + config_lines.append("allow-compression yes") + # If compression is NONE, don't add any directive - OpenVPN defaults to no compression + + config_lines.extend([ + "", + "# Persistence", + "persist-key", + "persist-tun", + "", + "# Logging", + f"status /var/log/openvpn/status-{server.id}.log", + f"log-append /var/log/openvpn/server-{server.id}.log", + "verb 3", + "", + "# Management interface", + f"management 0.0.0.0 {server.management_port}", + "", + "# User/Group (for Linux)", + "user nobody", + "group nogroup", + "", + "# Client config directory", + "client-config-dir /etc/openvpn/ccd", + "", + "# Scripts", + "script-security 2", + "client-connect /etc/openvpn/scripts/client-connect.sh", + "client-disconnect /etc/openvpn/scripts/client-disconnect.sh", + ]) + + return "\n".join(config_lines) + + def get_connected_clients(self, server: VPNServer) -> list[dict]: + """Get list of currently connected VPN clients.""" + try: + response = self._send_management_command(server, "status") + clients = [] + + if "ERROR" in response: + return clients + + # Parse status output + in_client_list = False + for line in response.split('\n'): + if line.startswith("ROUTING TABLE"): + in_client_list = False + elif line.startswith("Common Name"): + in_client_list = True + continue + elif in_client_list and ',' in line: + parts = line.split(',') + if len(parts) >= 5: + clients.append({ + "common_name": parts[0], + "real_address": parts[1], + "bytes_received": int(parts[2]) if parts[2].isdigit() else 0, + "bytes_sent": int(parts[3]) if parts[3].isdigit() else 0, + "connected_since": parts[4] + }) + + # Update connected count + server.connected_clients = len(clients) + server.last_status_check = datetime.utcnow() + self.db.commit() + + return clients + except Exception as e: + return [] + + def disconnect_client(self, server: VPNServer, common_name: str) -> bool: + """Disconnect a specific VPN client.""" + response = self._send_management_command(server, f"kill {common_name}") + return "SUCCESS" in response + + def get_server_status(self, server: VPNServer) -> dict: + """Get detailed server status.""" + try: + # Try to connect to management interface + response = self._send_management_command(server, "state") + + if "ERROR" in response: + server.status = VPNServerStatus.ERROR + self.db.commit() + return { + "status": "error", + "message": response + } + + # Parse state + for line in response.split('\n'): + if 'CONNECTED' in line: + server.status = VPNServerStatus.RUNNING + break + else: + server.status = VPNServerStatus.STOPPED + + server.last_status_check = datetime.utcnow() + self.db.commit() + + clients = self.get_connected_clients(server) + + return { + "status": server.status.value, + "connected_clients": len(clients), + "clients": clients, + "last_check": server.last_status_check.isoformat() if server.last_status_check else None + } + except Exception as e: + return { + "status": "unknown", + "message": str(e) + } + + def _send_management_command(self, server: VPNServer, command: str) -> str: + """Send command to OpenVPN management interface.""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + + # Connect to management interface + # OpenVPN runs in host network mode, so we connect via host.docker.internal + # This resolves to the Docker host from within containers + host = "host.docker.internal" + sock.connect((host, server.management_port)) + + # Read welcome message + sock.recv(1024) + + # Send command + sock.send(f"{command}\n".encode()) + + # Read response + response = b"" + while True: + data = sock.recv(4096) + if not data: + break + response += data + if b"END" in data or b"SUCCESS" in data or b"ERROR" in data: + break + + sock.close() + return response.decode() + except Exception as e: + return f"ERROR: {str(e)}" + + def update_all_server_status(self): + """Update status for all active servers.""" + servers = self.db.query(VPNServer).filter( + VPNServer.is_active == True + ).all() + + for server in servers: + self.get_server_status(server) + + def get_server_by_id(self, server_id: int) -> Optional[VPNServer]: + """Get a VPN server by ID.""" + return self.db.query(VPNServer).filter( + VPNServer.id == server_id + ).first() + + def get_servers_for_tenant(self, tenant_id: Optional[int] = None) -> list[VPNServer]: + """Get all VPN servers for a tenant.""" + query = self.db.query(VPNServer) + + if tenant_id: + query = query.filter( + (VPNServer.tenant_id == tenant_id) | (VPNServer.tenant_id == None) + ) + else: + query = query.filter(VPNServer.tenant_id == None) + + return query.order_by(VPNServer.name).all() diff --git a/server/app/services/vpn_service.py b/server/app/services/vpn_service.py new file mode 100644 index 0000000..ed709d6 --- /dev/null +++ b/server/app/services/vpn_service.py @@ -0,0 +1,95 @@ +"""VPN management service for OpenVPN operations. + +This service provides basic OpenVPN management interface communication. +For PKI/certificate management, use CertificateService. +For VPN server management, use VPNServerService. +""" + +import socket +from ..config import get_settings + +settings = get_settings() + + +class VPNService: + """Service for OpenVPN management interface operations.""" + + def __init__(self, host: str = None, port: int = None): + self.management_host = host or settings.openvpn_management_host + self.management_port = port or settings.openvpn_management_port + + def _send_management_command(self, command: str) -> str: + """Send command to OpenVPN management interface.""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((self.management_host, self.management_port)) + + # Read welcome message + sock.recv(1024) + + # Send command + sock.send(f"{command}\n".encode()) + + # Read response + response = b"" + while True: + data = sock.recv(4096) + if not data: + break + response += data + if b"END" in data or b"SUCCESS" in data or b"ERROR" in data: + break + + sock.close() + return response.decode() + except Exception as e: + return f"ERROR: {str(e)}" + + def get_connected_clients(self) -> list[dict]: + """Get list of currently connected VPN clients.""" + response = self._send_management_command("status") + clients = [] + + if "ERROR" in response: + return clients + + # Parse status output + in_client_list = False + for line in response.split('\n'): + if line.startswith("ROUTING TABLE"): + in_client_list = False + elif line.startswith("Common Name"): + in_client_list = True + continue + elif in_client_list and ',' in line: + parts = line.split(',') + if len(parts) >= 5: + clients.append({ + "common_name": parts[0], + "real_address": parts[1], + "bytes_received": int(parts[2]) if parts[2].isdigit() else 0, + "bytes_sent": int(parts[3]) if parts[3].isdigit() else 0, + "connected_since": parts[4] + }) + + return clients + + def disconnect_client(self, common_name: str) -> bool: + """Disconnect a specific VPN client.""" + response = self._send_management_command(f"kill {common_name}") + return "SUCCESS" in response + + def get_server_status(self) -> dict: + """Get OpenVPN server status.""" + response = self._send_management_command("state") + + if "ERROR" in response: + return {"status": "error", "message": response} + + # Parse state + for line in response.split('\n'): + if 'CONNECTED' in line: + return {"status": "running", "message": "Server is running"} + + return {"status": "unknown", "message": response} diff --git a/server/app/services/vpn_sync_service.py b/server/app/services/vpn_sync_service.py new file mode 100644 index 0000000..5bc9064 --- /dev/null +++ b/server/app/services/vpn_sync_service.py @@ -0,0 +1,182 @@ +"""VPN Sync Service for synchronizing VPN connection state with database.""" + +from datetime import datetime +from sqlalchemy.orm import Session +from ..models.vpn_server import VPNServer +from ..models.vpn_profile import VPNProfile +from ..models.vpn_connection_log import VPNConnectionLog +from ..models.gateway import Gateway +from .vpn_server_service import VPNServerService + + +class VPNSyncService: + """Service to sync VPN connections with gateway status and logs.""" + + def __init__(self, db: Session): + self.db = db + self.vpn_service = VPNServerService(db) + + def sync_all_connections(self) -> dict: + """ + Sync all VPN connections across all servers. + Returns summary of changes. + """ + result = { + "servers_checked": 0, + "clients_found": 0, + "gateways_online": 0, + "gateways_offline": 0, + "new_connections": 0, + "closed_connections": 0, + "errors": [] + } + + # Get all active VPN servers + servers = self.db.query(VPNServer).filter(VPNServer.is_active == True).all() + + # Collect all currently connected CNs + connected_cns = set() + + for server in servers: + result["servers_checked"] += 1 + try: + clients = self.vpn_service.get_connected_clients(server) + result["clients_found"] += len(clients) + + for client in clients: + cn = client.get("common_name") + if cn: + connected_cns.add(cn) + self._handle_connected_client(server, client, result) + + except Exception as e: + result["errors"].append(f"Server {server.name}: {str(e)}") + + # Mark disconnected profiles/gateways + self._handle_disconnected_profiles(connected_cns, result) + + self.db.commit() + return result + + def _handle_connected_client(self, server: VPNServer, client: dict, result: dict): + """Handle a connected VPN client - update profile, gateway, and log.""" + cn = client.get("common_name") + real_address = client.get("real_address") + bytes_rx = client.get("bytes_received", 0) + bytes_tx = client.get("bytes_sent", 0) + connected_since = client.get("connected_since") + + # Find profile by CN + profile = self.db.query(VPNProfile).filter( + VPNProfile.cert_cn == cn, + VPNProfile.vpn_server_id == server.id + ).first() + + if not profile: + # Try finding by CN across all servers (in case of migration) + profile = self.db.query(VPNProfile).filter( + VPNProfile.cert_cn == cn + ).first() + + if not profile: + return # Unknown client, skip + + gateway = profile.gateway + + # Update gateway status + if not gateway.is_online: + gateway.is_online = True + gateway.last_seen = datetime.utcnow() + result["gateways_online"] += 1 + + # Update profile last connection + profile.last_connection = datetime.utcnow() + + # Check for existing active connection log + active_log = self.db.query(VPNConnectionLog).filter( + VPNConnectionLog.vpn_profile_id == profile.id, + VPNConnectionLog.disconnected_at.is_(None) + ).first() + + if not active_log: + # Create new connection log + log = VPNConnectionLog( + vpn_profile_id=profile.id, + vpn_server_id=server.id, + gateway_id=gateway.id, + common_name=cn, + real_address=real_address, + connected_at=datetime.utcnow(), + bytes_received=bytes_rx, + bytes_sent=bytes_tx + ) + self.db.add(log) + result["new_connections"] += 1 + else: + # Update traffic stats + active_log.bytes_received = bytes_rx + active_log.bytes_sent = bytes_tx + active_log.real_address = real_address + + def _handle_disconnected_profiles(self, connected_cns: set, result: dict): + """Mark profiles as disconnected if their CN is no longer connected.""" + # Find all active connection logs + active_logs = self.db.query(VPNConnectionLog).filter( + VPNConnectionLog.disconnected_at.is_(None) + ).all() + + for log in active_logs: + if log.common_name not in connected_cns: + # Mark as disconnected + log.disconnected_at = datetime.utcnow() + result["closed_connections"] += 1 + + # Update gateway status + gateway = log.gateway + + # Check if gateway has any other active connections + other_active = self.db.query(VPNConnectionLog).filter( + VPNConnectionLog.gateway_id == gateway.id, + VPNConnectionLog.id != log.id, + VPNConnectionLog.disconnected_at.is_(None) + ).first() + + if not other_active: + gateway.is_online = False + result["gateways_offline"] += 1 + + def get_profile_connection_logs( + self, + profile_id: int, + limit: int = 50 + ) -> list[VPNConnectionLog]: + """Get connection logs for a specific profile.""" + return self.db.query(VPNConnectionLog).filter( + VPNConnectionLog.vpn_profile_id == profile_id + ).order_by(VPNConnectionLog.connected_at.desc()).limit(limit).all() + + def get_gateway_connection_logs( + self, + gateway_id: int, + limit: int = 50 + ) -> list[VPNConnectionLog]: + """Get connection logs for a gateway (all profiles).""" + return self.db.query(VPNConnectionLog).filter( + VPNConnectionLog.gateway_id == gateway_id + ).order_by(VPNConnectionLog.connected_at.desc()).limit(limit).all() + + def get_server_connection_logs( + self, + server_id: int, + limit: int = 50 + ) -> list[VPNConnectionLog]: + """Get connection logs for a VPN server.""" + return self.db.query(VPNConnectionLog).filter( + VPNConnectionLog.vpn_server_id == server_id + ).order_by(VPNConnectionLog.connected_at.desc()).limit(limit).all() + + def get_active_connections(self) -> list[VPNConnectionLog]: + """Get all currently active VPN connections.""" + return self.db.query(VPNConnectionLog).filter( + VPNConnectionLog.disconnected_at.is_(None) + ).order_by(VPNConnectionLog.connected_at.desc()).all() diff --git a/server/app/static/css/custom.css b/server/app/static/css/custom.css new file mode 100644 index 0000000..89dc106 --- /dev/null +++ b/server/app/static/css/custom.css @@ -0,0 +1,196 @@ +/* mGuard VPN Manager - Custom Styles */ + +/* Body */ +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +/* Gateway Status Indicators */ +.status-online { + color: #198754; +} + +.status-offline { + color: #6c757d; +} + +.status-indicator { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 8px; +} + +.status-indicator.online { + background-color: #198754; + box-shadow: 0 0 8px #198754; +} + +.status-indicator.offline { + background-color: #6c757d; +} + +/* Gateway Cards */ +.gateway-card { + transition: transform 0.2s, box-shadow 0.2s; +} + +.gateway-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +.gateway-card.online { + border-left: 4px solid #198754; +} + +.gateway-card.offline { + border-left: 4px solid #6c757d; +} + +/* Dashboard Stats */ +.stat-card { + border-radius: 10px; +} + +.stat-card .stat-icon { + font-size: 2.5rem; + opacity: 0.2; +} + +.stat-card .stat-value { + font-size: 2rem; + font-weight: bold; +} + +/* Tables */ +.table-hover tbody tr { + cursor: pointer; +} + +/* Login Page */ +.login-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%); +} + +.login-box { + background: white; + border-radius: 10px; + padding: 2rem; + box-shadow: 0 10px 40px rgba(0,0,0,0.2); + width: 100%; + max-width: 400px; +} + +.login-logo { + font-size: 3rem; + color: #0d6efd; +} + +/* HTMX Loading Indicator */ +.htmx-indicator { + display: none; +} + +.htmx-request .htmx-indicator { + display: inline-block; +} + +.htmx-request.htmx-indicator { + display: inline-block; +} + +/* Sidebar (optional) */ +.sidebar { + min-width: 250px; + max-width: 250px; + min-height: 100vh; + background: #212529; +} + +.sidebar a { + color: rgba(255,255,255,.8); + text-decoration: none; + padding: 10px 15px; + display: block; +} + +.sidebar a:hover { + background: rgba(255,255,255,.1); + color: white; +} + +.sidebar a.active { + background: #0d6efd; + color: white; +} + +/* Badges */ +.badge-role-super_admin { + background-color: #dc3545; +} + +.badge-role-admin { + background-color: #0d6efd; +} + +.badge-role-technician { + background-color: #198754; +} + +.badge-role-viewer { + background-color: #6c757d; +} + +/* Endpoint Protocol Badges */ +.badge-tcp { + background-color: #0d6efd; +} + +.badge-udp { + background-color: #6610f2; +} + +/* PKI Initialization Banner */ +.pki-init-banner { + background: #ffc107; + color: #212529; + padding: 15px 20px; + text-align: center; + position: sticky; + top: 0; + z-index: 1100; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); + border-bottom: 3px solid #e0a800; +} + +.pki-init-banner .small { + opacity: 0.8; +} + +.pki-init-banner .spinner-border { + color: #212529 !important; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .stat-card .stat-value { + font-size: 1.5rem; + } + + .pki-init-banner { + padding: 10px 15px; + font-size: 0.9rem; + } +} diff --git a/server/app/static/js/app.js b/server/app/static/js/app.js new file mode 100644 index 0000000..50dac03 --- /dev/null +++ b/server/app/static/js/app.js @@ -0,0 +1,82 @@ +// mGuard VPN Manager - Custom JavaScript + +// HTMX Configuration +document.body.addEventListener('htmx:configRequest', function(evt) { + // Add CSRF token to all HTMX requests + // evt.detail.headers['X-CSRFToken'] = document.querySelector('meta[name="csrf-token"]')?.content; +}); + +// Show loading indicator during HTMX requests +document.body.addEventListener('htmx:beforeRequest', function(evt) { + // Optional: Show global loading indicator +}); + +document.body.addEventListener('htmx:afterRequest', function(evt) { + // Optional: Hide global loading indicator +}); + +// Handle HTMX errors +document.body.addEventListener('htmx:responseError', function(evt) { + console.error('HTMX Error:', evt.detail); + // Show error toast or alert +}); + +// Initialize tooltips +document.addEventListener('DOMContentLoaded', function() { + // Bootstrap tooltips + var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + tooltipTriggerList.map(function(tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl); + }); + + // Bootstrap popovers + var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]')); + popoverTriggerList.map(function(popoverTriggerEl) { + return new bootstrap.Popover(popoverTriggerEl); + }); +}); + +// Confirm delete dialogs +function confirmDelete(message, formId) { + if (confirm(message || 'Wirklich löschen?')) { + document.getElementById(formId).submit(); + } + return false; +} + +// Auto-refresh gateway status +function refreshGatewayStatus() { + // Handled by HTMX polling +} + +// Format relative time +function formatRelativeTime(dateString) { + if (!dateString) return 'Nie'; + + const date = new Date(dateString); + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + + if (diff < 60) return 'Gerade eben'; + if (diff < 3600) return Math.floor(diff / 60) + ' Min.'; + if (diff < 86400) return Math.floor(diff / 3600) + ' Std.'; + return Math.floor(diff / 86400) + ' Tage'; +} + +// Update all relative times on page +function updateRelativeTimes() { + document.querySelectorAll('[data-relative-time]').forEach(function(el) { + el.textContent = formatRelativeTime(el.dataset.relativeTime); + }); +} + +// Update times every minute +setInterval(updateRelativeTimes, 60000); + +// Copy to clipboard +function copyToClipboard(text) { + navigator.clipboard.writeText(text).then(function() { + // Show success toast + console.log('Copied to clipboard'); + }); +} diff --git a/server/app/templates/applications/form.html b/server/app/templates/applications/form.html new file mode 100644 index 0000000..d8751ff --- /dev/null +++ b/server/app/templates/applications/form.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %}{% if application %}Anwendung bearbeiten{% else %}Neue Anwendung{% endif %} - mGuard VPN{% endblock %} + +{% block content %} +
+
+
+
+

+ + {% if application %}Anwendung bearbeiten{% else %}Neue Anwendung{% endif %} +

+
+
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ + +
+
+ Bootstrap Icons - nur den Namen ohne "bi-" +
+
+ +
+ + Abbrechen + + +
+
+
+
+
+
+{% endblock %} diff --git a/server/app/templates/applications/list.html b/server/app/templates/applications/list.html new file mode 100644 index 0000000..4a04eed --- /dev/null +++ b/server/app/templates/applications/list.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} + +{% block title %}Anwendungen - mGuard VPN{% endblock %} + +{% block content %} +
+

Anwendungstemplates

+ + Neue Anwendung + +
+ +
+
+ + + + + + + + + + + + {% for app in applications %} + + + + + + + + {% else %} + + + + {% endfor %} + +
NamePortProtokollBeschreibungAktionen
+ {% if app.icon %}{% endif %} + {{ app.name }} + {{ app.default_port }}{{ app.protocol.value|upper }}{{ app.description or '-' }} + + + +
+ +
+
Keine Anwendungen vorhanden
+
+
+ +
+
+
Hinweis
+
+
+

+ Anwendungstemplates definieren Standard-Ports und Protokolle für bekannte Industrieanwendungen. + Beim Anlegen eines Endpunkts wird automatisch der Port ausgefüllt, wenn eine Anwendung ausgewählt wird. +

+
+
+{% endblock %} diff --git a/server/app/templates/auth/login.html b/server/app/templates/auth/login.html new file mode 100644 index 0000000..3f30122 --- /dev/null +++ b/server/app/templates/auth/login.html @@ -0,0 +1,64 @@ + + + + + + Login - mGuard VPN Manager + + + + + + + + diff --git a/server/app/templates/base.html b/server/app/templates/base.html new file mode 100644 index 0000000..a86d30b --- /dev/null +++ b/server/app/templates/base.html @@ -0,0 +1,142 @@ + + + + + + {% block title %}mGuard VPN Manager{% endblock %} + + + + + + + + + + + + {% block head %}{% endblock %} + + + {% if current_user %} + + + {% endif %} + + +
+
+ + {% if flash_messages %} + {% for msg in flash_messages %} + + {% endfor %} + {% endif %} + + {% block content %}{% endblock %} +
+
+ + + {% if current_user %} +
+
+ mGuard VPN Manager v1.0 | + + + + +
+
+ {% endif %} + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/server/app/templates/connections/log.html b/server/app/templates/connections/log.html new file mode 100644 index 0000000..ae3a8d0 --- /dev/null +++ b/server/app/templates/connections/log.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} + +{% block title %}Verbindungen - mGuard VPN Manager{% endblock %} + +{% block content %} +
+

Verbindungen

+
+ + ... + + +
+
+ + +
+
+
VPN-Clients (Gateways)
+
+
+
+
+ Laden... +
+
+
+
+ + +
+
+
Aktive Benutzer-Sessions
+
+
+
+
+ Laden... +
+
+
+
+ + +
+
+
Verbindungshistorie
+
+
+
+
+ Laden... +
+
+
+
+{% endblock %} diff --git a/server/app/templates/dashboard/index.html b/server/app/templates/dashboard/index.html new file mode 100644 index 0000000..0848854 --- /dev/null +++ b/server/app/templates/dashboard/index.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - mGuard VPN Manager{% endblock %} + +{% block content %} +
+

Dashboard

+ +
+ + +
+
+
+
+
+
+
Gateways Online
+
{{ stats.gateways_online }} / {{ stats.gateways_total }}
+
+ +
+
+
+
+
+
+
+
+
+
Aktive Verbindungen
+
{{ stats.active_connections }}
+
+ +
+
+
+
+
+
+
+
+
+
Endpunkte
+
{{ stats.endpoints_total }}
+
+ +
+
+
+
+
+
+
+
+
+
Benutzer
+
{{ stats.users_total }}
+
+ +
+
+
+
+
+ +
+ +
+
+
+
Gateway Status
+ Alle anzeigen +
+
+
+
+ Laden... +
+
+
+
+
+ + +
+
+
+
Letzte Verbindungen
+ Alle +
+
+
+
+ Laden... +
+
+
+
+
+
+ + +
+
+
+
+
Schnellaktionen
+
+ +
+
+
+{% endblock %} diff --git a/server/app/templates/gateways/detail.html b/server/app/templates/gateways/detail.html new file mode 100644 index 0000000..9f226c0 --- /dev/null +++ b/server/app/templates/gateways/detail.html @@ -0,0 +1,262 @@ +{% extends "base.html" %} + +{% block title %}{{ gateway.name }} - mGuard VPN Manager{% endblock %} + +{% block content %} + + +
+
+

+ + {{ gateway.name }} +

+

+ {{ gateway.router_type }} | + {{ gateway.location or 'Kein Standort' }} | + {% if gateway.last_seen %} + Zuletzt gesehen: {{ gateway.last_seen }} + {% else %} + Nie verbunden + {% endif %} +

+
+
+ + Provisioning + + + Bearbeiten + + + +
+
+ +
+ +
+
+
+
Gateway Details
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Status + {% if gateway.is_online %} + Online + {% else %} + Offline + {% endif %} +
Typ{{ gateway.router_type }}
Firmware{{ gateway.firmware_version or '-' }}
Seriennummer{{ gateway.serial_number or '-' }}
VPN IP{{ gateway.vpn_ip or 'Nicht zugewiesen' }}
VPN Subnetz{{ gateway.vpn_subnet or '-' }}
Provisioniert + {% if gateway.is_provisioned %} + Ja + {% else %} + Nein + {% endif %} +
Standort{{ gateway.location or '-' }}
+ + {% if gateway.description %} +
+

{{ gateway.description }}

+ {% endif %} +
+
+
+ + +
+
+
+
Endpunkte
+ +
+
+
+
+ Laden... +
+
+
+
+
+
+ + +
+
+
+
+
VPN-Verbindungslog
+
+
+
+
+ Laden... +
+
+
+
+
+
+ + +
+
+
+
+
Benutzerzugriff
+ +
+
+
+
+ Laden... +
+
+
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/server/app/templates/gateways/form.html b/server/app/templates/gateways/form.html new file mode 100644 index 0000000..5200d4c --- /dev/null +++ b/server/app/templates/gateways/form.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} + +{% block title %}{{ 'Gateway bearbeiten' if gateway else 'Neues Gateway' }} - mGuard VPN Manager{% endblock %} + +{% block content %} + + +
+
+
+
+
+ + {{ 'Gateway bearbeiten' if gateway else 'Neues Gateway' }} +
+
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + + Netzwerk hinter dem Gateway +
+
+ +
+ + +
+ + {% if current_user.is_super_admin and not gateway %} +
+ + +
+ {% endif %} + +
+ +
+ + Abbrechen + + +
+
+
+
+
+
+{% endblock %} diff --git a/server/app/templates/gateways/list.html b/server/app/templates/gateways/list.html new file mode 100644 index 0000000..5021a17 --- /dev/null +++ b/server/app/templates/gateways/list.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} + +{% block title %}Gateways - mGuard VPN Manager{% endblock %} + +{% block content %} +
+

Gateways

+ + Neues Gateway + +
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+
+ Laden... +
+
+
+{% endblock %} diff --git a/server/app/templates/gateways/profile_detail.html b/server/app/templates/gateways/profile_detail.html new file mode 100644 index 0000000..642b2b7 --- /dev/null +++ b/server/app/templates/gateways/profile_detail.html @@ -0,0 +1,231 @@ +{% extends "base.html" %} + +{% block title %}{{ profile.name }} - {{ gateway.name }} - mGuard VPN{% endblock %} + +{% block content %} + + +
+

+ {{ profile.name }} + {% if profile.priority == 1 %} + Primär + {% else %} + Priorität {{ profile.priority }} + {% endif %} +

+ +
+ +
+
+
+
+ Profil-Informationen +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + {% if profile.provisioned_at %} + + + + + {% endif %} +
Name{{ profile.name }}
Beschreibung{{ profile.description or '-' }}
Status + {% if profile.status.value == 'active' %} + Aktiv + {% elif profile.status.value == 'provisioned' %} + Provisioniert + {% elif profile.status.value == 'pending' %} + Ausstehend + {% elif profile.status.value == 'expired' %} + Abgelaufen + {% elif profile.status.value == 'revoked' %} + Widerrufen + {% else %} + {{ profile.status.value }} + {% endif %} +
Priorität{{ profile.priority }}
Aktiv + {% if profile.is_active %} + Ja + {% else %} + Nein + {% endif %} +
Erstellt{{ profile.created_at.strftime('%d.%m.%Y %H:%M') }}
Zuletzt provisioniert{{ profile.provisioned_at.strftime('%d.%m.%Y %H:%M') }}
+
+
+ +
+
+ VPN-Server +
+
+ {% if profile.vpn_server %} + + + + + + + + + + + + + + + + + +
Server + + {{ profile.vpn_server.name }} + +
Hostname{{ profile.vpn_server.hostname }}
Port / Protokoll{{ profile.vpn_server.port }} / {{ profile.vpn_server.protocol.value|upper }}
VPN-Netzwerk{{ profile.vpn_server.vpn_network }}/{{ profile.vpn_server.vpn_netmask }}
+ {% else %} +

Kein VPN-Server zugewiesen

+ {% endif %} +
+
+
+ +
+
+
+ Zertifikat +
+
+ + + + + + + + + + + + + + + + + +
Common Name{{ profile.cert_cn }}
Gültig von{{ profile.valid_from.strftime('%d.%m.%Y') if profile.valid_from else '-' }}
Gültig bis + {% if profile.valid_until %} + {{ profile.valid_until.strftime('%d.%m.%Y') }} + {% if profile.is_expired %} + Abgelaufen + {% elif profile.days_until_expiry <= 30 %} + {{ profile.days_until_expiry }} Tage + {% endif %} + {% else %} + - + {% endif %} +
CA + {% if profile.certificate_authority %} + + {{ profile.certificate_authority.name }} + + {% else %} + - + {% endif %} +
+ + {% if profile.client_cert %} +
+
+ + Zertifikat anzeigen + +
{{ profile.client_cert }}
+
+ {% endif %} +
+
+ +
+
+ Provisioning +
+
+

+ Laden Sie die OpenVPN-Konfigurationsdatei herunter und importieren Sie sie auf dem mGuard-Router. +

+ + {{ profile.name }}.ovpn herunterladen + + {% if profile.provisioned_at %} +
+ Zuletzt heruntergeladen: {{ profile.provisioned_at.strftime('%d.%m.%Y %H:%M') }} +
+ {% endif %} +
+
+ + {% if profile.status.value not in ['revoked', 'expired'] %} +
+
+ Gefahrenzone +
+
+

+ Durch das Widerrufen des Zertifikats wird der Zugang zum VPN-Server gesperrt. + Diese Aktion kann nicht rückgängig gemacht werden. +

+
+ +
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/server/app/templates/gateways/profile_form.html b/server/app/templates/gateways/profile_form.html new file mode 100644 index 0000000..263cadf --- /dev/null +++ b/server/app/templates/gateways/profile_form.html @@ -0,0 +1,151 @@ +{% extends "base.html" %} + +{% block title %} +{% if profile %}Profil bearbeiten{% else %}Neues VPN-Profil{% endif %} - {{ gateway.name }} - mGuard VPN +{% endblock %} + +{% block content %} + + +
+
+
+
+

+ + {% if profile %}Profil bearbeiten{% else %}Neues VPN-Profil{% endif %} +

+
+
+ {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ + +
Ein beschreibender Name für dieses Profil
+
+ +
+ + +
+ +
+ + {% if profile %} + +
+ Der VPN-Server kann nicht geändert werden (Zertifikat ist an den Server gebunden). +
+ {% else %} + +
+ Der VPN-Server, mit dem sich das Gateway verbinden soll. + {% if not vpn_servers %} + + + Keine VPN-Server verfügbar. Erstellen Sie zuerst einen Server. + + {% endif %} +
+ {% endif %} +
+ +
+ + +
+ 1 = Höchste Priorität (Primärer Server). Bei Verbindungsproblemen wird das Profil + mit der nächsthöheren Priorität verwendet. +
+
+ + {% if not profile %} +
+ + +
+ Der Common Name für das Client-Zertifikat. Wenn leer, wird automatisch + "{{ gateway.name|lower|replace(' ', '-') }}-[profilname]" verwendet. +
+
+ +
+ + +
Wie lange das Zertifikat gültig sein soll (Standard: 365 Tage)
+
+ {% endif %} + +
+
+ + +
+
Inaktive Profile werden beim Provisioning nicht berücksichtigt
+
+ +
+ +
+ + Abbrechen + + +
+
+
+
+ + {% if not profile %} +
+
+ Was passiert beim Erstellen? +
+
+
    +
  1. Ein Client-Zertifikat wird aus der CA des VPN-Servers generiert
  2. +
  3. Das Zertifikat wird mit dem Gateway verknüpft
  4. +
  5. Nach dem Erstellen können Sie die OpenVPN-Konfigurationsdatei (.ovpn) herunterladen
  6. +
  7. Die Konfiguration kann dann auf dem mGuard-Router importiert werden
  8. +
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/server/app/templates/gateways/profiles.html b/server/app/templates/gateways/profiles.html new file mode 100644 index 0000000..bc319da --- /dev/null +++ b/server/app/templates/gateways/profiles.html @@ -0,0 +1,155 @@ +{% extends "base.html" %} + +{% block title %}VPN-Profile - {{ gateway.name }} - mGuard VPN{% endblock %} + +{% block content %} + + +
+

+ VPN-Profile + {{ profiles|length }} +

+ +
+ +{% if profiles %} +
+
+
+ + + + + + + + + + + + + + {% for profile in profiles %} + + + + + + + + + + {% endfor %} + +
PrioritätNameVPN-ServerCommon NameStatusGültigkeitAktionen
+ {{ profile.priority }} + + + {{ profile.name }} + + {% if profile.priority == 1 %} + Primär + {% endif %} + + {% if profile.vpn_server %} + + {{ profile.vpn_server.name }} + +
+ + {{ profile.vpn_server.hostname }}:{{ profile.vpn_server.port }}/{{ profile.vpn_server.protocol.value }} + + {% else %} + - + {% endif %} +
+ {{ profile.cert_cn }} + + {% if profile.status.value == 'active' %} + Aktiv + {% elif profile.status.value == 'provisioned' %} + Provisioniert + {% elif profile.status.value == 'pending' %} + Ausstehend + {% elif profile.status.value == 'expired' %} + Abgelaufen + {% elif profile.status.value == 'revoked' %} + Widerrufen + {% else %} + {{ profile.status.value }} + {% endif %} + + {% if profile.valid_until %} + + bis {{ profile.valid_until.strftime('%d.%m.%Y') }} + {% if profile.days_until_expiry is defined %} + {% if profile.days_until_expiry <= 30 %} +
{{ profile.days_until_expiry }} Tage + {% endif %} + {% endif %} +
+ {% else %} + - + {% endif %} +
+ +
+
+
+
+ +
+
+ Hinweis zur Priorität +
+
+

+ Profile mit niedrigerer Prioritätsnummer werden bevorzugt verwendet. + Bei Verbindungsproblemen mit dem primären Server (Priorität 1) versucht der Client + automatisch, sich mit dem nächsten Profil zu verbinden (Failover). +

+
+
+ +{% else %} +
+
+ +

Keine VPN-Profile vorhanden

+

+ Erstellen Sie ein VPN-Profil, um dieses Gateway mit einem VPN-Server zu verbinden. +

+ + Erstes Profil erstellen + +
+
+{% endif %} +{% endblock %} diff --git a/server/app/templates/tenants/form.html b/server/app/templates/tenants/form.html new file mode 100644 index 0000000..9d4738b --- /dev/null +++ b/server/app/templates/tenants/form.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %}{% if tenant %}Mandant bearbeiten{% else %}Neuer Mandant{% endif %} - mGuard VPN{% endblock %} + +{% block content %} +
+
+
+
+

+ + {% if tenant %}Mandant bearbeiten{% else %}Neuer Mandant{% endif %} +

+
+
+
+
+ + +
+ +
+ + +
+ + {% if tenant %} +
+
+ + +
+
+ {% endif %} + +
+ + Abbrechen + + +
+
+
+
+
+
+{% endblock %} diff --git a/server/app/templates/tenants/list.html b/server/app/templates/tenants/list.html new file mode 100644 index 0000000..ff4f6bc --- /dev/null +++ b/server/app/templates/tenants/list.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block title %}Mandanten - mGuard VPN{% endblock %} + +{% block content %} +
+

Mandanten

+ + Neuer Mandant + +
+ +
+
+ + + + + + + + + + + + + + {% for tenant in tenants %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
NameBeschreibungBenutzerGatewaysStatusErstelltAktionen
{{ tenant.name }}{{ tenant.description or '-' }}{{ tenant.users|length }}{{ tenant.gateways|length }} + {% if tenant.is_active %} + Aktiv + {% else %} + Inaktiv + {% endif %} + {{ tenant.created_at.strftime('%d.%m.%Y') }} + + + +
+ +
+
Keine Mandanten vorhanden
+
+
+{% endblock %} diff --git a/server/app/templates/users/access.html b/server/app/templates/users/access.html new file mode 100644 index 0000000..b39d1ee --- /dev/null +++ b/server/app/templates/users/access.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} + +{% block title %}Zugriffe für {{ user.username }} - mGuard VPN Manager{% endblock %} + +{% block content %} + + +
+
+

Zugriffe für {{ user.username }}

+

{{ user.email }} | Rolle: {{ user.role.value }}

+
+ + Zurück + +
+ +
+
+
+
+
Gateway-Zugriffe
+
+
+
+ + + + + + + + + + + {% for gateway in gateways %} + + + + + + + {% else %} + + + + {% endfor %} + +
ZugriffGatewayStandortStatus
+
+ +
+
+ + {{ gateway.location or '-' }} + {% if gateway.is_online %} + Online + {% else %} + Offline + {% endif %} +
+ Keine Gateways vorhanden +
+ + {% if gateways %} +
+
+ + +
+ +
+ {% endif %} +
+
+
+
+ +
+
+
+
Info
+
+
+

Benutzer: {{ user.username }}

+

Rolle: + {{ user.role.value }} +

+ +
+ +
Rollen-Erklärung:
+
    +
  • technician: Kann nur zugewiesene Gateways sehen und verbinden
  • +
  • admin: Kann alle Gateways des Mandanten verwalten
  • +
  • super_admin: Voller Zugriff auf alle Mandanten
  • +
+ +
+ + Admins haben automatisch Zugriff auf alle Gateways ihres Mandanten. + Diese Zuweisungen gelten nur für Techniker. +
+
+
+
+
+ + +{% endblock %} diff --git a/server/app/templates/users/form.html b/server/app/templates/users/form.html new file mode 100644 index 0000000..3da4f68 --- /dev/null +++ b/server/app/templates/users/form.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}{{ 'Benutzer bearbeiten' if user else 'Neuer Benutzer' }} - mGuard VPN Manager{% endblock %} + +{% block content %} + + +
+
+
+
+
+ + {{ 'Benutzer bearbeiten' if user else 'Neuer Benutzer' }} +
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {% if current_user.is_super_admin and not user %} +
+ + +
+ {% endif %} + + {% if user %} +
+ + +
+ {% endif %} + +
+ +
+ + Abbrechen + + +
+
+
+
+
+
+{% endblock %} diff --git a/server/app/templates/users/list.html b/server/app/templates/users/list.html new file mode 100644 index 0000000..661ed61 --- /dev/null +++ b/server/app/templates/users/list.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Benutzer - mGuard VPN Manager{% endblock %} + +{% block content %} +
+

Benutzer

+ + Neuer Benutzer + +
+ +
+
+ + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
BenutzernameE-MailRolleStatusLetzter LoginAktionen
+ + {{ user.username }} + {% if user.full_name %} + {{ user.full_name }} + {% endif %} + {{ user.email }} + {{ user.role.value }} + + {% if user.is_active %} + Aktiv + {% else %} + Inaktiv + {% endif %} + + {% if user.last_login %} + {{ user.last_login.strftime('%d.%m.%Y %H:%M') }} + {% else %} + Nie + {% endif %} + + + + + + Zugriffe + + {% if user.id != current_user.id %} + + {% endif %} +
+ Keine Benutzer vorhanden +
+
+
+{% endblock %} diff --git a/server/app/templates/vpn_servers/clients.html b/server/app/templates/vpn_servers/clients.html new file mode 100644 index 0000000..0063fd9 --- /dev/null +++ b/server/app/templates/vpn_servers/clients.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} + +{% block title %}Verbundene Clients - {{ server.name }} - mGuard VPN{% endblock %} + +{% block content %} + + +
+

+ Verbundene Clients + {{ clients|length }} +

+ + Zurück zum Server + +
+ +
+
+ {% if clients %} +
+ + + + + + + + + + + + + {% for client in clients %} + + + + + + + + + {% endfor %} + +
Common NameEchte AdresseEmpfangenGesendetVerbunden seitAktionen
+ {{ client.common_name }} + {{ client.real_address }}{{ (client.bytes_received / 1024 / 1024)|round(2) }} MB{{ (client.bytes_sent / 1024 / 1024)|round(2) }} MB{{ client.connected_since }} +
+ +
+
+
+ {% else %} +
+ +

Keine Clients verbunden

+
+ {% endif %} +
+
+{% endblock %} diff --git a/server/app/templates/vpn_servers/detail.html b/server/app/templates/vpn_servers/detail.html new file mode 100644 index 0000000..ea94404 --- /dev/null +++ b/server/app/templates/vpn_servers/detail.html @@ -0,0 +1,260 @@ +{% extends "base.html" %} + +{% block title %}{{ server.name }} - VPN-Server - mGuard VPN{% endblock %} + +{% block content %} + + +
+

+ {% if server.status.value == 'running' %} + + {% else %} + + {% endif %} + {{ server.name }} + {% if server.is_primary %} + Primär + {% endif %} + {% if server.protocol.value == 'udp' %} + UDP + {% else %} + TCP + {% endif %} +

+ +
+ +
+ +
+
+
+
Status
+
+
+
+
Status
+
+ {% if server.status.value == 'running' %} + Läuft + {% elif server.status.value == 'stopped' %} + Gestoppt + {% elif server.status.value == 'starting' %} + Startet... + {% elif server.status.value == 'error' %} + Fehler + {% else %} + {{ server.status.value }} + {% endif %} +
+ +
Verbundene Clients
+
{{ status.connected_clients or 0 }}
+ +
Letzte Prüfung
+
+ {{ server.last_status_check.strftime('%d.%m.%Y %H:%M') if server.last_status_check else '-' }} +
+ +
Container
+
{{ server.docker_container_name or '-' }}
+ +
Management-Port
+
{{ server.management_port }}
+
+
+
+
+ + +
+
+
+
Netzwerk
+
+
+
+
Adresse
+
{{ server.hostname }}:{{ server.port }}
+ +
Protokoll
+
{{ server.protocol.value.upper() }}
+ +
VPN-Netzwerk
+
{{ server.vpn_network }}/{{ server.vpn_netmask }}
+ +
Max Clients
+
{{ server.max_clients }}
+ +
Keepalive
+
{{ server.keepalive_interval }}s / {{ server.keepalive_timeout }}s
+
+
+
+
+
+ +
+ +
+
+
+
Sicherheit
+
+
+
+
CA
+
+ + {{ server.certificate_authority.name }} + +
+ +
Cipher
+
{{ server.cipher.value }}
+ +
Auth
+
{{ server.auth.value }}
+ +
TLS Version
+
>= {{ server.tls_version_min }}
+ +
TLS-Auth
+
+ {% if server.tls_auth_enabled %} + Aktiviert + {% else %} + Deaktiviert + {% endif %} +
+ +
Kompression
+
{{ server.compression.value }}
+
+
+
+
+ + +
+
+
+
VPN-Profile
+
+
+ {% if server.vpn_profiles %} +
    + {% for profile in server.vpn_profiles[:10] %} +
  • + + + {{ profile.gateway.name }} - {{ profile.name }} + + + {% if profile.status.value == 'active' %} + Aktiv + {% elif profile.status.value == 'provisioned' %} + Provisioniert + {% else %} + {{ profile.status.value }} + {% endif %} +
  • + {% endfor %} +
+ {% if server.vpn_profiles|length > 10 %} +

... und {{ server.vpn_profiles|length - 10 }} weitere

+ {% endif %} + {% else %} +

Keine Profile verwenden diesen Server.

+ {% endif %} +
+
+
+
+ + +
+
+
Server-Log
+
+ + + Download + +
+
+
+
+
+
+ Laden... +
+ Log wird geladen... +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/server/app/templates/vpn_servers/form.html b/server/app/templates/vpn_servers/form.html new file mode 100644 index 0000000..e82db2d --- /dev/null +++ b/server/app/templates/vpn_servers/form.html @@ -0,0 +1,204 @@ +{% extends "base.html" %} + +{% block title %}{% if server %}VPN-Server bearbeiten{% else %}Neuer VPN-Server{% endif %} - mGuard VPN{% endblock %} + +{% block content %} +
+
+
+
+

+ {% if server %} + VPN-Server bearbeiten + {% else %} + Neuer VPN-Server + {% endif %} +

+
+
+
+ + +
Grundeinstellungen
+ +
+
+ + +
+
+ + {% if server %} + + + {% else %} + + {% endif %} +
+
+ +
+ + +
+ +
+
Netzwerk
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
Sicherheit
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Performance
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Docker
+ +
+
+ + +
+
+
+ + +
+
+
+ + {% if server %} +
+
+
+ + +
+
+
+ {% endif %} + +
+ + Abbrechen +
+
+
+
+
+
+{% endblock %} diff --git a/server/app/templates/vpn_servers/list.html b/server/app/templates/vpn_servers/list.html new file mode 100644 index 0000000..27148ad --- /dev/null +++ b/server/app/templates/vpn_servers/list.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} + +{% block title %}VPN-Server - mGuard VPN{% endblock %} + +{% block content %} +
+

VPN-Server

+ + Neuer VPN-Server + +
+ +
+ {% for server in servers %} +
+
+
+ + {% if server.status.value == 'running' %} + + {% else %} + + {% endif %} + {{ server.name }} + +
+ {% if server.is_primary %} + Primär + {% endif %} + {% if server.protocol.value == 'udp' %} + UDP + {% else %} + TCP + {% endif %} +
+
+
+

{{ server.description or 'Keine Beschreibung' }}

+ + +
+ {{ server.hostname }}:{{ server.port }} +
+ + +
+ {% if server.status.value == 'running' %} + Läuft + {{ server.connected_clients }} Clients + {% elif server.status.value == 'stopped' %} + Gestoppt + {% elif server.status.value == 'starting' %} + Startet... + {% elif server.status.value == 'error' %} + Fehler + {% else %} + {{ server.status.value }} + {% endif %} +
+ + +
    +
  • Netzwerk: {{ server.vpn_network }}/{{ server.vpn_netmask }}
  • +
  • Cipher: {{ server.cipher.value }}
  • +
  • CA: {{ server.certificate_authority.name if server.certificate_authority else '-' }}
  • +
+
+ +
+
+ {% else %} +
+
+ + Keine VPN-Server vorhanden. + Erstellen Sie einen neuen VPN-Server. +
+ Hinweis: Sie benötigen zuerst eine Certificate Authority. +
+
+ {% endfor %} +
+{% endblock %} diff --git a/server/app/utils/__init__.py b/server/app/utils/__init__.py new file mode 100644 index 0000000..3bf902f --- /dev/null +++ b/server/app/utils/__init__.py @@ -0,0 +1,11 @@ +"""Utility modules.""" + +from .security import ( + verify_password, get_password_hash, + create_access_token, create_refresh_token, decode_token +) + +__all__ = [ + "verify_password", "get_password_hash", + "create_access_token", "create_refresh_token", "decode_token" +] diff --git a/server/app/utils/security.py b/server/app/utils/security.py new file mode 100644 index 0000000..f8fecb2 --- /dev/null +++ b/server/app/utils/security.py @@ -0,0 +1,75 @@ +"""Security utilities for password hashing and JWT tokens.""" + +from datetime import datetime, timedelta +import bcrypt +from jose import jwt, JWTError +from ..config import get_settings + +settings = get_settings() + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash.""" + return bcrypt.checkpw( + plain_password.encode('utf-8'), + hashed_password.encode('utf-8') + ) + + +def get_password_hash(password: str) -> str: + """Generate password hash.""" + return bcrypt.hashpw( + password.encode('utf-8'), + bcrypt.gensalt() + ).decode('utf-8') + + +def create_access_token( + user_id: int, + username: str, + role: str, + tenant_id: int | None = None, + expires_delta: timedelta | None = None +) -> str: + """Create a JWT access token.""" + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes) + + to_encode = { + "sub": user_id, + "username": username, + "role": role, + "tenant_id": tenant_id, + "exp": expire, + "type": "access" + } + return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + + +def create_refresh_token( + user_id: int, + expires_delta: timedelta | None = None +) -> str: + """Create a JWT refresh token.""" + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days) + + to_encode = { + "sub": user_id, + "exp": expire, + "type": "refresh" + } + return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + + +def decode_token(token: str) -> dict | None: + """Decode and validate a JWT token.""" + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + return payload + except JWTError: + return None diff --git a/server/app/web/__init__.py b/server/app/web/__init__.py new file mode 100644 index 0000000..ce23243 --- /dev/null +++ b/server/app/web/__init__.py @@ -0,0 +1,23 @@ +"""Web routes for browser-based UI using Jinja2 + HTMX.""" + +from fastapi import APIRouter +from . import auth, dashboard, gateways, users, tenants, applications, connections, htmx +from . import ca, vpn_servers, vpn_profiles + +# Create web router +web_router = APIRouter() + +# Include web route modules +web_router.include_router(auth.router, tags=["Web Auth"]) +web_router.include_router(dashboard.router, tags=["Web Dashboard"]) +web_router.include_router(gateways.router, tags=["Web Gateways"]) +web_router.include_router(users.router, tags=["Web Users"]) +web_router.include_router(tenants.router, tags=["Web Tenants"]) +web_router.include_router(applications.router, tags=["Web Applications"]) +web_router.include_router(connections.router, tags=["Web Connections"]) +web_router.include_router(htmx.router, prefix="/htmx", tags=["HTMX Partials"]) + +# PKI & VPN Management +web_router.include_router(ca.router, tags=["Web CA"]) +web_router.include_router(vpn_servers.router, tags=["Web VPN Servers"]) +web_router.include_router(vpn_profiles.router, tags=["Web VPN Profiles"]) diff --git a/server/app/web/applications.py b/server/app/web/applications.py new file mode 100644 index 0000000..219df2f --- /dev/null +++ b/server/app/web/applications.py @@ -0,0 +1,156 @@ +"""Application template management web routes (Super Admin only).""" + +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.user import User +from ..models.endpoint import ApplicationTemplate, Protocol +from .deps import require_super_admin_web, flash, get_flashed_messages + +router = APIRouter() + + +@router.get("/applications", response_class=HTMLResponse) +async def list_applications( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """List all application templates.""" + applications = db.query(ApplicationTemplate).order_by(ApplicationTemplate.name).all() + + return request.app.state.templates.TemplateResponse( + "applications/list.html", + { + "request": request, + "current_user": current_user, + "applications": applications, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.get("/applications/new", response_class=HTMLResponse) +async def new_application_form( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """New application template form.""" + return request.app.state.templates.TemplateResponse( + "applications/form.html", + { + "request": request, + "current_user": current_user, + "application": None, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/applications/new") +async def create_application( + request: Request, + name: str = Form(...), + default_port: int = Form(...), + protocol: str = Form("tcp"), + description: str = Form(None), + icon: str = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """Create new application template.""" + # Check if name exists + existing = db.query(ApplicationTemplate).filter(ApplicationTemplate.name == name).first() + if existing: + flash(request, "Anwendungsname bereits vergeben", "danger") + return RedirectResponse(url="/applications/new", status_code=303) + + application = ApplicationTemplate( + name=name, + default_port=default_port, + protocol=Protocol(protocol), + description=description or None, + icon=icon or None + ) + + db.add(application) + db.commit() + + flash(request, f"Anwendung '{name}' erstellt", "success") + return RedirectResponse(url="/applications", status_code=303) + + +@router.get("/applications/{app_id}/edit", response_class=HTMLResponse) +async def edit_application_form( + request: Request, + app_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """Edit application template form.""" + application = db.query(ApplicationTemplate).filter(ApplicationTemplate.id == app_id).first() + if not application: + flash(request, "Anwendung nicht gefunden", "danger") + return RedirectResponse(url="/applications", status_code=303) + + return request.app.state.templates.TemplateResponse( + "applications/form.html", + { + "request": request, + "current_user": current_user, + "application": application, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/applications/{app_id}/edit") +async def update_application( + request: Request, + app_id: int, + name: str = Form(...), + default_port: int = Form(...), + protocol: str = Form("tcp"), + description: str = Form(None), + icon: str = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """Update application template.""" + application = db.query(ApplicationTemplate).filter(ApplicationTemplate.id == app_id).first() + if not application: + flash(request, "Anwendung nicht gefunden", "danger") + return RedirectResponse(url="/applications", status_code=303) + + application.name = name + application.default_port = default_port + application.protocol = Protocol(protocol) + application.description = description or None + application.icon = icon or None + + db.commit() + + flash(request, "Anwendung aktualisiert", "success") + return RedirectResponse(url="/applications", status_code=303) + + +@router.post("/applications/{app_id}/delete") +async def delete_application( + request: Request, + app_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """Delete application template.""" + application = db.query(ApplicationTemplate).filter(ApplicationTemplate.id == app_id).first() + if not application: + flash(request, "Anwendung nicht gefunden", "danger") + return RedirectResponse(url="/applications", status_code=303) + + db.delete(application) + db.commit() + + flash(request, f"Anwendung '{application.name}' gelöscht", "warning") + return RedirectResponse(url="/applications", status_code=303) diff --git a/server/app/web/auth.py b/server/app/web/auth.py new file mode 100644 index 0000000..cbbd8b5 --- /dev/null +++ b/server/app/web/auth.py @@ -0,0 +1,58 @@ +"""Web authentication routes (session-based).""" + +from fastapi import APIRouter, Request, Form, Depends, HTTPException +from fastapi.responses import RedirectResponse, HTMLResponse +from sqlalchemy.orm import Session +from ..database import get_db +from ..services.auth_service import AuthService +from ..utils.security import verify_password +from .deps import get_flashed_messages + +router = APIRouter() + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + """Show login page.""" + # If already logged in, redirect to dashboard + if request.session.get("user_id"): + return RedirectResponse(url="/dashboard", status_code=303) + + return request.app.state.templates.TemplateResponse( + "auth/login.html", + {"request": request, "error": None, "flash_messages": get_flashed_messages(request)} + ) + + +@router.post("/login", response_class=HTMLResponse) +async def login_submit( + request: Request, + username: str = Form(...), + password: str = Form(...), + remember: bool = Form(False), + db: Session = Depends(get_db) +): + """Process login form.""" + auth_service = AuthService(db) + user = auth_service.authenticate_user(username, password) + + if not user: + return request.app.state.templates.TemplateResponse( + "auth/login.html", + {"request": request, "error": "Ungültige Anmeldedaten", "flash_messages": []} + ) + + # Store user in session + request.session["user_id"] = user.id + request.session["username"] = user.username + request.session["role"] = user.role.value + request.session["tenant_id"] = user.tenant_id + + return RedirectResponse(url="/dashboard", status_code=303) + + +@router.get("/logout") +async def logout(request: Request): + """Logout user.""" + request.session.clear() + return RedirectResponse(url="/login", status_code=303) diff --git a/server/app/web/ca.py b/server/app/web/ca.py new file mode 100644 index 0000000..04a1225 --- /dev/null +++ b/server/app/web/ca.py @@ -0,0 +1,301 @@ +"""Certificate Authority management web routes.""" + +from fastapi import APIRouter, Request, Depends, Form, UploadFile, File +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from sqlalchemy.orm import Session +from typing import Optional + +from ..database import get_db +from ..models.user import User +from ..models.certificate_authority import CertificateAuthority, CAStatus +from ..services.certificate_service import CertificateService +from .deps import require_admin_web, flash, get_flashed_messages + +router = APIRouter() + + +@router.get("/ca", response_class=HTMLResponse) +async def list_cas( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """List all Certificate Authorities.""" + if current_user.is_super_admin: + cas = db.query(CertificateAuthority).all() + else: + cas = db.query(CertificateAuthority).filter( + (CertificateAuthority.tenant_id == current_user.tenant_id) | + (CertificateAuthority.tenant_id == None) + ).all() + + return request.app.state.templates.TemplateResponse( + "ca/list.html", + { + "request": request, + "current_user": current_user, + "cas": cas, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.get("/ca/new", response_class=HTMLResponse) +async def new_ca_form( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """New CA form.""" + return request.app.state.templates.TemplateResponse( + "ca/form.html", + { + "request": request, + "current_user": current_user, + "ca": None, + "mode": "create", + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/ca/new") +async def create_ca( + request: Request, + name: str = Form(...), + description: str = Form(None), + key_size: int = Form(4096), + validity_days: int = Form(3650), + organization: str = Form("mGuard VPN"), + country: str = Form("DE"), + state: str = Form("NRW"), + city: str = Form("Dortmund"), + is_default: bool = Form(False), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Create new CA.""" + try: + service = CertificateService(db) + + tenant_id = None if current_user.is_super_admin else current_user.tenant_id + + ca = service.create_ca( + name=name, + key_size=key_size, + validity_days=validity_days, + organization=organization, + country=country, + state=state, + city=city, + tenant_id=tenant_id, + created_by_id=current_user.id, + is_default=is_default + ) + + flash(request, f"CA '{name}' wird erstellt. DH-Parameter werden im Hintergrund generiert...", "success") + return RedirectResponse(url=f"/ca/{ca.id}", status_code=303) + + except Exception as e: + flash(request, f"Fehler beim Erstellen der CA: {str(e)}", "danger") + return RedirectResponse(url="/ca/new", status_code=303) + + +@router.get("/ca/import", response_class=HTMLResponse) +async def import_ca_form( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Import CA form.""" + return request.app.state.templates.TemplateResponse( + "ca/form.html", + { + "request": request, + "current_user": current_user, + "ca": None, + "mode": "import", + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/ca/import") +async def import_ca( + request: Request, + name: str = Form(...), + description: str = Form(None), + ca_cert: UploadFile = File(...), + ca_key: UploadFile = File(...), + dh_params: Optional[UploadFile] = File(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Import existing CA from files.""" + try: + service = CertificateService(db) + + ca_cert_pem = (await ca_cert.read()).decode('utf-8') + ca_key_pem = (await ca_key.read()).decode('utf-8') + dh_params_pem = (await dh_params.read()).decode('utf-8') if dh_params else None + + tenant_id = None if current_user.is_super_admin else current_user.tenant_id + + ca = service.import_ca( + name=name, + ca_cert_pem=ca_cert_pem, + ca_key_pem=ca_key_pem, + dh_params_pem=dh_params_pem, + tenant_id=tenant_id, + created_by_id=current_user.id + ) + + if dh_params_pem: + flash(request, f"CA '{name}' erfolgreich importiert", "success") + else: + flash(request, f"CA '{name}' importiert. DH-Parameter werden generiert...", "success") + + return RedirectResponse(url=f"/ca/{ca.id}", status_code=303) + + except Exception as e: + flash(request, f"Fehler beim Importieren: {str(e)}", "danger") + return RedirectResponse(url="/ca/import", status_code=303) + + +@router.get("/ca/{ca_id}", response_class=HTMLResponse) +async def ca_detail( + request: Request, + ca_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """CA detail page.""" + ca = db.query(CertificateAuthority).filter( + CertificateAuthority.id == ca_id + ).first() + + if not ca: + flash(request, "CA nicht gefunden", "danger") + return RedirectResponse(url="/ca", status_code=303) + + # Check access + if not current_user.is_super_admin and ca.tenant_id != current_user.tenant_id and ca.tenant_id is not None: + flash(request, "Zugriff verweigert", "danger") + return RedirectResponse(url="/ca", status_code=303) + + return request.app.state.templates.TemplateResponse( + "ca/detail.html", + { + "request": request, + "current_user": current_user, + "ca": ca, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/ca/{ca_id}/set-default") +async def set_ca_default( + request: Request, + ca_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Set CA as default.""" + ca = db.query(CertificateAuthority).filter( + CertificateAuthority.id == ca_id + ).first() + + if not ca: + flash(request, "CA nicht gefunden", "danger") + return RedirectResponse(url="/ca", status_code=303) + + # Unset other defaults for same tenant + db.query(CertificateAuthority).filter( + CertificateAuthority.tenant_id == ca.tenant_id, + CertificateAuthority.id != ca.id + ).update({"is_default": False}) + + ca.is_default = True + db.commit() + + flash(request, f"'{ca.name}' ist jetzt die Standard-CA", "success") + return RedirectResponse(url=f"/ca/{ca_id}", status_code=303) + + +@router.get("/ca/{ca_id}/download/cert") +async def download_ca_cert( + ca_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Download CA certificate.""" + ca = db.query(CertificateAuthority).filter( + CertificateAuthority.id == ca_id + ).first() + + if not ca or not ca.ca_cert: + return Response(status_code=404) + + return Response( + content=ca.ca_cert, + media_type="application/x-pem-file", + headers={"Content-Disposition": f"attachment; filename={ca.name}-ca.crt"} + ) + + +@router.get("/ca/{ca_id}/download/crl") +async def download_crl( + ca_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Download Certificate Revocation List.""" + ca = db.query(CertificateAuthority).filter( + CertificateAuthority.id == ca_id + ).first() + + if not ca: + return Response(status_code=404) + + service = CertificateService(db) + crl = service.get_crl(ca) + + return Response( + content=crl, + media_type="application/x-pem-file", + headers={"Content-Disposition": f"attachment; filename={ca.name}-crl.pem"} + ) + + +@router.post("/ca/{ca_id}/delete") +async def delete_ca( + request: Request, + ca_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Delete CA.""" + ca = db.query(CertificateAuthority).filter( + CertificateAuthority.id == ca_id + ).first() + + if not ca: + flash(request, "CA nicht gefunden", "danger") + return RedirectResponse(url="/ca", status_code=303) + + # Check if CA has servers or profiles + if ca.vpn_servers: + flash(request, "CA wird noch von VPN-Servern verwendet. Bitte zuerst löschen.", "danger") + return RedirectResponse(url=f"/ca/{ca_id}", status_code=303) + + if ca.vpn_profiles: + flash(request, "CA wird noch von VPN-Profilen verwendet. Bitte zuerst löschen.", "danger") + return RedirectResponse(url=f"/ca/{ca_id}", status_code=303) + + name = ca.name + db.delete(ca) + db.commit() + + flash(request, f"CA '{name}' gelöscht", "warning") + return RedirectResponse(url="/ca", status_code=303) diff --git a/server/app/web/connections.py b/server/app/web/connections.py new file mode 100644 index 0000000..da9fecb --- /dev/null +++ b/server/app/web/connections.py @@ -0,0 +1,27 @@ +"""Connection log web routes.""" + +from fastapi import APIRouter, Request, Depends +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.user import User +from .deps import get_current_user_web, get_flashed_messages + +router = APIRouter() + + +@router.get("/connections", response_class=HTMLResponse) +async def connection_log( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Connection log page.""" + return request.app.state.templates.TemplateResponse( + "connections/log.html", + { + "request": request, + "current_user": current_user, + "flash_messages": get_flashed_messages(request) + } + ) diff --git a/server/app/web/dashboard.py b/server/app/web/dashboard.py new file mode 100644 index 0000000..737289d --- /dev/null +++ b/server/app/web/dashboard.py @@ -0,0 +1,57 @@ +"""Dashboard web routes.""" + +from fastapi import APIRouter, Request, Depends +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session +from sqlalchemy import func +from ..database import get_db +from ..models.user import User +from ..models.gateway import Gateway +from ..models.endpoint import Endpoint +from ..models.access import ConnectionLog +from .deps import get_current_user_web, get_flashed_messages + +router = APIRouter() + + +@router.get("/", response_class=HTMLResponse) +async def root(request: Request): + """Root redirect to dashboard.""" + if request.session.get("user_id"): + return RedirectResponse(url="/dashboard", status_code=303) + return RedirectResponse(url="/login", status_code=303) + + +@router.get("/dashboard", response_class=HTMLResponse) +async def dashboard( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Dashboard with overview.""" + # Get statistics + gateways_total = db.query(Gateway).count() + gateways_online = db.query(Gateway).filter(Gateway.is_online == True).count() + endpoints_total = db.query(Endpoint).count() + users_total = db.query(User).filter(User.is_active == True).count() + active_connections = db.query(ConnectionLog).filter( + ConnectionLog.disconnected_at.is_(None) + ).count() + + stats = { + "gateways_total": gateways_total, + "gateways_online": gateways_online, + "endpoints_total": endpoints_total, + "users_total": users_total, + "active_connections": active_connections + } + + return request.app.state.templates.TemplateResponse( + "dashboard/index.html", + { + "request": request, + "current_user": current_user, + "stats": stats, + "flash_messages": get_flashed_messages(request) + } + ) diff --git a/server/app/web/deps.py b/server/app/web/deps.py new file mode 100644 index 0000000..4d50b97 --- /dev/null +++ b/server/app/web/deps.py @@ -0,0 +1,61 @@ +"""Web route dependencies.""" + +from fastapi import Request, HTTPException, Depends +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.user import User, UserRole + + +async def get_current_user_web(request: Request, db: Session = Depends(get_db)) -> User: + """Get current user from session for web routes.""" + user_id = request.session.get("user_id") + + if not user_id: + raise HTTPException(status_code=303, headers={"Location": "/login"}) + + user = db.query(User).filter(User.id == user_id).first() + + if not user or not user.is_active: + request.session.clear() + raise HTTPException(status_code=303, headers={"Location": "/login"}) + + return user + + +async def require_user_web( + current_user: User = Depends(get_current_user_web) +) -> User: + """Require any authenticated user for web routes.""" + return current_user + + +async def require_admin_web( + current_user: User = Depends(get_current_user_web) +) -> User: + """Require admin role for web routes.""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich") + return current_user + + +async def require_super_admin_web( + current_user: User = Depends(get_current_user_web) +) -> User: + """Require super admin role for web routes.""" + if current_user.role != UserRole.SUPER_ADMIN: + raise HTTPException(status_code=403, detail="Super-Admin-Rechte erforderlich") + return current_user + + +def get_flashed_messages(request: Request) -> list[dict]: + """Get and clear flash messages from session.""" + messages = request.session.pop("flash_messages", []) + return messages + + +def flash(request: Request, message: str, category: str = "info"): + """Add a flash message to session.""" + if "flash_messages" not in request.session: + request.session["flash_messages"] = [] + request.session["flash_messages"].append({"category": category, "message": message}) diff --git a/server/app/web/gateways.py b/server/app/web/gateways.py new file mode 100644 index 0000000..143332c --- /dev/null +++ b/server/app/web/gateways.py @@ -0,0 +1,250 @@ +"""Gateway web routes.""" + +from fastapi import APIRouter, Request, Depends, Form, Response +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.user import User, UserRole +from ..models.gateway import Gateway, RouterType, ProvisioningMethod +from ..models.endpoint import ApplicationTemplate +from ..models.tenant import Tenant +from ..models.access import UserGatewayAccess +from .deps import get_current_user_web, require_admin_web, flash, get_flashed_messages + +router = APIRouter() + + +def get_accessible_gateways(db: Session, user: User): + """Get gateways accessible by user.""" + if user.role == UserRole.SUPER_ADMIN: + return db.query(Gateway).all() + elif user.role == UserRole.ADMIN: + return db.query(Gateway).filter(Gateway.tenant_id == user.tenant_id).all() + else: + return db.query(Gateway).join( + UserGatewayAccess, + UserGatewayAccess.gateway_id == Gateway.id + ).filter(UserGatewayAccess.user_id == user.id).all() + + +@router.get("/gateways", response_class=HTMLResponse) +async def list_gateways( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """List gateways page.""" + return request.app.state.templates.TemplateResponse( + "gateways/list.html", + { + "request": request, + "current_user": current_user, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.get("/gateways/new", response_class=HTMLResponse) +async def new_gateway_form( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """New gateway form.""" + tenants = db.query(Tenant).filter(Tenant.is_active == True).all() + + return request.app.state.templates.TemplateResponse( + "gateways/form.html", + { + "request": request, + "current_user": current_user, + "gateway": None, + "tenants": tenants, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/gateways/new", response_class=HTMLResponse) +async def create_gateway( + request: Request, + name: str = Form(...), + router_type: str = Form(...), + firmware_version: str = Form(None), + serial_number: str = Form(None), + location: str = Form(None), + vpn_subnet: str = Form(None), + description: str = Form(None), + tenant_id: int = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Create new gateway.""" + # Determine tenant + if current_user.role == UserRole.SUPER_ADMIN and tenant_id: + gateway_tenant_id = tenant_id + else: + gateway_tenant_id = current_user.tenant_id + + # Generate VPN cert CN + vpn_cert_cn = f"gateway-{name.lower().replace(' ', '-')}" + + gateway = Gateway( + name=name, + router_type=RouterType(router_type), + firmware_version=firmware_version or None, + serial_number=serial_number or None, + location=location or None, + vpn_subnet=vpn_subnet or None, + description=description or None, + tenant_id=gateway_tenant_id, + vpn_cert_cn=vpn_cert_cn + ) + + db.add(gateway) + db.commit() + db.refresh(gateway) + + flash(request, f"Gateway '{name}' erfolgreich erstellt", "success") + return RedirectResponse(url=f"/gateways/{gateway.id}", status_code=303) + + +@router.get("/gateways/{gateway_id}", response_class=HTMLResponse) +async def gateway_detail( + request: Request, + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Gateway detail page.""" + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + + if not gateway: + flash(request, "Gateway nicht gefunden", "danger") + return RedirectResponse(url="/gateways", status_code=303) + + # Check access + if current_user.role == UserRole.TECHNICIAN: + access = db.query(UserGatewayAccess).filter( + UserGatewayAccess.user_id == current_user.id, + UserGatewayAccess.gateway_id == gateway_id + ).first() + if not access: + flash(request, "Kein Zugriff auf dieses Gateway", "danger") + return RedirectResponse(url="/gateways", status_code=303) + + templates = db.query(ApplicationTemplate).all() + + return request.app.state.templates.TemplateResponse( + "gateways/detail.html", + { + "request": request, + "current_user": current_user, + "gateway": gateway, + "templates": templates, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.get("/gateways/{gateway_id}/edit", response_class=HTMLResponse) +async def edit_gateway_form( + request: Request, + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Edit gateway form.""" + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + if not gateway: + flash(request, "Gateway nicht gefunden", "danger") + return RedirectResponse(url="/gateways", status_code=303) + + tenants = db.query(Tenant).filter(Tenant.is_active == True).all() + + return request.app.state.templates.TemplateResponse( + "gateways/form.html", + { + "request": request, + "current_user": current_user, + "gateway": gateway, + "tenants": tenants, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/gateways/{gateway_id}/edit") +async def update_gateway( + request: Request, + gateway_id: int, + name: str = Form(...), + router_type: str = Form(...), + firmware_version: str = Form(None), + serial_number: str = Form(None), + location: str = Form(None), + vpn_subnet: str = Form(None), + description: str = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Update gateway.""" + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + if not gateway: + flash(request, "Gateway nicht gefunden", "danger") + return RedirectResponse(url="/gateways", status_code=303) + + gateway.name = name + gateway.router_type = RouterType(router_type) + gateway.firmware_version = firmware_version or None + gateway.serial_number = serial_number or None + gateway.location = location or None + gateway.vpn_subnet = vpn_subnet or None + gateway.description = description or None + + db.commit() + + flash(request, "Gateway aktualisiert", "success") + return RedirectResponse(url=f"/gateways/{gateway_id}", status_code=303) + + +@router.post("/gateways/{gateway_id}/delete") +async def delete_gateway( + request: Request, + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Delete gateway.""" + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + if not gateway: + flash(request, "Gateway nicht gefunden", "danger") + return RedirectResponse(url="/gateways", status_code=303) + + db.delete(gateway) + db.commit() + + flash(request, f"Gateway '{gateway.name}' gelöscht", "warning") + return RedirectResponse(url="/gateways", status_code=303) + + +@router.get("/gateways/{gateway_id}/provision") +async def download_provisioning( + request: Request, + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Redirect to VPN profiles for provisioning. + + Provisioning is now done through VPN profiles. + Each profile contains its own certificate and VPN server configuration. + """ + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + if not gateway: + flash(request, "Gateway nicht gefunden", "danger") + return RedirectResponse(url="/gateways", status_code=303) + + # Redirect to profiles page + flash(request, "Bitte erstellen Sie ein VPN-Profil für das Provisioning", "info") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303) diff --git a/server/app/web/htmx.py b/server/app/web/htmx.py new file mode 100644 index 0000000..6b40f4a --- /dev/null +++ b/server/app/web/htmx.py @@ -0,0 +1,698 @@ +"""HTMX partial routes for dynamic updates.""" + +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.user import User, UserRole +from ..models.gateway import Gateway +from ..models.endpoint import Endpoint +from ..models.access import UserGatewayAccess, ConnectionLog +from ..models.vpn_server import VPNServer +from ..models.vpn_connection_log import VPNConnectionLog +from ..services.vpn_server_service import VPNServerService +from ..services.vpn_sync_service import VPNSyncService +from .deps import get_current_user_web + +router = APIRouter() + + +@router.get("/dashboard/stats", response_class=HTMLResponse) +async def dashboard_stats( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Dashboard statistics partial.""" + gateways_total = db.query(Gateway).count() + gateways_online = db.query(Gateway).filter(Gateway.is_online == True).count() + endpoints_total = db.query(Endpoint).count() + users_total = db.query(User).filter(User.is_active == True).count() + active_connections = db.query(ConnectionLog).filter( + ConnectionLog.disconnected_at.is_(None) + ).count() + + # Count VPN clients across all active servers + vpn_clients_total = 0 + vpn_servers = db.query(VPNServer).filter(VPNServer.is_active == True).all() + service = VPNServerService(db) + for server in vpn_servers: + try: + clients = service.get_connected_clients(server) + vpn_clients_total += len(clients) + except: + pass # Server might be offline + + return f""" +
+
+
+
+
+
Gateways
+
{gateways_online} / {gateways_total}
+
+ +
+
+
+
+
+
+
+
+
+
VPN-Clients
+
{vpn_clients_total}
+
+ +
+
+
+
+
+
+
+
+
+
Sessions
+
{active_connections}
+
+ +
+
+
+
+
+
+
+
+
+
Endpunkte
+
{endpoints_total}
+
+ +
+
+
+
+
+
+
+
+
+
Benutzer
+
{users_total}
+
+ +
+
+
+
+ """ + + +@router.get("/gateways/list", response_class=HTMLResponse) +@router.get("/gateways/search", response_class=HTMLResponse) +@router.get("/gateways/filter", response_class=HTMLResponse) +async def gateway_list_partial( + request: Request, + q: str = "", + status: str = "", + type: str = "", + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Gateway list partial for HTMX.""" + query = db.query(Gateway) + + # Filter by tenant for non-super-admins + if current_user.role != UserRole.SUPER_ADMIN: + if current_user.role == UserRole.ADMIN: + query = query.filter(Gateway.tenant_id == current_user.tenant_id) + else: + query = query.join( + UserGatewayAccess, + UserGatewayAccess.gateway_id == Gateway.id + ).filter(UserGatewayAccess.user_id == current_user.id) + + # Apply filters + if q: + query = query.filter(Gateway.name.ilike(f"%{q}%")) + if status == "online": + query = query.filter(Gateway.is_online == True) + elif status == "offline": + query = query.filter(Gateway.is_online == False) + if type: + query = query.filter(Gateway.router_type == type) + + gateways = query.all() + + if not gateways: + return """ +
+ Keine Gateways gefunden +
+ """ + + html = '
' + for gw in gateways: + status_class = "online" if gw.is_online else "offline" + status_badge = 'Online' if gw.is_online else 'Offline' + last_seen = gw.last_seen.strftime('%d.%m.%Y %H:%M') if gw.last_seen else 'Nie' + + html += f""" +
+
+
+
+ + {gw.name} +
+

+ {gw.router_type} | {gw.location or 'Kein Standort'} +

+

+ {status_badge} + Zuletzt: {last_seen} +

+ + Details + +
+
+
+ """ + html += '
' + + return html + + +@router.get("/gateways/status-list", response_class=HTMLResponse) +async def gateway_status_list( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Gateway status list for dashboard.""" + query = db.query(Gateway) + + if current_user.role != UserRole.SUPER_ADMIN: + if current_user.role == UserRole.ADMIN: + query = query.filter(Gateway.tenant_id == current_user.tenant_id) + + gateways = query.limit(10).all() + + if not gateways: + return '

Keine Gateways vorhanden

' + + html = '' + for gw in gateways: + status = '' if gw.is_online else '' + html += f""" + + + + + + """ + html += '
{status} {gw.name}{gw.router_type} + + + +
' + + return html + + +@router.get("/gateways/{gateway_id}/endpoints", response_class=HTMLResponse) +async def gateway_endpoints_partial( + request: Request, + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Endpoints list partial.""" + endpoints = db.query(Endpoint).filter(Endpoint.gateway_id == gateway_id).all() + + if not endpoints: + return """ +
+ +

Keine Endpunkte definiert

+
+ """ + + html = '' + + for ep in endpoints: + protocol_badge = f'{ep.protocol.value.upper()}' + html += f""" + + + + + + + + """ + html += '
NameAdresseProtokollAnwendung
{ep.name}{ep.internal_ip}:{ep.port}{protocol_badge}{ep.application_name or '-'} + +
' + + return html + + +@router.post("/gateways/{gateway_id}/endpoints", response_class=HTMLResponse) +async def create_endpoint_htmx( + request: Request, + gateway_id: int, + name: str = Form(...), + internal_ip: str = Form(...), + port: int = Form(...), + protocol: str = Form("tcp"), + application_template_id: int = Form(None), + description: str = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Create endpoint via HTMX.""" + from ..models.endpoint import Protocol + + endpoint = Endpoint( + gateway_id=gateway_id, + name=name, + internal_ip=internal_ip, + port=port, + protocol=Protocol(protocol), + application_template_id=application_template_id if application_template_id else None, + description=description + ) + + db.add(endpoint) + db.commit() + + # Return updated list + return await gateway_endpoints_partial(request, gateway_id, db, current_user) + + +@router.delete("/endpoints/{endpoint_id}", response_class=HTMLResponse) +async def delete_endpoint_htmx( + request: Request, + endpoint_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Delete endpoint via HTMX.""" + endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first() + if endpoint: + gateway_id = endpoint.gateway_id + db.delete(endpoint) + db.commit() + return await gateway_endpoints_partial(request, gateway_id, db, current_user) + return "" + + +@router.get("/gateways/{gateway_id}/access", response_class=HTMLResponse) +async def gateway_access_partial( + request: Request, + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """User access list partial for gateway.""" + access_list = db.query(UserGatewayAccess).filter( + UserGatewayAccess.gateway_id == gateway_id + ).all() + + if not access_list: + return """ +
+ +

Keine Benutzer haben Zugriff

+
+ """ + + html = '' + + for access in access_list: + user = db.query(User).filter(User.id == access.user_id).first() + granted_by = db.query(User).filter(User.id == access.granted_by_id).first() if access.granted_by_id else None + granted_at = access.granted_at.strftime('%d.%m.%Y') if access.granted_at else '-' + + html += f""" + + + + + + + + """ + html += '
BenutzerRolleGewährt amGewährt von
{user.username if user else '-'}{user.role.value if user else '-'}{granted_at}{granted_by.username if granted_by else '-'} + +
' + + return html + + +@router.post("/gateways/{gateway_id}/access", response_class=HTMLResponse) +async def grant_access_htmx( + request: Request, + gateway_id: int, + user_id: int = Form(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Grant user access to gateway via HTMX.""" + # Check if access already exists + existing = db.query(UserGatewayAccess).filter( + UserGatewayAccess.gateway_id == gateway_id, + UserGatewayAccess.user_id == user_id + ).first() + + if not existing: + access = UserGatewayAccess( + gateway_id=gateway_id, + user_id=user_id, + granted_by_id=current_user.id + ) + db.add(access) + db.commit() + + return await gateway_access_partial(request, gateway_id, db, current_user) + + +@router.delete("/gateways/{gateway_id}/access/{user_id}", response_class=HTMLResponse) +async def revoke_access_htmx( + request: Request, + gateway_id: int, + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Revoke user access to gateway via HTMX.""" + access = db.query(UserGatewayAccess).filter( + UserGatewayAccess.gateway_id == gateway_id, + UserGatewayAccess.user_id == user_id + ).first() + + if access: + db.delete(access) + db.commit() + + return await gateway_access_partial(request, gateway_id, db, current_user) + + +@router.get("/gateways/{gateway_id}/vpn-log", response_class=HTMLResponse) +async def gateway_vpn_log_partial( + request: Request, + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """VPN connection log for gateway.""" + sync_service = VPNSyncService(db) + logs = sync_service.get_gateway_connection_logs(gateway_id, limit=20) + + if not logs: + return """ +
+ +

Keine VPN-Verbindungen aufgezeichnet

+
+ """ + + html = ''' + + + + + + + + + + + + ''' + + for log in logs: + profile_name = log.vpn_profile.name if log.vpn_profile else '-' + server_name = log.vpn_server.name if log.vpn_server else '-' + real_addr = log.real_address or '-' + connected = log.connected_at.strftime('%d.%m. %H:%M') if log.connected_at else '-' + + if log.disconnected_at: + disconnected = log.disconnected_at.strftime('%H:%M') + duration = log.duration_seconds or 0 + if duration >= 3600: + duration_str = f"{duration // 3600}h {(duration % 3600) // 60}m" + elif duration >= 60: + duration_str = f"{duration // 60}m" + else: + duration_str = f"{duration}s" + status_badge = '' + else: + disconnected = 'Aktiv' + duration_str = '-' + status_badge = '' + + rx = log.bytes_received or 0 + tx = log.bytes_sent or 0 + rx_str = f"{rx / 1024 / 1024:.1f}" if rx > 1024*1024 else f"{rx / 1024:.0f}K" + tx_str = f"{tx / 1024 / 1024:.1f}" if tx > 1024*1024 else f"{tx / 1024:.0f}K" + traffic = f"↓{rx_str} ↑{tx_str}" + + html += f''' + + + + + + + + + ''' + + html += '
ProfilServerEchte AdresseVerbundenGetrenntDauerTraffic
{profile_name}{server_name}{real_addr}{connected}{disconnected}{duration_str}{traffic}
' + return html + + +@router.get("/connections/count", response_class=HTMLResponse) +async def connections_count( + db: Session = Depends(get_db) +): + """Active connections count.""" + count = db.query(ConnectionLog).filter( + ConnectionLog.disconnected_at.is_(None) + ).count() + + # Also count VPN clients + vpn_clients = 0 + vpn_servers = db.query(VPNServer).filter(VPNServer.is_active == True).all() + service = VPNServerService(db) + for server in vpn_servers: + try: + clients = service.get_connected_clients(server) + vpn_clients += len(clients) + except: + pass + + return f' {vpn_clients} VPN-Clients   {count} Sessions' + + +@router.get("/connections/vpn-clients", response_class=HTMLResponse) +async def vpn_clients_list( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """List of connected VPN clients (gateways).""" + # Sync connections with database (updates gateway status, creates logs) + sync_service = VPNSyncService(db) + sync_service.sync_all_connections() + + # Get active connections from database + active_connections = sync_service.get_active_connections() + + if not active_connections: + return '

Keine VPN-Clients verbunden

' + + html = ''' + + + + + + + + + + + + ''' + + for conn in active_connections: + gateway_name = conn.gateway.name if conn.gateway else '-' + gateway_id = conn.gateway.id if conn.gateway else '' + profile_name = conn.vpn_profile.name if conn.vpn_profile else '-' + server_name = conn.vpn_server.name if conn.vpn_server else '-' + server_id = conn.vpn_server.id if conn.vpn_server else '' + real_addr = conn.real_address or '-' + rx = conn.bytes_received or 0 + tx = conn.bytes_sent or 0 + connected = conn.connected_at.strftime('%d.%m.%Y %H:%M') if conn.connected_at else '-' + + # Format bytes + rx_str = f"{rx / 1024 / 1024:.2f} MB" if rx > 1024*1024 else f"{rx / 1024:.1f} KB" + tx_str = f"{tx / 1024 / 1024:.2f} MB" if tx > 1024*1024 else f"{tx / 1024:.1f} KB" + + html += f''' + + + + + + + + + ''' + + html += '
GatewayProfilVPN-ServerEchte AdresseEmpfangenGesendetVerbunden seit
{gateway_name}{profile_name}{server_name}{real_addr}{rx_str}{tx_str}{connected}
' + return html + + +@router.get("/connections/active", response_class=HTMLResponse) +async def active_connections( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Active connections partial.""" + connections = db.query(ConnectionLog).filter( + ConnectionLog.disconnected_at.is_(None) + ).all() + + if not connections: + return '

Keine aktiven Verbindungen

' + + html = '
    ' + for conn in connections: + user = db.query(User).filter(User.id == conn.user_id).first() + gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first() + endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first() if conn.endpoint_id else None + + html += f""" +
  • +
    + {user.username if user else 'Unknown'} + + {gateway.name if gateway else 'Unknown'} + {f' / {endpoint.name}' if endpoint else ''} +
    + Verbunden +
  • + """ + html += '
' + + return html + + +@router.get("/connections/recent", response_class=HTMLResponse) +async def recent_connections( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Recent connections for dashboard.""" + connections = db.query(ConnectionLog).order_by( + ConnectionLog.connected_at.desc() + ).limit(5).all() + + if not connections: + return '

Keine Verbindungen

' + + html = '
    ' + for conn in connections: + user = db.query(User).filter(User.id == conn.user_id).first() + gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first() + time = conn.connected_at.strftime('%H:%M') + + if conn.disconnected_at: + status = '' + else: + status = '' + + html += f""" +
  • + + {status} + {user.username if user else '?'} + + {gateway.name if gateway else '?'} + + {time} +
  • + """ + html += '
' + + return html + + +@router.get("/connections/list", response_class=HTMLResponse) +async def connections_list( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_web) +): + """Connection history list.""" + connections = db.query(ConnectionLog).order_by( + ConnectionLog.connected_at.desc() + ).limit(50).all() + + if not connections: + return '

Keine Verbindungshistorie

' + + html = '' + + for conn in connections: + user = db.query(User).filter(User.id == conn.user_id).first() + gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first() + endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first() if conn.endpoint_id else None + + connected = conn.connected_at.strftime('%d.%m.%Y %H:%M') + disconnected = conn.disconnected_at.strftime('%H:%M') if conn.disconnected_at else 'Aktiv' + + duration = "" + if conn.duration_seconds: + mins = conn.duration_seconds // 60 + duration = f"{mins} Min." + + html += f""" + + + + + + + + + """ + + html += '
BenutzerGatewayEndpunktVerbundenGetrenntDauer
{user.username if user else '-'}{gateway.name if gateway else '-'}{endpoint.name if endpoint else '-'}{connected}{disconnected}{duration}
' + return html + + diff --git a/server/app/web/tenants.py b/server/app/web/tenants.py new file mode 100644 index 0000000..0d9aedd --- /dev/null +++ b/server/app/web/tenants.py @@ -0,0 +1,151 @@ +"""Tenant management web routes (Super Admin only).""" + +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.user import User +from ..models.tenant import Tenant +from .deps import require_super_admin_web, flash, get_flashed_messages + +router = APIRouter() + + +@router.get("/tenants", response_class=HTMLResponse) +async def list_tenants( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """List all tenants.""" + tenants = db.query(Tenant).all() + + return request.app.state.templates.TemplateResponse( + "tenants/list.html", + { + "request": request, + "current_user": current_user, + "tenants": tenants, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.get("/tenants/new", response_class=HTMLResponse) +async def new_tenant_form( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """New tenant form.""" + return request.app.state.templates.TemplateResponse( + "tenants/form.html", + { + "request": request, + "current_user": current_user, + "tenant": None, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/tenants/new") +async def create_tenant( + request: Request, + name: str = Form(...), + description: str = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """Create new tenant.""" + # Check if name exists + existing = db.query(Tenant).filter(Tenant.name == name).first() + if existing: + flash(request, "Mandantenname bereits vergeben", "danger") + return RedirectResponse(url="/tenants/new", status_code=303) + + tenant = Tenant( + name=name, + description=description or None + ) + + db.add(tenant) + db.commit() + + flash(request, f"Mandant '{name}' erstellt", "success") + return RedirectResponse(url="/tenants", status_code=303) + + +@router.get("/tenants/{tenant_id}/edit", response_class=HTMLResponse) +async def edit_tenant_form( + request: Request, + tenant_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """Edit tenant form.""" + tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + flash(request, "Mandant nicht gefunden", "danger") + return RedirectResponse(url="/tenants", status_code=303) + + return request.app.state.templates.TemplateResponse( + "tenants/form.html", + { + "request": request, + "current_user": current_user, + "tenant": tenant, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/tenants/{tenant_id}/edit") +async def update_tenant( + request: Request, + tenant_id: int, + name: str = Form(...), + description: str = Form(None), + is_active: bool = Form(True), + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """Update tenant.""" + tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + flash(request, "Mandant nicht gefunden", "danger") + return RedirectResponse(url="/tenants", status_code=303) + + tenant.name = name + tenant.description = description or None + tenant.is_active = is_active + + db.commit() + + flash(request, "Mandant aktualisiert", "success") + return RedirectResponse(url="/tenants", status_code=303) + + +@router.post("/tenants/{tenant_id}/delete") +async def delete_tenant( + request: Request, + tenant_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_super_admin_web) +): + """Delete tenant.""" + tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + flash(request, "Mandant nicht gefunden", "danger") + return RedirectResponse(url="/tenants", status_code=303) + + # Check if tenant has users or gateways + if tenant.users or tenant.gateways: + flash(request, "Mandant hat noch Benutzer oder Gateways. Bitte zuerst löschen.", "danger") + return RedirectResponse(url="/tenants", status_code=303) + + db.delete(tenant) + db.commit() + + flash(request, f"Mandant '{tenant.name}' gelöscht", "warning") + return RedirectResponse(url="/tenants", status_code=303) diff --git a/server/app/web/users.py b/server/app/web/users.py new file mode 100644 index 0000000..7d4f8b3 --- /dev/null +++ b/server/app/web/users.py @@ -0,0 +1,144 @@ +"""User management web routes.""" + +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session +from ..database import get_db +from ..models.user import User, UserRole +from ..utils.security import get_password_hash +from .deps import get_current_user_web, require_admin_web, flash, get_flashed_messages + +router = APIRouter() + + +@router.get("/users", response_class=HTMLResponse) +async def list_users( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """List users.""" + if current_user.role == UserRole.SUPER_ADMIN: + users = db.query(User).all() + else: + users = db.query(User).filter(User.tenant_id == current_user.tenant_id).all() + + return request.app.state.templates.TemplateResponse( + "users/list.html", + { + "request": request, + "current_user": current_user, + "users": users, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.get("/users/new", response_class=HTMLResponse) +async def new_user_form( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """New user form.""" + from ..models.tenant import Tenant + tenants = db.query(Tenant).filter(Tenant.is_active == True).all() + + return request.app.state.templates.TemplateResponse( + "users/form.html", + { + "request": request, + "current_user": current_user, + "user": None, + "tenants": tenants, + "roles": [r.value for r in UserRole if r != UserRole.SUPER_ADMIN or current_user.role == UserRole.SUPER_ADMIN], + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/users/new") +async def create_user( + request: Request, + username: str = Form(...), + email: str = Form(...), + password: str = Form(...), + role: str = Form(...), + full_name: str = Form(None), + tenant_id: int = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Create new user.""" + # Check if username exists + existing = db.query(User).filter(User.username == username).first() + if existing: + flash(request, "Benutzername bereits vergeben", "danger") + return RedirectResponse(url="/users/new", status_code=303) + + # Check if email exists + existing = db.query(User).filter(User.email == email).first() + if existing: + flash(request, "E-Mail bereits vergeben", "danger") + return RedirectResponse(url="/users/new", status_code=303) + + # Determine tenant + if current_user.role == UserRole.SUPER_ADMIN and tenant_id: + user_tenant_id = tenant_id + else: + user_tenant_id = current_user.tenant_id + + user = User( + username=username, + email=email, + password_hash=get_password_hash(password), + role=UserRole(role), + full_name=full_name or None, + tenant_id=user_tenant_id if UserRole(role) != UserRole.SUPER_ADMIN else None + ) + + db.add(user) + db.commit() + + flash(request, f"Benutzer '{username}' erstellt", "success") + return RedirectResponse(url="/users", status_code=303) + + +@router.get("/users/{user_id}/access", response_class=HTMLResponse) +async def user_access( + request: Request, + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Manage user gateway access.""" + from ..models.gateway import Gateway + from ..models.access import UserGatewayAccess + + user = db.query(User).filter(User.id == user_id).first() + if not user: + flash(request, "Benutzer nicht gefunden", "danger") + return RedirectResponse(url="/users", status_code=303) + + # Get all gateways and current access + if current_user.role == UserRole.SUPER_ADMIN: + gateways = db.query(Gateway).all() + else: + gateways = db.query(Gateway).filter(Gateway.tenant_id == current_user.tenant_id).all() + + user_access = db.query(UserGatewayAccess).filter( + UserGatewayAccess.user_id == user_id + ).all() + access_gateway_ids = [a.gateway_id for a in user_access] + + return request.app.state.templates.TemplateResponse( + "users/access.html", + { + "request": request, + "current_user": current_user, + "user": user, + "gateways": gateways, + "access_gateway_ids": access_gateway_ids, + "flash_messages": get_flashed_messages(request) + } + ) diff --git a/server/app/web/vpn_profiles.py b/server/app/web/vpn_profiles.py new file mode 100644 index 0000000..0b9b4cf --- /dev/null +++ b/server/app/web/vpn_profiles.py @@ -0,0 +1,394 @@ +"""VPN Profile management web routes (nested under gateways).""" + +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from sqlalchemy.orm import Session + +from ..database import get_db +from ..models.user import User +from ..models.gateway import Gateway +from ..models.vpn_server import VPNServer +from ..models.vpn_profile import VPNProfile +from ..services.vpn_profile_service import VPNProfileService +from .deps import require_admin_web, require_user_web, flash, get_flashed_messages + +router = APIRouter() + + +@router.get("/gateways/{gateway_id}/profiles", response_class=HTMLResponse) +async def list_profiles( + request: Request, + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_user_web) +): + """List VPN profiles for a gateway.""" + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + + if not gateway: + flash(request, "Gateway nicht gefunden", "danger") + return RedirectResponse(url="/gateways", status_code=303) + + # Check access + if not current_user.is_admin and not any( + access.gateway_id == gateway_id for access in current_user.gateway_access + ): + flash(request, "Zugriff verweigert", "danger") + return RedirectResponse(url="/gateways", status_code=303) + + service = VPNProfileService(db) + profiles = service.get_profiles_for_gateway(gateway_id) + + return request.app.state.templates.TemplateResponse( + "gateways/profiles.html", + { + "request": request, + "current_user": current_user, + "gateway": gateway, + "profiles": profiles, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.get("/gateways/{gateway_id}/profiles/new", response_class=HTMLResponse) +async def new_profile_form( + request: Request, + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """New VPN profile form.""" + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + + if not gateway: + flash(request, "Gateway nicht gefunden", "danger") + return RedirectResponse(url="/gateways", status_code=303) + + # Get available VPN servers + if current_user.is_super_admin: + servers = db.query(VPNServer).filter(VPNServer.is_active == True).all() + else: + servers = db.query(VPNServer).filter( + (VPNServer.tenant_id == current_user.tenant_id) | + (VPNServer.tenant_id == None), + VPNServer.is_active == True + ).all() + + # Calculate next priority + existing_profiles = db.query(VPNProfile).filter( + VPNProfile.gateway_id == gateway_id + ).count() + next_priority = existing_profiles + 1 + + return request.app.state.templates.TemplateResponse( + "gateways/profile_form.html", + { + "request": request, + "current_user": current_user, + "gateway": gateway, + "profile": None, + "vpn_servers": servers, + "next_priority": next_priority, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/gateways/{gateway_id}/profiles/new") +async def create_profile( + request: Request, + gateway_id: int, + name: str = Form(...), + vpn_server_id: int = Form(...), + priority: int = Form(1), + description: str = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Create new VPN profile.""" + try: + service = VPNProfileService(db) + + profile = service.create_profile( + gateway_id=gateway_id, + vpn_server_id=vpn_server_id, + name=name, + priority=priority, + description=description + ) + + flash(request, f"VPN-Profil '{name}' erstellt", "success") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/{profile.id}", status_code=303) + + except ValueError as e: + flash(request, str(e), "danger") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/new", status_code=303) + except Exception as e: + flash(request, f"Fehler: {str(e)}", "danger") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/new", status_code=303) + + +@router.get("/gateways/{gateway_id}/profiles/{profile_id}", response_class=HTMLResponse) +async def profile_detail( + request: Request, + gateway_id: int, + profile_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_user_web) +): + """VPN profile detail page.""" + profile = db.query(VPNProfile).filter( + VPNProfile.id == profile_id, + VPNProfile.gateway_id == gateway_id + ).first() + + if not profile: + flash(request, "Profil nicht gefunden", "danger") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303) + + return request.app.state.templates.TemplateResponse( + "gateways/profile_detail.html", + { + "request": request, + "current_user": current_user, + "gateway": profile.gateway, + "profile": profile, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.get("/gateways/{gateway_id}/profiles/{profile_id}/edit", response_class=HTMLResponse) +async def edit_profile_form( + request: Request, + gateway_id: int, + profile_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Edit VPN profile form.""" + profile = db.query(VPNProfile).filter( + VPNProfile.id == profile_id, + VPNProfile.gateway_id == gateway_id + ).first() + + if not profile: + flash(request, "Profil nicht gefunden", "danger") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303) + + # Get available VPN servers + if current_user.is_super_admin: + servers = db.query(VPNServer).filter(VPNServer.is_active == True).all() + else: + servers = db.query(VPNServer).filter( + (VPNServer.tenant_id == current_user.tenant_id) | + (VPNServer.tenant_id == None), + VPNServer.is_active == True + ).all() + + return request.app.state.templates.TemplateResponse( + "gateways/profile_form.html", + { + "request": request, + "current_user": current_user, + "gateway": profile.gateway, + "profile": profile, + "vpn_servers": servers, + "next_priority": profile.priority, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/gateways/{gateway_id}/profiles/{profile_id}/edit") +async def update_profile( + request: Request, + gateway_id: int, + profile_id: int, + name: str = Form(...), + priority: int = Form(1), + description: str = Form(None), + is_active: str = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Update VPN profile.""" + profile = db.query(VPNProfile).filter( + VPNProfile.id == profile_id, + VPNProfile.gateway_id == gateway_id + ).first() + + if not profile: + flash(request, "Profil nicht gefunden", "danger") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303) + + profile.name = name + profile.description = description + profile.priority = priority + profile.is_active = is_active is not None # Checkbox sends "on" when checked, None when not + db.commit() + + flash(request, f"Profil '{name}' aktualisiert", "success") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/{profile_id}", status_code=303) + + +@router.get("/gateways/{gateway_id}/profiles/{profile_id}/provision") +async def provision_profile( + gateway_id: int, + profile_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_user_web) +): + """Download OpenVPN config for a profile.""" + service = VPNProfileService(db) + profile = service.get_profile_by_id(profile_id) + + if not profile or profile.gateway_id != gateway_id: + return Response(status_code=404, content="Profile not found") + + if not profile.is_ready: + return Response(status_code=400, content="Profile not ready for provisioning") + + try: + config = service.provision_profile(profile) + + filename = f"{profile.gateway.name}-{profile.name}.ovpn" + filename = filename.lower().replace(' ', '-') + + return Response( + content=config, + media_type="application/x-openvpn-profile", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + except Exception as e: + return Response(status_code=500, content=str(e)) + + +@router.post("/gateways/{gateway_id}/profiles/{profile_id}/priority") +async def update_priority( + request: Request, + gateway_id: int, + profile_id: int, + priority: int = Form(...), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Update profile priority.""" + service = VPNProfileService(db) + profile = service.get_profile_by_id(profile_id) + + if not profile or profile.gateway_id != gateway_id: + flash(request, "Profil nicht gefunden", "danger") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303) + + service.set_priority(profile, priority) + + flash(request, "Priorität aktualisiert", "success") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303) + + +@router.post("/gateways/{gateway_id}/profiles/{profile_id}/revoke") +async def revoke_profile( + request: Request, + gateway_id: int, + profile_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Revoke profile certificate.""" + service = VPNProfileService(db) + profile = service.get_profile_by_id(profile_id) + + if not profile or profile.gateway_id != gateway_id: + flash(request, "Profil nicht gefunden", "danger") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303) + + service.revoke_profile(profile) + + flash(request, f"Zertifikat für '{profile.name}' widerrufen", "warning") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303) + + +@router.post("/gateways/{gateway_id}/profiles/{profile_id}/renew") +async def renew_profile( + request: Request, + gateway_id: int, + profile_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Renew profile certificate.""" + service = VPNProfileService(db) + profile = service.get_profile_by_id(profile_id) + + if not profile or profile.gateway_id != gateway_id: + flash(request, "Profil nicht gefunden", "danger") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303) + + service.renew_profile(profile) + + flash(request, f"Zertifikat für '{profile.name}' erneuert", "success") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/{profile_id}", status_code=303) + + +@router.post("/gateways/{gateway_id}/profiles/{profile_id}/delete") +async def delete_profile( + request: Request, + gateway_id: int, + profile_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Delete VPN profile.""" + service = VPNProfileService(db) + profile = service.get_profile_by_id(profile_id) + + if not profile or profile.gateway_id != gateway_id: + flash(request, "Profil nicht gefunden", "danger") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303) + + name = profile.name + service.delete_profile(profile) + + flash(request, f"Profil '{name}' gelöscht", "warning") + return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303) + + +@router.get("/gateways/{gateway_id}/provision-all") +async def provision_all_profiles( + gateway_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_user_web) +): + """Download all active profiles as a ZIP file.""" + import io + import zipfile + + service = VPNProfileService(db) + gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first() + + if not gateway: + return Response(status_code=404, content="Gateway not found") + + configs = service.generate_all_configs_for_gateway(gateway_id) + + if not configs: + return Response(status_code=400, content="No active profiles available") + + # Create ZIP file in memory + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for filename, config in configs: + zip_file.writestr(filename, config) + + zip_buffer.seek(0) + + zip_filename = f"{gateway.name}-vpn-profiles.zip" + zip_filename = zip_filename.lower().replace(' ', '-') + + return Response( + content=zip_buffer.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename={zip_filename}"} + ) diff --git a/server/app/web/vpn_servers.py b/server/app/web/vpn_servers.py new file mode 100644 index 0000000..8ef674e --- /dev/null +++ b/server/app/web/vpn_servers.py @@ -0,0 +1,349 @@ +"""VPN Server management web routes.""" + +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session +from typing import Optional + +from ..database import get_db +from ..models.user import User +from ..models.vpn_server import VPNServer, VPNProtocol, VPNCipher, VPNAuth, VPNCompression +from ..models.certificate_authority import CertificateAuthority, CAStatus +from ..services.vpn_server_service import VPNServerService +from .deps import require_admin_web, flash, get_flashed_messages + +router = APIRouter() + + +@router.get("/vpn-servers", response_class=HTMLResponse) +async def list_vpn_servers( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """List all VPN servers.""" + service = VPNServerService(db) + + if current_user.is_super_admin: + servers = db.query(VPNServer).all() + else: + servers = service.get_servers_for_tenant(current_user.tenant_id) + + return request.app.state.templates.TemplateResponse( + "vpn_servers/list.html", + { + "request": request, + "current_user": current_user, + "servers": servers, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.get("/vpn-servers/new", response_class=HTMLResponse) +async def new_vpn_server_form( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """New VPN server form.""" + # Get available CAs + if current_user.is_super_admin: + cas = db.query(CertificateAuthority).filter( + CertificateAuthority.status == CAStatus.ACTIVE + ).all() + else: + cas = db.query(CertificateAuthority).filter( + (CertificateAuthority.tenant_id == current_user.tenant_id) | + (CertificateAuthority.tenant_id == None), + CertificateAuthority.status == CAStatus.ACTIVE + ).all() + + return request.app.state.templates.TemplateResponse( + "vpn_servers/form.html", + { + "request": request, + "current_user": current_user, + "server": None, + "cas": cas, + "protocols": VPNProtocol, + "ciphers": VPNCipher, + "auth_methods": VPNAuth, + "compression_options": VPNCompression, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/vpn-servers/new") +async def create_vpn_server( + request: Request, + name: str = Form(...), + hostname: str = Form(...), + ca_id: int = Form(...), + port: int = Form(1194), + protocol: str = Form("udp"), + vpn_network: str = Form("10.8.0.0"), + vpn_netmask: str = Form("255.255.255.0"), + cipher: str = Form("AES-256-GCM"), + auth: str = Form("SHA256"), + tls_version_min: str = Form("1.2"), + compression: str = Form("none"), + max_clients: int = Form(100), + keepalive_interval: int = Form(10), + keepalive_timeout: int = Form(60), + management_port: int = Form(7505), + is_primary: bool = Form(False), + description: str = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Create new VPN server.""" + try: + service = VPNServerService(db) + + tenant_id = None if current_user.is_super_admin else current_user.tenant_id + + server = service.create_server( + name=name, + hostname=hostname, + ca_id=ca_id, + port=port, + protocol=VPNProtocol(protocol), + vpn_network=vpn_network, + vpn_netmask=vpn_netmask, + cipher=VPNCipher(cipher), + auth=VPNAuth(auth), + tls_version_min=tls_version_min, + compression=VPNCompression(compression), + max_clients=max_clients, + keepalive_interval=keepalive_interval, + keepalive_timeout=keepalive_timeout, + management_port=management_port, + is_primary=is_primary, + tenant_id=tenant_id, + description=description + ) + + flash(request, f"VPN-Server '{name}' erstellt", "success") + return RedirectResponse(url=f"/vpn-servers/{server.id}", status_code=303) + + except ValueError as e: + flash(request, str(e), "danger") + return RedirectResponse(url="/vpn-servers/new", status_code=303) + except Exception as e: + flash(request, f"Fehler beim Erstellen: {str(e)}", "danger") + return RedirectResponse(url="/vpn-servers/new", status_code=303) + + +@router.get("/vpn-servers/{server_id}", response_class=HTMLResponse) +async def vpn_server_detail( + request: Request, + server_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """VPN server detail page.""" + service = VPNServerService(db) + server = service.get_server_by_id(server_id) + + if not server: + flash(request, "VPN-Server nicht gefunden", "danger") + return RedirectResponse(url="/vpn-servers", status_code=303) + + # Check access + if not current_user.is_super_admin and server.tenant_id != current_user.tenant_id and server.tenant_id is not None: + flash(request, "Zugriff verweigert", "danger") + return RedirectResponse(url="/vpn-servers", status_code=303) + + # Get status + status = service.get_server_status(server) + + return request.app.state.templates.TemplateResponse( + "vpn_servers/detail.html", + { + "request": request, + "current_user": current_user, + "server": server, + "status": status, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.get("/vpn-servers/{server_id}/edit", response_class=HTMLResponse) +async def edit_vpn_server_form( + request: Request, + server_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Edit VPN server form.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + flash(request, "VPN-Server nicht gefunden", "danger") + return RedirectResponse(url="/vpn-servers", status_code=303) + + # Get available CAs + cas = db.query(CertificateAuthority).filter( + CertificateAuthority.status == CAStatus.ACTIVE + ).all() + + return request.app.state.templates.TemplateResponse( + "vpn_servers/form.html", + { + "request": request, + "current_user": current_user, + "server": server, + "cas": cas, + "protocols": VPNProtocol, + "ciphers": VPNCipher, + "auth_methods": VPNAuth, + "compression_options": VPNCompression, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/vpn-servers/{server_id}/edit") +async def update_vpn_server( + request: Request, + server_id: int, + name: str = Form(...), + hostname: str = Form(...), + port: int = Form(1194), + protocol: str = Form("udp"), + vpn_network: str = Form("10.8.0.0"), + vpn_netmask: str = Form("255.255.255.0"), + cipher: str = Form("AES-256-GCM"), + auth: str = Form("SHA256"), + tls_version_min: str = Form("1.2"), + compression: str = Form("none"), + max_clients: int = Form(100), + keepalive_interval: int = Form(10), + keepalive_timeout: int = Form(60), + management_port: int = Form(7505), + is_primary: bool = Form(False), + is_active: bool = Form(True), + description: str = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Update VPN server.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + flash(request, "VPN-Server nicht gefunden", "danger") + return RedirectResponse(url="/vpn-servers", status_code=303) + + # Update fields + server.name = name + server.description = description + server.hostname = hostname + server.port = port + server.protocol = VPNProtocol(protocol) + server.vpn_network = vpn_network + server.vpn_netmask = vpn_netmask + server.cipher = VPNCipher(cipher) + server.auth = VPNAuth(auth) + server.tls_version_min = tls_version_min + server.compression = VPNCompression(compression) + server.max_clients = max_clients + server.keepalive_interval = keepalive_interval + server.keepalive_timeout = keepalive_timeout + server.management_port = management_port + server.is_primary = is_primary + server.is_active = is_active + + # If setting as primary, unset other primaries + if is_primary: + db.query(VPNServer).filter( + VPNServer.tenant_id == server.tenant_id, + VPNServer.id != server.id + ).update({"is_primary": False}) + + db.commit() + + flash(request, "VPN-Server aktualisiert", "success") + return RedirectResponse(url=f"/vpn-servers/{server_id}", status_code=303) + + +@router.get("/vpn-servers/{server_id}/clients", response_class=HTMLResponse) +async def vpn_server_clients( + request: Request, + server_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Show connected clients for a VPN server.""" + service = VPNServerService(db) + server = service.get_server_by_id(server_id) + + if not server: + flash(request, "VPN-Server nicht gefunden", "danger") + return RedirectResponse(url="/vpn-servers", status_code=303) + + clients = service.get_connected_clients(server) + + return request.app.state.templates.TemplateResponse( + "vpn_servers/clients.html", + { + "request": request, + "current_user": current_user, + "server": server, + "clients": clients, + "flash_messages": get_flashed_messages(request) + } + ) + + +@router.post("/vpn-servers/{server_id}/disconnect/{common_name}") +async def disconnect_client( + request: Request, + server_id: int, + common_name: str, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Disconnect a client from VPN server.""" + service = VPNServerService(db) + server = service.get_server_by_id(server_id) + + if not server: + flash(request, "VPN-Server nicht gefunden", "danger") + return RedirectResponse(url="/vpn-servers", status_code=303) + + if service.disconnect_client(server, common_name): + flash(request, f"Client '{common_name}' getrennt", "success") + else: + flash(request, f"Konnte Client '{common_name}' nicht trennen", "danger") + + return RedirectResponse(url=f"/vpn-servers/{server_id}/clients", status_code=303) + + +@router.post("/vpn-servers/{server_id}/delete") +async def delete_vpn_server( + request: Request, + server_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Delete VPN server.""" + server = db.query(VPNServer).filter(VPNServer.id == server_id).first() + + if not server: + flash(request, "VPN-Server nicht gefunden", "danger") + return RedirectResponse(url="/vpn-servers", status_code=303) + + # Check if server has profiles + if server.vpn_profiles: + flash(request, "Server hat noch VPN-Profile. Bitte zuerst löschen.", "danger") + return RedirectResponse(url=f"/vpn-servers/{server_id}", status_code=303) + + name = server.name + db.delete(server) + db.commit() + + flash(request, f"VPN-Server '{name}' gelöscht", "warning") + return RedirectResponse(url="/vpn-servers", status_code=303) diff --git a/server/init.sql b/server/init.sql new file mode 100644 index 0000000..e8d2d5c --- /dev/null +++ b/server/init.sql @@ -0,0 +1,8 @@ +-- Initial database schema for mGuard VPN Endpoint Server +-- This file is executed on first container startup + +-- Ensure proper character encoding +ALTER DATABASE mguard_vpn CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Note: Tables are created by SQLAlchemy/Alembic +-- This file can be used for initial data or stored procedures if needed diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..03eab6b --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,31 @@ +# FastAPI and Server +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 + +# Templates (Web UI) +jinja2==3.1.3 +itsdangerous==2.1.2 # Session signing +aiofiles==23.2.1 # Static files + +# Database +sqlalchemy==2.0.25 +pymysql==1.1.0 +alembic==1.13.1 + +# Authentication +python-jose[cryptography]==3.3.0 +bcrypt==4.1.2 +cryptography==42.0.0 # PKI/Certificate management + +# Validation +pydantic==2.5.3 +pydantic-settings==2.1.0 +email-validator==2.1.0 + +# OpenVPN Management +# (Custom implementation, no external package needed) + +# Utilities +python-dotenv==1.0.0 +httpx==0.26.0