diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8bd9cce --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# ============================================== +# mGuard VPN Endpoint Server Configuration +# ============================================== +# Copy this file to .env and adjust the values + +# Database Configuration +DB_ROOT_PASSWORD=change_me_root_password +DB_NAME=mguard_vpn +DB_USER=mguard +DB_PASSWORD=change_me_db_password + +# API/Web Configuration +# Generate with: openssl rand -hex 32 +SECRET_KEY=change_me_generate_with_openssl_rand_hex_32 + +# Admin User (created on first startup) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change_me_admin_password +ADMIN_EMAIL=admin@example.com + +# ============================================== +# OpenVPN Container +# ============================================== +# Der OpenVPN-Container läuft im Host-Netzwerk-Modus und verwaltet +# automatisch alle VPN-Server-Instanzen, die in der Datenbank als +# aktiv markiert sind. +# +# Workflow: +# 1. Container starten mit: docker-compose up -d +# 2. Im Browser: CA erstellen unter http://localhost:8000/ca/new +# 3. VPN-Server erstellen unter http://localhost:8000/vpn-servers/new +# 4. Der Container erkennt neue Server automatisch (Polling alle 30s) +# +# Keine Anpassungen in docker-compose.yml nötig! +# VPN-Server können jederzeit über die Web-UI hinzugefügt/entfernt werden. +# +# Optional: Polling-Intervall anpassen (Standard: 30 Sekunden) +# POLL_INTERVAL=30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..531a465 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Environment and credentials +.env +.env.* +!.env.example +*.env +secrets/ +credentials/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.pydevproject +.settings/ +*.sublime-project +*.sublime-workspace + +# Database +*.db +*.sqlite +*.sqlite3 +data/ +db_data/ + +# Docker volumes (local data) +mariadb_data/ +openvpn_data/ +openvpn_logs/ + +# Logs +*.log +logs/ + +# Certificates and keys (sensitive!) +*.pem +*.key +*.crt +*.csr +*.p12 +*.pfx +ca/ +certs/ +keys/ +pki/ +easy-rsa/ + +# OpenVPN specific +*.ovpn +ta.key +dh*.pem + +# OS files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Test and coverage +.tox/ +.nox/ +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ + +# Jupyter +.ipynb_checkpoints/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Temporary files +tmp/ +temp/ +*.tmp +*.temp +*.bak +*.backup + +# Build artifacts +*.tar.gz +*.zip + +# Node (if frontend exists) +node_modules/ +npm-debug.log +yarn-error.log diff --git a/README.md b/README.md index b834cca..11a5840 100644 --- a/README.md +++ b/README.md @@ -531,6 +531,248 @@ docker-compose up -d --- +## Desktop Client (Techniker-Anwendung) + +Der Desktop Client ist eine PyQt6-Anwendung für Techniker, um sich zu VPN-Gateways zu verbinden. + +### Voraussetzungen + +- **Python 3.10+** +- **OpenVPN** (muss installiert sein) +- Zugang zum mGuard VPN Server + +--- + +### Installation + +#### Linux (Debian/Ubuntu) + +```bash +# System-Abhängigkeiten installieren +sudo apt update +sudo apt install python3 python3-pip python3-venv openvpn + +# In das Client-Verzeichnis wechseln +cd client/ + +# Virtuelle Umgebung erstellen (empfohlen) +python3 -m venv venv +source venv/bin/activate + +# Abhängigkeiten installieren +pip install -r requirements.txt +``` + +#### Linux (Fedora/RHEL) + +```bash +# System-Abhängigkeiten installieren +sudo dnf install python3 python3-pip openvpn + +# In das Client-Verzeichnis wechseln +cd client/ + +# Virtuelle Umgebung erstellen (empfohlen) +python3 -m venv venv +source venv/bin/activate + +# Abhängigkeiten installieren +pip install -r requirements.txt +``` + +#### Linux (Arch) + +```bash +# System-Abhängigkeiten installieren +sudo pacman -S python python-pip openvpn + +# In das Client-Verzeichnis wechseln +cd client/ + +# Virtuelle Umgebung erstellen (empfohlen) +python -m venv venv +source venv/bin/activate + +# Abhängigkeiten installieren +pip install -r requirements.txt +``` + +#### Windows + +1. **Python installieren:** + - Download von https://www.python.org/downloads/ + - Bei Installation "Add Python to PATH" aktivieren + +2. **OpenVPN installieren:** + - Download von https://openvpn.net/community-downloads/ + - Standard-Installation in `C:\Program Files\OpenVPN` + +3. **Client installieren:** + ```powershell + # PowerShell oder CMD + cd client + + # Virtuelle Umgebung erstellen (empfohlen) + python -m venv venv + venv\Scripts\activate + + # Abhängigkeiten installieren + pip install -r requirements.txt + ``` + +#### macOS + +```bash +# Homebrew installieren (falls nicht vorhanden) +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# Python und OpenVPN installieren +brew install python openvpn + +# In das Client-Verzeichnis wechseln +cd client/ + +# Virtuelle Umgebung erstellen (empfohlen) +python3 -m venv venv +source venv/bin/activate + +# Abhängigkeiten installieren +pip install -r requirements.txt +``` + +--- + +### Client starten + +#### Linux / macOS + +```bash +# In das Client-Verzeichnis wechseln +cd client/ + +# Virtuelle Umgebung aktivieren (falls verwendet) +source venv/bin/activate + +# Client starten +python main.py +``` + +**Hinweis:** Für VPN-Verbindungen sind Root-Rechte erforderlich: + +```bash +# Mit sudo starten (für VPN-Verbindungen) +sudo venv/bin/python main.py +``` + +#### Windows + +```powershell +# In das Client-Verzeichnis wechseln +cd client + +# Virtuelle Umgebung aktivieren (falls verwendet) +venv\Scripts\activate + +# Client starten +python main.py +``` + +**Hinweis:** Als Administrator ausführen für VPN-Verbindungen. + +--- + +### Erste Schritte im Client + +1. **Server konfigurieren:** + - Beim ersten Start Server-URL eingeben (z.B. `https://vpn.meinefirma.de:8000`) + +2. **Anmelden:** + - Mit Techniker-Zugangsdaten einloggen + +3. **Gateway wählen:** + - Liste der zugewiesenen Gateways wird angezeigt + - Online-Status wird angezeigt (grün = erreichbar) + +4. **Verbinden:** + - Gateway auswählen und "Verbinden" klicken + - VPN-Verbindung wird automatisch hergestellt + +5. **Endpunkte nutzen:** + - Nach Verbindung sind die Endpunkte (SPS, HMI, etc.) erreichbar + - IP-Adressen und Ports werden angezeigt + +--- + +### Konfiguration + +Der Client speichert Einstellungen in: + +| OS | Pfad | +|----|------| +| Linux/macOS | `~/.mguard-vpn/settings.json` | +| Windows | `C:\Users\\.mguard-vpn\settings.json` | + +OpenVPN-Konfigurationen werden gespeichert in: + +| OS | Pfad | +|----|------| +| Linux/macOS | `~/.openvpn/` | +| Windows | `C:\Users\\OpenVPN\config\` | + +--- + +### Fehlerbehebung Client + +#### "OpenVPN nicht gefunden" + +**Linux:** +```bash +# OpenVPN Pfad prüfen +which openvpn +# Sollte /usr/sbin/openvpn ausgeben + +# Falls woanders installiert, symlink erstellen +sudo ln -s /pfad/zu/openvpn /usr/sbin/openvpn +``` + +**Windows:** +- Prüfen ob OpenVPN in `C:\Program Files\OpenVPN\bin\` installiert ist +- Falls anderer Pfad: `client/config.py` anpassen + +**macOS:** +```bash +# OpenVPN Pfad prüfen +which openvpn +# Meist /opt/homebrew/bin/openvpn oder /usr/local/bin/openvpn + +# Symlink erstellen falls nötig +sudo ln -s $(which openvpn) /usr/sbin/openvpn +``` + +#### "Permission denied" bei VPN-Verbindung + +VPN-Verbindungen erfordern erhöhte Rechte: + +```bash +# Linux/macOS: Mit sudo starten +sudo python main.py + +# Windows: Als Administrator ausführen +# Rechtsklick → "Als Administrator ausführen" +``` + +#### PyQt6 Fehler auf Linux + +```bash +# Fehlende Qt-Bibliotheken installieren +sudo apt install libxcb-xinerama0 libxkbcommon-x11-0 + +# Oder für Wayland +sudo apt install qtwayland5 +``` + +--- + ## Lizenz Proprietär - Nur für internen Gebrauch. diff --git a/client/requirements.txt b/client/requirements.txt index 0a01b14..dc7571f 100644 --- a/client/requirements.txt +++ b/client/requirements.txt @@ -1,5 +1,6 @@ # PyQt GUI -PyQt6==6.6.1 +PyQt6>=6.4.0,<6.7.0 +PyQt6-Qt6>=6.4.0,<6.7.0 # HTTP Client httpx==0.26.0 diff --git a/client/services/api_client.py b/client/services/api_client.py index becc79a..5a96004 100644 --- a/client/services/api_client.py +++ b/client/services/api_client.py @@ -37,7 +37,7 @@ class APIClient: 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) + self.client = httpx.Client(timeout=30.0, follow_redirects=True) def _headers(self) -> dict: """Get request headers with auth token.""" @@ -64,8 +64,10 @@ class APIClient: data = response.json() self.access_token = data["access_token"] self.refresh_token = data["refresh_token"] + print(f"Login successful, token: {self.access_token[:50]}...") return True - except httpx.HTTPError: + except httpx.HTTPError as e: + print(f"Login error: {e}") return False def logout(self): @@ -100,7 +102,8 @@ class APIClient: def get_gateways(self) -> list[Gateway]: """Get list of accessible gateways.""" try: - data = self._request("GET", "/api/gateways") + print(f"Fetching gateways with token: {self.access_token[:30] if self.access_token else 'NONE'}...") + data = self._request("GET", "/api/gateways/") return [ Gateway( id=g["id"], @@ -113,7 +116,8 @@ class APIClient: ) for g in data ] - except httpx.HTTPError: + except httpx.HTTPError as e: + print(f"Error fetching gateways: {e}") return [] def get_gateways_status(self) -> list[dict]: diff --git a/server/app/api/deps.py b/server/app/api/deps.py index 954c434..0c93f8f 100644 --- a/server/app/api/deps.py +++ b/server/app/api/deps.py @@ -33,7 +33,7 @@ def get_current_user( headers={"WWW-Authenticate": "Bearer"} ) - user_id = payload.get("sub") + user_id = int(payload.get("sub")) # sub is stored as string per JWT spec user = db.query(User).filter(User.id == user_id).first() if not user: diff --git a/server/app/main.py b/server/app/main.py index eb35b0e..b2d04eb 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -100,7 +100,8 @@ app = FastAPI( lifespan=lifespan, docs_url="/api/docs", redoc_url="/api/redoc", - openapi_url="/api/openapi.json" + openapi_url="/api/openapi.json", + redirect_slashes=False # Prevent 307 redirects that lose auth headers ) # Session middleware for web UI diff --git a/server/app/schemas/gateway.py b/server/app/schemas/gateway.py index f5f43d5..972fdaa 100644 --- a/server/app/schemas/gateway.py +++ b/server/app/schemas/gateway.py @@ -18,8 +18,8 @@ class GatewayBase(BaseModel): @field_validator('vpn_subnet') @classmethod def validate_subnet(cls, v: str | None) -> str | None: - if v is None: - return v + if v is None or v == "" or v == "None": + return None try: ipaddress.ip_network(v, strict=False) return v @@ -46,8 +46,8 @@ class GatewayUpdate(BaseModel): @field_validator('vpn_subnet') @classmethod def validate_subnet(cls, v: str | None) -> str | None: - if v is None: - return v + if v is None or v == "" or v == "None": + return None try: ipaddress.ip_network(v, strict=False) return v diff --git a/server/app/templates/users/access.html b/server/app/templates/users/access.html index b39d1ee..7b5e627 100644 --- a/server/app/templates/users/access.html +++ b/server/app/templates/users/access.html @@ -6,7 +6,7 @@ diff --git a/server/app/templates/users/detail.html b/server/app/templates/users/detail.html new file mode 100644 index 0000000..ea155fc --- /dev/null +++ b/server/app/templates/users/detail.html @@ -0,0 +1,145 @@ +{% extends "base.html" %} + +{% block title %}{{ user.username }} - mGuard VPN Manager{% endblock %} + +{% block content %} + + +
+
+

{{ user.username }}

+

{{ user.email }}

+
+
+ + Zurück + + + Bearbeiten + + + Zugriffe verwalten + +
+
+ +
+
+
+
+
Benutzerinformationen
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Benutzername{{ user.username }}
E-Mail{{ user.email }}
Voller Name{{ user.full_name or '-' }}
Rolle + {% if user.role.value == 'super_admin' %} + Super Admin + {% elif user.role.value == 'admin' %} + Admin + {% elif user.role.value == 'technician' %} + Techniker + {% else %} + {{ user.role.value }} + {% endif %} +
Mandant + {% if user.tenant %} + {{ user.tenant.name }} + {% else %} + - (Global) + {% endif %} +
Status + {% if user.is_active %} + Aktiv + {% else %} + Inaktiv + {% endif %} +
Erstellt am{{ user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '-' }}
Letzte Anmeldung{{ user.last_login.strftime('%d.%m.%Y %H:%M') if user.last_login else 'Noch nie' }}
+
+
+
+ +
+
+
+
Gateway-Zugriffe
+
+
+ {% if user.role.value in ['super_admin', 'admin'] %} +
+ + {% if user.role.value == 'super_admin' %} + Super-Admins haben automatisch Zugriff auf alle Gateways aller Mandanten. + {% else %} + Admins haben automatisch Zugriff auf alle Gateways ihres Mandanten. + {% endif %} +
+ {% else %} + {% if user.gateway_access %} +
    + {% for access in user.gateway_access %} +
  • + + {{ access.gateway.name }} + {% if access.gateway.location %} + {{ access.gateway.location }} + {% endif %} + + {% if access.gateway.is_online %} + Online + {% else %} + Offline + {% endif %} +
  • + {% endfor %} +
+ {% else %} +

Keine Gateway-Zugriffe zugewiesen.

+ {% endif %} + + {% endif %} +
+
+
+
+{% endblock %} diff --git a/server/app/templates/users/form.html b/server/app/templates/users/form.html index 3da4f68..86ff621 100644 --- a/server/app/templates/users/form.html +++ b/server/app/templates/users/form.html @@ -25,8 +25,7 @@ + placeholder="benutzername">
@@ -61,13 +60,15 @@
- {% if current_user.is_super_admin and not user %} + {% if current_user.is_super_admin %}
diff --git a/server/app/utils/security.py b/server/app/utils/security.py index f8fecb2..236893c 100644 --- a/server/app/utils/security.py +++ b/server/app/utils/security.py @@ -38,7 +38,7 @@ def create_access_token( expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes) to_encode = { - "sub": user_id, + "sub": str(user_id), # JWT spec requires sub to be a string "username": username, "role": role, "tenant_id": tenant_id, @@ -59,7 +59,7 @@ def create_refresh_token( expire = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days) to_encode = { - "sub": user_id, + "sub": str(user_id), # JWT spec requires sub to be a string "exp": expire, "type": "refresh" } @@ -71,5 +71,8 @@ def decode_token(token: str) -> dict | None: try: payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) return payload - except JWTError: + except JWTError as e: + print(f"JWT decode error: {e}") + print(f"Token (first 50 chars): {token[:50]}...") + print(f"Secret key (first 10 chars): {settings.secret_key[:10]}...") return None diff --git a/server/app/web/users.py b/server/app/web/users.py index 7d4f8b3..a4cbe19 100644 --- a/server/app/web/users.py +++ b/server/app/web/users.py @@ -104,6 +104,35 @@ async def create_user( return RedirectResponse(url="/users", status_code=303) +@router.get("/users/{user_id}", response_class=HTMLResponse) +async def user_detail( + request: Request, + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """User detail page.""" + 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) + + # Check tenant access + if current_user.role != UserRole.SUPER_ADMIN and user.tenant_id != current_user.tenant_id: + flash(request, "Zugriff verweigert", "danger") + return RedirectResponse(url="/users", status_code=303) + + return request.app.state.templates.TemplateResponse( + "users/detail.html", + { + "request": request, + "current_user": current_user, + "user": user, + "flash_messages": get_flashed_messages(request) + } + ) + + @router.get("/users/{user_id}/access", response_class=HTMLResponse) async def user_access( request: Request, @@ -126,10 +155,10 @@ async def user_access( else: gateways = db.query(Gateway).filter(Gateway.tenant_id == current_user.tenant_id).all() - user_access = db.query(UserGatewayAccess).filter( + user_access_list = db.query(UserGatewayAccess).filter( UserGatewayAccess.user_id == user_id ).all() - access_gateway_ids = [a.gateway_id for a in user_access] + access_gateway_ids = [a.gateway_id for a in user_access_list] return request.app.state.templates.TemplateResponse( "users/access.html", @@ -142,3 +171,144 @@ async def user_access( "flash_messages": get_flashed_messages(request) } ) + + +@router.post("/users/{user_id}/access") +async def save_user_access( + request: Request, + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Save 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 form data + form_data = await request.form() + selected_gateway_ids = [int(gw_id) for gw_id in form_data.getlist("gateway_ids")] + + # Get available gateways + if current_user.role == UserRole.SUPER_ADMIN: + available_gateways = db.query(Gateway).all() + else: + available_gateways = db.query(Gateway).filter(Gateway.tenant_id == current_user.tenant_id).all() + available_gateway_ids = [g.id for g in available_gateways] + + # Remove existing access for available gateways + db.query(UserGatewayAccess).filter( + UserGatewayAccess.user_id == user_id, + UserGatewayAccess.gateway_id.in_(available_gateway_ids) + ).delete(synchronize_session=False) + + # Add new access + for gateway_id in selected_gateway_ids: + if gateway_id in available_gateway_ids: + access = UserGatewayAccess( + user_id=user_id, + gateway_id=gateway_id, + granted_by_id=current_user.id + ) + db.add(access) + + db.commit() + + flash(request, f"Zugriffe für '{user.username}' aktualisiert", "success") + return RedirectResponse(url=f"/users/{user_id}/access", status_code=303) + + +@router.get("/users/{user_id}/edit", response_class=HTMLResponse) +async def edit_user_form( + request: Request, + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Edit user form.""" + from ..models.tenant import Tenant + + 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) + + # Check tenant access + if current_user.role != UserRole.SUPER_ADMIN and user.tenant_id != current_user.tenant_id: + flash(request, "Zugriff verweigert", "danger") + return RedirectResponse(url="/users", status_code=303) + + 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": user, + "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/{user_id}/edit") +async def update_user( + request: Request, + user_id: int, + username: str = Form(...), + email: str = Form(...), + password: str = Form(None), + role: str = Form(...), + full_name: str = Form(None), + tenant_id: int = Form(None), + is_active: str = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_web) +): + """Update user.""" + 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) + + # Check tenant access + if current_user.role != UserRole.SUPER_ADMIN and user.tenant_id != current_user.tenant_id: + flash(request, "Zugriff verweigert", "danger") + return RedirectResponse(url="/users", status_code=303) + + # Check if username is taken by another user + existing = db.query(User).filter(User.username == username, User.id != user_id).first() + if existing: + flash(request, "Benutzername bereits vergeben", "danger") + return RedirectResponse(url=f"/users/{user_id}/edit", status_code=303) + + # Check if email is taken by another user + existing = db.query(User).filter(User.email == email, User.id != user_id).first() + if existing: + flash(request, "E-Mail bereits vergeben", "danger") + return RedirectResponse(url=f"/users/{user_id}/edit", status_code=303) + + user.username = username + user.email = email + user.role = UserRole(role) + user.full_name = full_name or None + user.is_active = is_active is not None + + # Only update password if provided + if password: + user.password_hash = get_password_hash(password) + + # Update tenant + if current_user.role == UserRole.SUPER_ADMIN: + user.tenant_id = tenant_id if UserRole(role) != UserRole.SUPER_ADMIN else None + + db.commit() + + flash(request, f"Benutzer '{username}' aktualisiert", "success") + return RedirectResponse(url=f"/users/{user_id}", status_code=303)