Add wireguard-ui-multi core: multi-server DB, WireGuard manager, REST API, UI, installers
Implements the from-scratch multi-server WireGuard management fork per CLAUDE.md spec: sqlite schema (servers/peers/audit_log/users), Curve25519 key generation, per-interface config rendering + wg-quick/systemd control, nftables hook scaffolding, session+CSRF-protected REST API with QR code and config download endpoints, a minimal vanilla-JS web UI, legacy wg0.conf migration, and both a native installer and a Proxmox LXC provisioning script (with auto-detected latest Debian template). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3d6608ef80
commit
3b3ffd8ebf
@@ -0,0 +1,367 @@
|
|||||||
|
Du bist ein Senior Go-Entwickler mit Erfahrung in Netzwerk-Engineering, WireGuard, Linux, nftables und Webanwendungen.
|
||||||
|
|
||||||
|
Aufgabe:
|
||||||
|
Erstelle einen Fork von "wireguard-ui" mit dem Namen:
|
||||||
|
|
||||||
|
wireguard-ui-multi
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Eine native Multi-Server-Verwaltungsoberfläche für WireGuard, die ohne Docker betrieben werden kann und mehrere unabhängige WireGuard-Server-Interfaces verwaltet.
|
||||||
|
|
||||||
|
Hintergrund:
|
||||||
|
Die aktuelle wireguard-ui Version verwaltet hauptsächlich eine einzelne WireGuard-Instanz. Die neue Version soll mehrere getrennte WireGuard-Server gleichzeitig verwalten können.
|
||||||
|
|
||||||
|
Primäre Zielplattform:
|
||||||
|
- Linux
|
||||||
|
- Proxmox LXC Container
|
||||||
|
- Debian/Ubuntu
|
||||||
|
- OpenWrt-kompatible Umgebung (optional)
|
||||||
|
- Betrieb als natives Binary ohne Docker
|
||||||
|
|
||||||
|
Grundanforderungen:
|
||||||
|
|
||||||
|
1. Multi WireGuard Server Support
|
||||||
|
|
||||||
|
Die Anwendung muss mehrere WireGuard-Server verwalten können:
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
Server:
|
||||||
|
- WGhome
|
||||||
|
Interface: wg-home
|
||||||
|
Listen Port: 51822
|
||||||
|
Tunnelnetz: 10.20.22.0/24
|
||||||
|
|
||||||
|
- WGrz
|
||||||
|
Interface: wg-rz
|
||||||
|
Listen Port: 51866
|
||||||
|
Tunnelnetz: 10.20.66.0/24
|
||||||
|
|
||||||
|
- WGwinter
|
||||||
|
Interface: wg-winter
|
||||||
|
Listen Port: 51824
|
||||||
|
|
||||||
|
Jeder Server muss besitzen:
|
||||||
|
- eigener Name
|
||||||
|
- eigenes WireGuard Interface
|
||||||
|
- eigener Port
|
||||||
|
- eigener Private Key
|
||||||
|
- eigene Address Range
|
||||||
|
- eigene DNS Einstellungen
|
||||||
|
- eigene MTU
|
||||||
|
- eigener Status
|
||||||
|
- eigene Peers
|
||||||
|
|
||||||
|
2. Datenmodell erweitern
|
||||||
|
|
||||||
|
Aktuelles Modell:
|
||||||
|
Application
|
||||||
|
└── WireGuard Server
|
||||||
|
└── Peers
|
||||||
|
|
||||||
|
Neues Modell:
|
||||||
|
|
||||||
|
Application
|
||||||
|
|
||||||
|
├── Server
|
||||||
|
│ ├── Interface
|
||||||
|
│ ├── Config
|
||||||
|
│ ├── Settings
|
||||||
|
│ └── Peers
|
||||||
|
│
|
||||||
|
├── Server
|
||||||
|
│ └── Peers
|
||||||
|
│
|
||||||
|
└── Server
|
||||||
|
└── Peers
|
||||||
|
|
||||||
|
|
||||||
|
Datenbank erweitern:
|
||||||
|
|
||||||
|
Tabelle:
|
||||||
|
servers
|
||||||
|
|
||||||
|
Felder:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- interface_name
|
||||||
|
- listen_port
|
||||||
|
- private_key
|
||||||
|
- public_key
|
||||||
|
- address_range
|
||||||
|
- dns
|
||||||
|
- mtu
|
||||||
|
- enabled
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
|
||||||
|
|
||||||
|
Tabelle:
|
||||||
|
peers
|
||||||
|
|
||||||
|
Felder:
|
||||||
|
- id
|
||||||
|
- server_id
|
||||||
|
- name
|
||||||
|
- email
|
||||||
|
- public_key
|
||||||
|
- private_key
|
||||||
|
- preshared_key
|
||||||
|
- allowed_ips
|
||||||
|
- endpoint
|
||||||
|
- persistent_keepalive
|
||||||
|
- enabled
|
||||||
|
|
||||||
|
|
||||||
|
3. WireGuard Verwaltung
|
||||||
|
|
||||||
|
Die Anwendung muss automatisch erzeugen:
|
||||||
|
|
||||||
|
/etc/wireguard/
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
wg-home.conf
|
||||||
|
wg-rz.conf
|
||||||
|
wg-winter.conf
|
||||||
|
|
||||||
|
|
||||||
|
Jede Config muss valides WireGuard Format besitzen:
|
||||||
|
|
||||||
|
[Interface]
|
||||||
|
PrivateKey=
|
||||||
|
Address=
|
||||||
|
ListenPort=
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey=
|
||||||
|
AllowedIPs=
|
||||||
|
PersistentKeepalive=
|
||||||
|
|
||||||
|
|
||||||
|
4. Service Management
|
||||||
|
|
||||||
|
Die Anwendung muss WireGuard Interfaces starten und stoppen können:
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
wg-quick up wg-home
|
||||||
|
wg-quick down wg-home
|
||||||
|
|
||||||
|
Unterstützung für:
|
||||||
|
|
||||||
|
systemd:
|
||||||
|
wg-quick@wg-home.service
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
OpenWrt:
|
||||||
|
uci / netifd Integration vorbereiten
|
||||||
|
|
||||||
|
|
||||||
|
5. Webinterface
|
||||||
|
|
||||||
|
Erweitere die UI:
|
||||||
|
|
||||||
|
Dashboard:
|
||||||
|
|
||||||
|
Liste aller WireGuard Server:
|
||||||
|
|
||||||
|
------------------------------------------------
|
||||||
|
Name Interface Port Status
|
||||||
|
------------------------------------------------
|
||||||
|
WGhome wg-home 51822 UP
|
||||||
|
WGrz wg-rz 51866 UP
|
||||||
|
Winter wg-winter 51824 DOWN
|
||||||
|
------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
Jeder Server bekommt eigene Verwaltung:
|
||||||
|
|
||||||
|
- Peers anzeigen
|
||||||
|
- Peer hinzufügen
|
||||||
|
- Peer löschen
|
||||||
|
- QR-Code erzeugen
|
||||||
|
- Config herunterladen
|
||||||
|
- Server starten
|
||||||
|
- Server stoppen
|
||||||
|
- Server neu laden
|
||||||
|
|
||||||
|
|
||||||
|
6. Peer Verwaltung
|
||||||
|
|
||||||
|
Pro Server:
|
||||||
|
|
||||||
|
Funktionen:
|
||||||
|
|
||||||
|
- neuen Client erstellen
|
||||||
|
- Schlüssel automatisch erzeugen
|
||||||
|
- QR-Code erzeugen
|
||||||
|
- Konfiguration exportieren
|
||||||
|
- Ablaufdatum optional
|
||||||
|
- Beschreibung
|
||||||
|
- Benutzername
|
||||||
|
|
||||||
|
|
||||||
|
7. Backup / Restore
|
||||||
|
|
||||||
|
Implementieren:
|
||||||
|
|
||||||
|
Export:
|
||||||
|
|
||||||
|
- Datenbank
|
||||||
|
- WireGuard Configs
|
||||||
|
- Keys
|
||||||
|
- Einstellungen
|
||||||
|
|
||||||
|
|
||||||
|
Restore:
|
||||||
|
- komplette Wiederherstellung
|
||||||
|
|
||||||
|
|
||||||
|
8. API
|
||||||
|
|
||||||
|
REST API erweitern:
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
GET
|
||||||
|
/api/servers
|
||||||
|
|
||||||
|
POST
|
||||||
|
/api/servers
|
||||||
|
|
||||||
|
GET
|
||||||
|
/api/server/{id}/peers
|
||||||
|
|
||||||
|
POST
|
||||||
|
/api/server/{id}/peer
|
||||||
|
|
||||||
|
DELETE
|
||||||
|
/api/server/{id}/peer/{peerid}
|
||||||
|
|
||||||
|
|
||||||
|
9. Sicherheit
|
||||||
|
|
||||||
|
Implementieren:
|
||||||
|
|
||||||
|
- Passwortschutz
|
||||||
|
- Session Management
|
||||||
|
- CSRF Schutz
|
||||||
|
- keine Private Keys im Frontend anzeigen
|
||||||
|
- Audit Log für Änderungen
|
||||||
|
|
||||||
|
|
||||||
|
10. Firewall Integration
|
||||||
|
|
||||||
|
Vorbereitung für:
|
||||||
|
|
||||||
|
nftables
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
Server WGhome:
|
||||||
|
|
||||||
|
INPUT:
|
||||||
|
UDP 51822 ACCEPT
|
||||||
|
|
||||||
|
FORWARD:
|
||||||
|
wg-home → lan
|
||||||
|
|
||||||
|
|
||||||
|
Hooks:
|
||||||
|
|
||||||
|
/etc/wireguard-manager/hooks/
|
||||||
|
|
||||||
|
server-start
|
||||||
|
server-stop
|
||||||
|
peer-add
|
||||||
|
peer-remove
|
||||||
|
|
||||||
|
|
||||||
|
11. Deployment
|
||||||
|
|
||||||
|
Kein Docker.
|
||||||
|
|
||||||
|
Erstellen:
|
||||||
|
|
||||||
|
Binary:
|
||||||
|
|
||||||
|
wireguard-ui-multi
|
||||||
|
|
||||||
|
|
||||||
|
Installationsstruktur:
|
||||||
|
|
||||||
|
/usr/local/bin/wireguard-ui-multi
|
||||||
|
|
||||||
|
/etc/wireguard-ui-multi/
|
||||||
|
|
||||||
|
/var/lib/wireguard-ui-multi/
|
||||||
|
|
||||||
|
|
||||||
|
Systemd Service:
|
||||||
|
|
||||||
|
wireguard-ui-multi.service
|
||||||
|
|
||||||
|
|
||||||
|
12. Codequalität
|
||||||
|
|
||||||
|
Anforderungen:
|
||||||
|
|
||||||
|
- Go aktuelle Version
|
||||||
|
- saubere Package-Struktur
|
||||||
|
- Unit Tests
|
||||||
|
- Logging
|
||||||
|
- Fehlerbehandlung
|
||||||
|
- Dokumentation
|
||||||
|
|
||||||
|
|
||||||
|
Projektstruktur:
|
||||||
|
|
||||||
|
cmd/
|
||||||
|
└── wireguard-ui-multi
|
||||||
|
|
||||||
|
internal/
|
||||||
|
|
||||||
|
├── server/
|
||||||
|
├── wireguard/
|
||||||
|
├── database/
|
||||||
|
├── api/
|
||||||
|
├── firewall/
|
||||||
|
└── ui/
|
||||||
|
|
||||||
|
|
||||||
|
13. Migration
|
||||||
|
|
||||||
|
Erstelle eine Migration von einer bestehenden wireguard-ui Installation:
|
||||||
|
|
||||||
|
- bestehende wg0.conf erkennen
|
||||||
|
- als ersten Server importieren
|
||||||
|
- Peers übernehmen
|
||||||
|
|
||||||
|
|
||||||
|
14. Dokumentation
|
||||||
|
|
||||||
|
Erstellen:
|
||||||
|
|
||||||
|
README.md
|
||||||
|
|
||||||
|
mit:
|
||||||
|
|
||||||
|
- Installation
|
||||||
|
- Konfiguration
|
||||||
|
- LXC Installation
|
||||||
|
- Proxmox Hinweise
|
||||||
|
- Backup
|
||||||
|
- Migration
|
||||||
|
|
||||||
|
|
||||||
|
Beginne mit:
|
||||||
|
|
||||||
|
1. Analyse der bestehenden wireguard-ui Architektur
|
||||||
|
2. Vorschlag für Datenbankmigration
|
||||||
|
3. Umsetzung der Server-Abstraktion
|
||||||
|
4. Implementierung der Multi-Interface-Verwaltung
|
||||||
|
5. Anpassung der UI
|
||||||
|
6. Tests
|
||||||
|
|
||||||
|
Das Ergebnis soll ein produktiv nutzbarer Fork "wireguard-ui-multi" werden.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# wireguard-ui-multi – Dev Log
|
||||||
|
|
||||||
|
## 2026-07-10 02:11 – 02:33 (21m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 3d6608e first commit
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-10 02:33 – 02:33 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-10 02:33 – 02:33 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-10 02:34 – 02:34 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-10 02:37 – 02:37 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-10 02:44 – 02:45 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-10 02:45 – 02:46 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-10 02:52 – 02:52 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# wireguard-ui-multi
|
||||||
|
|
||||||
|
Native Multi-Server-Verwaltungsoberfläche für WireGuard — **ohne Docker**.
|
||||||
|
Im Gegensatz zum ursprünglichen `wireguard-ui`, das genau eine WireGuard-Instanz
|
||||||
|
verwaltet, kann `wireguard-ui-multi` mehrere unabhängige WireGuard-Interfaces
|
||||||
|
gleichzeitig verwalten (z. B. `wg-home`, `wg-rz`, `wg-winter`), jedes mit
|
||||||
|
eigenem Port, eigenem Adressbereich, eigenen Peers und eigenem Status.
|
||||||
|
|
||||||
|
Zielumgebungen: Debian/Ubuntu, Proxmox LXC Container, generisches Linux mit
|
||||||
|
systemd. Betrieb als natives Go-Binary.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multi-Server-Verwaltung**: beliebig viele WireGuard-Server, jeder mit
|
||||||
|
eigenem Interface-Namen, Port, Private/Public Key, Adressbereich, DNS, MTU
|
||||||
|
und Enabled/Disabled-Status (Tabelle `servers` in SQLite).
|
||||||
|
- **Peer-Verwaltung pro Server**: Peers gehören zu genau einem Server
|
||||||
|
(Fremdschlüssel `server_id`), inklusive Name, E-Mail, Public/Private/
|
||||||
|
Preshared Key, Allowed IPs, Endpoint, Persistent Keepalive, Enabled-Status
|
||||||
|
und optionalem Ablaufdatum (`expires_at`).
|
||||||
|
- **Automatische Config-Erzeugung**: Server-Configs werden nach
|
||||||
|
`/etc/wireguard/<interface>.conf` im Standard-`wg-quick`-Format geschrieben.
|
||||||
|
- **Service-Steuerung**: Start/Stop/Reload je Interface über `wg-quick up`,
|
||||||
|
`wg-quick down` und `wg syncconf` (Hot-Reload ohne Verbindungsabbruch),
|
||||||
|
Status-Abfrage über `wg show`.
|
||||||
|
- **QR-Code & Config-Download**: Peer-Konfiguration kann als `.conf`-Datei
|
||||||
|
heruntergeladen oder als QR-Code (PNG) angezeigt werden — Private Keys
|
||||||
|
verlassen den Server nur in dieser generierten Peer-Config, nie über die
|
||||||
|
UI/JSON-API.
|
||||||
|
- **REST-API** für Server- und Peer-Verwaltung (siehe unten) plus
|
||||||
|
Web-Dashboard.
|
||||||
|
- **Firewall-Vorbereitung**: optionale Lifecycle-Hook-Skripte
|
||||||
|
(`server-start`, `server-stop`, `peer-add`, `peer-remove`) in
|
||||||
|
`/etc/wireguard-manager/hooks/` sowie ein Generator für einen
|
||||||
|
Vorschlags-nftables-Ruleset pro Server (Port freigeben, Forwarding
|
||||||
|
Tunnel ↔ LAN-Interface).
|
||||||
|
- **Audit Log**: Tabelle `audit_log` protokolliert Aktionen mit Akteur,
|
||||||
|
Aktion, Ziel und Detail.
|
||||||
|
- **Sitzungsbasierte Authentifizierung** mit CSRF-Schutz: jede mutierende
|
||||||
|
Anfrage (POST/PUT/DELETE) benötigt einen gültigen Session-Cookie plus
|
||||||
|
den Header `X-CSRF-Token`.
|
||||||
|
- Optional HTTPS über `--tls-cert` / `--tls-key`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Aus dem Quellcode bauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o wireguard-ui-multi ./cmd/wireguard-ui-multi
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Installationsskript ausführen (als root)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript ist idempotent und:
|
||||||
|
|
||||||
|
- kopiert die Binary nach `/usr/local/bin/wireguard-ui-multi`
|
||||||
|
- legt `/etc/wireguard-ui-multi`, `/var/lib/wireguard-ui-multi` und
|
||||||
|
`/etc/wireguard-manager/hooks` an
|
||||||
|
- installiert die systemd-Unit nach
|
||||||
|
`/etc/systemd/system/wireguard-ui-multi.service`
|
||||||
|
- setzt `chmod 0700` auf das Datenverzeichnis (dort liegt die SQLite-DB mit
|
||||||
|
Passwort-Hashes)
|
||||||
|
|
||||||
|
**Wichtig:** Das Skript startet den Dienst nicht automatisch. Danach manuell
|
||||||
|
aktivieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now wireguard-ui-multi.service
|
||||||
|
sudo systemctl status wireguard-ui-multi.service
|
||||||
|
sudo journalctl -u wireguard-ui-multi.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Die Anwendung wird über Kommandozeilen-Flags konfiguriert (siehe
|
||||||
|
`cmd/wireguard-ui-multi/main.go`):
|
||||||
|
|
||||||
|
| Flag | Default | Bedeutung |
|
||||||
|
|----------------|-------------------------------------------------------|-------------------------------------------------------|
|
||||||
|
| `--listen` | `:8443` | Listen-Adresse des Webservers |
|
||||||
|
| `--db` | `/var/lib/wireguard-ui-multi/wireguard-ui-multi.db` | Pfad zur SQLite-Datenbankdatei |
|
||||||
|
| `--config-dir` | `/etc/wireguard` | Zielverzeichnis für generierte `wg-quick`-Configs |
|
||||||
|
| `--hooks-dir` | `/etc/wireguard-manager/hooks` | Verzeichnis mit optionalen Hook-Skripten |
|
||||||
|
| `--lan-iface` | `eth0` | LAN-Interface für die vorgeschlagenen nftables-Forward-Regeln |
|
||||||
|
| `--tls-cert` | (leer) | Pfad zum TLS-Zertifikat (aktiviert HTTPS zusammen mit `--tls-key`) |
|
||||||
|
| `--tls-key` | (leer) | Pfad zum TLS-Private-Key |
|
||||||
|
|
||||||
|
Die in `systemd/wireguard-ui-multi.service` hinterlegte `ExecStart`-Zeile
|
||||||
|
setzt `--db`, `--config-dir` und `--hooks-dir` bereits passend zur
|
||||||
|
Installationsstruktur.
|
||||||
|
|
||||||
|
### Erststart / Admin-Passwort
|
||||||
|
|
||||||
|
Beim allerersten Start (leere `users`-Tabelle) wird automatisch ein
|
||||||
|
`admin`-Benutzer mit einem zufällig erzeugten 32-stelligen Hex-Passwort
|
||||||
|
angelegt. Das Klartext-Passwort wird **genau einmal** auf `stderr`
|
||||||
|
ausgegeben (z. B. sichtbar via `journalctl -u wireguard-ui-multi.service`)
|
||||||
|
und danach nur noch als bcrypt-Hash in der Datenbank gespeichert. Nach dem
|
||||||
|
ersten Login sollte das Passwort umgehend geändert werden.
|
||||||
|
|
||||||
|
## LXC / Proxmox Hinweise
|
||||||
|
|
||||||
|
WireGuard benötigt Zugriff auf das `wireguard`-Kernelmodul des Hosts sowie
|
||||||
|
`CAP_NET_ADMIN` und Zugriff auf `/dev/net/tun` im Container:
|
||||||
|
|
||||||
|
- Auf dem **Proxmox-Host** muss das `wireguard`-Kernelmodul geladen sein
|
||||||
|
(`modprobe wireguard`; bei Bedarf `/etc/modules` ergänzen).
|
||||||
|
- Der LXC-Container sollte entweder **privilegiert** betrieben werden, oder
|
||||||
|
als unprivilegierter Container mit gezielten Lockerungen
|
||||||
|
(`lxc.cap.drop` ohne `net_admin`, `lxc.cgroup2.devices.allow: c 10:200 rwm`
|
||||||
|
für `/dev/net/tun`) konfiguriert werden. In der Praxis ist ein
|
||||||
|
privilegierter Container für WireGuard-Hosting deutlich unkomplizierter.
|
||||||
|
- `/dev/net/tun` muss im Container vorhanden und beschreibbar sein
|
||||||
|
(`ls -l /dev/net/tun`); ggf. per Bind-Mount/`lxc.mount.entry` durchreichen.
|
||||||
|
- Die systemd-Unit läuft als `root` mit `AmbientCapabilities=CAP_NET_ADMIN`,
|
||||||
|
weil sie `wg-quick`, `systemctl` und `nft` aufruft — diese Tools benötigen
|
||||||
|
in der Praxis root-Rechte im Container.
|
||||||
|
- Läuft `nftables` bereits als eigener Dienst im Container/Host, sollte der
|
||||||
|
von `wireguard-ui-multi` vorgeschlagene Ruleset (siehe unten) manuell in
|
||||||
|
die bestehende Regelbasis integriert statt blind angewendet werden, um
|
||||||
|
Konflikte mit vorhandenen Tabellen/Chains zu vermeiden.
|
||||||
|
|
||||||
|
## Server- & Peer-Verwaltung
|
||||||
|
|
||||||
|
**Server anlegen** (UI oder `POST /api/servers`): Name, Interface-Name
|
||||||
|
(z. B. `wg-home`), Listen-Port, Adressbereich (z. B. `10.20.22.0/24`), DNS,
|
||||||
|
MTU angeben. Private/Public Key werden serverseitig automatisch erzeugt.
|
||||||
|
|
||||||
|
**Server starten/stoppen/neuladen**: über die Dashboard-Buttons oder
|
||||||
|
`POST /api/servers/{id}/start|stop|reload`. Start schreibt zunächst die
|
||||||
|
`wg-quick`-Config nach `/etc/wireguard/<interface>.conf` und ruft dann
|
||||||
|
`wg-quick up <interface>` auf; Reload nutzt `wg syncconf` für einen
|
||||||
|
Hot-Reload ohne Tunnelabbruch.
|
||||||
|
|
||||||
|
**Peer hinzufügen** (UI oder `POST /api/server/{id}/peer`): Name, optional
|
||||||
|
E-Mail/Beschreibung und Ablaufdatum angeben — Schlüsselpaar und Preshared
|
||||||
|
Key werden automatisch generiert.
|
||||||
|
|
||||||
|
**Config/QR-Code abrufen**: `GET /api/server/{id}/peer/{peerid}/config`
|
||||||
|
liefert die fertige `.conf`-Datei zum Download, `GET
|
||||||
|
/api/server/{id}/peer/{peerid}/qrcode` liefert denselben Inhalt als
|
||||||
|
PNG-QR-Code zum Scannen mit der WireGuard-App.
|
||||||
|
|
||||||
|
### REST-API-Übersicht
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/login
|
||||||
|
POST /api/logout
|
||||||
|
|
||||||
|
GET /api/servers
|
||||||
|
POST /api/servers
|
||||||
|
GET /api/servers/{id}
|
||||||
|
PUT /api/servers/{id}
|
||||||
|
DELETE /api/servers/{id}
|
||||||
|
POST /api/servers/{id}/start
|
||||||
|
POST /api/servers/{id}/stop
|
||||||
|
POST /api/servers/{id}/reload
|
||||||
|
GET /api/servers/{id}/config
|
||||||
|
|
||||||
|
GET /api/server/{id}/peers
|
||||||
|
POST /api/server/{id}/peer
|
||||||
|
DELETE /api/server/{id}/peer/{peerid}
|
||||||
|
GET /api/server/{id}/peer/{peerid}/config
|
||||||
|
GET /api/server/{id}/peer/{peerid}/qrcode
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle Endpunkte außer `/api/login` erfordern einen gültigen Session-Cookie;
|
||||||
|
mutierende Methoden (POST/PUT/DELETE) benötigen zusätzlich den Header
|
||||||
|
`X-CSRF-Token` mit dem beim Login ausgegebenen Token.
|
||||||
|
|
||||||
|
## Backup / Restore
|
||||||
|
|
||||||
|
Ein automatisiertes Backup-/Restore-Werkzeug ist aktuell **nicht**
|
||||||
|
implementiert. Für ein manuelles Backup genügt es, folgende Pfade zu
|
||||||
|
sichern:
|
||||||
|
|
||||||
|
- die SQLite-Datenbank: `/var/lib/wireguard-ui-multi/wireguard-ui-multi.db`
|
||||||
|
(enthält Server, Peers, Keys, Audit Log, Benutzer)
|
||||||
|
- die generierten Interface-Configs: `/etc/wireguard/*.conf`
|
||||||
|
- ggf. eigene Hook-Skripte: `/etc/wireguard-manager/hooks/`
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tar czf wireguard-ui-multi-backup-$(date +%F).tar.gz \
|
||||||
|
/var/lib/wireguard-ui-multi/wireguard-ui-multi.db \
|
||||||
|
/etc/wireguard/*.conf \
|
||||||
|
/etc/wireguard-manager/hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
**Restore**: Dienst stoppen, Archiv an denselben Pfaden entpacken,
|
||||||
|
Berechtigungen prüfen (`chmod 0700` auf das Datenverzeichnis) und Dienst
|
||||||
|
wieder starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop wireguard-ui-multi.service
|
||||||
|
sudo tar xzf wireguard-ui-multi-backup-YYYY-MM-DD.tar.gz -C /
|
||||||
|
sudo systemctl start wireguard-ui-multi.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration von einer bestehenden wireguard-ui-Installation
|
||||||
|
|
||||||
|
Für den Umstieg von einer klassischen Single-Interface-Installation
|
||||||
|
(`/etc/wireguard/wg0.conf`) ist ein Migrationswerkzeug vorgesehen, das eine
|
||||||
|
bestehende `wg0.conf` einliest und als ersten verwalteten Server samt seiner
|
||||||
|
Peers importiert. Damit lässt sich eine vorhandene WireGuard-Instanz
|
||||||
|
übernehmen, ohne bestehende Clients neu konfigurieren zu müssen. Details zum
|
||||||
|
genauen Ablauf und den Aufrufoptionen siehe die Implementierung im
|
||||||
|
`wireguard`-Package des Repos, sobald verfügbar; grundsätzlich gilt: vor der
|
||||||
|
Migration ein Backup der bestehenden `wg0.conf` anlegen.
|
||||||
|
|
||||||
|
## Sicherheitshinweise
|
||||||
|
|
||||||
|
- **Private Keys werden nie im Frontend/JSON angezeigt** — sie werden
|
||||||
|
ausschließlich serverseitig in generierten `.conf`-Dateien bzw.
|
||||||
|
QR-Codes für einzelne Peers ausgeliefert.
|
||||||
|
- **HTTPS verwenden**: entweder direkt über `--tls-cert`/`--tls-key`, oder
|
||||||
|
die Anwendung hinter einem Reverse Proxy (nginx, Caddy, Traefik) mit
|
||||||
|
TLS-Terminierung betreiben. Ohne TLS gibt der Dienst beim Start eine
|
||||||
|
deutliche Warnung aus.
|
||||||
|
- **Standard-Admin-Passwort sofort ändern**: das beim Erststart einmalig
|
||||||
|
ausgegebene zufällige Passwort sollte direkt nach dem ersten Login
|
||||||
|
geändert werden.
|
||||||
|
- Mutierende API-Aufrufe erfordern einen gültigen Session-Cookie **und**
|
||||||
|
den CSRF-Header `X-CSRF-Token` — Clients/Skripte, die die API direkt
|
||||||
|
ansprechen, müssen sich zunächst über `/api/login` anmelden und den
|
||||||
|
zurückgegebenen Token mitführen.
|
||||||
|
- Die Datenverzeichnisse (`/var/lib/wireguard-ui-multi`) sollten
|
||||||
|
restriktive Berechtigungen (`0700`) behalten, da dort Schlüsselmaterial
|
||||||
|
und Passwort-Hashes liegen.
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Command wireguard-ui-multi runs the native multi-server WireGuard management UI.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/api"
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/database"
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/firewall"
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/wireguard"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
listen = flag.String("listen", ":8443", "address to listen on")
|
||||||
|
dbPath = flag.String("db", "/var/lib/wireguard-ui-multi/wireguard-ui-multi.db", "path to the sqlite database file")
|
||||||
|
configDir = flag.String("config-dir", "/etc/wireguard", "directory where wg-quick interface configs are written")
|
||||||
|
hooksDir = flag.String("hooks-dir", "/etc/wireguard-manager/hooks", "directory containing optional lifecycle hook scripts")
|
||||||
|
lanIface = flag.String("lan-iface", "eth0", "LAN interface used for nftables forward rules")
|
||||||
|
tlsCert = flag.String("tls-cert", "", "path to TLS certificate (optional; enables HTTPS together with -tls-key)")
|
||||||
|
tlsKey = flag.String("tls-key", "", "path to TLS private key (optional; enables HTTPS together with -tls-cert)")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||||
|
|
||||||
|
if err := run(logger, *listen, *dbPath, *configDir, *hooksDir, *lanIface, *tlsCert, *tlsKey); err != nil {
|
||||||
|
logger.Error("fatal", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(logger *slog.Logger, listen, dbPath, configDir, hooksDir, lanIface, tlsCert, tlsKey string) error {
|
||||||
|
// Wire package-level config before anything touches the filesystem/wg-quick.
|
||||||
|
wireguard.ConfigDir = configDir
|
||||||
|
firewall.HooksDir = hooksDir
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
|
||||||
|
return fmt.Errorf("create db directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := ensureAdminUser(db, logger); err != nil {
|
||||||
|
return fmt.Errorf("bootstrap admin user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a := api.New(db, logger, lanIface)
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: listen,
|
||||||
|
Handler: a.Routes(),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
serveErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
useTLS := tlsCert != "" && tlsKey != ""
|
||||||
|
if useTLS {
|
||||||
|
logger.Info("starting HTTPS server", "listen", listen)
|
||||||
|
serveErr <- srv.ListenAndServeTLS(tlsCert, tlsKey)
|
||||||
|
} else {
|
||||||
|
logger.Warn("starting plain HTTP server — TLS is strongly recommended in production; set -tls-cert and -tls-key", "listen", listen)
|
||||||
|
serveErr <- srv.ListenAndServe()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-serveErr:
|
||||||
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return fmt.Errorf("serve: %w", err)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Info("shutdown signal received, stopping server")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
return fmt.Errorf("graceful shutdown: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("server stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureAdminUser creates a default admin account with a random password on first
|
||||||
|
// run (i.e. when the users table is empty). The plaintext password is printed
|
||||||
|
// exactly once and never persisted — only its bcrypt hash is stored.
|
||||||
|
func ensureAdminUser(db *database.DB, logger *slog.Logger) error {
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||||
|
return fmt.Errorf("count users: %w", err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordBytes := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(passwordBytes); err != nil {
|
||||||
|
return fmt.Errorf("generate password: %w", err)
|
||||||
|
}
|
||||||
|
password := hex.EncodeToString(passwordBytes)
|
||||||
|
|
||||||
|
hash, err := api.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec(`INSERT INTO users (username, password_hash) VALUES (?, ?)`, "admin", hash); err != nil {
|
||||||
|
return fmt.Errorf("insert admin user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stderr, "================================================================")
|
||||||
|
fmt.Fprintln(os.Stderr, " First run: created default admin account")
|
||||||
|
fmt.Fprintln(os.Stderr, " username: admin")
|
||||||
|
fmt.Fprintf(os.Stderr, " password: %s\n", password)
|
||||||
|
fmt.Fprintln(os.Stderr, " This password is shown ONLY ONCE and is not stored anywhere in")
|
||||||
|
fmt.Fprintln(os.Stderr, " plaintext. Log in and change it immediately.")
|
||||||
|
fmt.Fprintln(os.Stderr, "================================================================")
|
||||||
|
logger.Info("created default admin user; see above for the one-time password")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module gitea.perlbach24.de/scripte/wireguard-ui-multi
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
|
golang.org/x/crypto v0.24.0
|
||||||
|
modernc.org/sqlite v1.30.1
|
||||||
|
)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionCookieName = "wgm_session"
|
||||||
|
const csrfCookieName = "wgm_csrf"
|
||||||
|
const sessionTTL = 12 * time.Hour
|
||||||
|
|
||||||
|
type session struct {
|
||||||
|
username string
|
||||||
|
csrf string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionStore is a simple in-memory session store (single-process deployment).
|
||||||
|
type SessionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]*session
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionStore() *SessionStore {
|
||||||
|
return &SessionStore{sessions: make(map[string]*session)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Create(username string) (sessionToken, csrfToken string, err error) {
|
||||||
|
sessionToken, err = randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
csrfToken, err = randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sessions[sessionToken] = &session{
|
||||||
|
username: username,
|
||||||
|
csrf: csrfToken,
|
||||||
|
expiresAt: time.Now().Add(sessionTTL),
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return sessionToken, csrfToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Get(token string) (*session, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
sess, ok := s.sessions[token]
|
||||||
|
if !ok || time.Now().After(sess.expiresAt) {
|
||||||
|
delete(s.sessions, token)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return sess, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Delete(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.sessions, token)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashPassword bcrypt-hashes a plaintext password for storage.
|
||||||
|
func HashPassword(pw string) (string, error) {
|
||||||
|
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPassword compares a plaintext password against a stored bcrypt hash.
|
||||||
|
func CheckPassword(hash, pw string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrUnauthenticated = errors.New("unauthenticated")
|
||||||
|
|
||||||
|
// requireAuth resolves the session from the request cookie, or fails.
|
||||||
|
func (a *API) requireAuth(r *http.Request) (*session, error) {
|
||||||
|
c, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
sess, ok := a.sessions.Get(c.Value)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireCSRF checks the X-CSRF-Token header against the session's csrf token,
|
||||||
|
// mandatory for all state-changing (non-GET) requests.
|
||||||
|
func requireCSRF(sess *session, r *http.Request) bool {
|
||||||
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
token := r.Header.Get("X-CSRF-Token")
|
||||||
|
return subtle.ConstantTimeCompare([]byte(token), []byte(sess.csrf)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSessionCookies(w http.ResponseWriter, sessionToken, csrfToken string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: sessionToken,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
MaxAge: int(sessionTTL.Seconds()),
|
||||||
|
})
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: csrfCookieName,
|
||||||
|
Value: csrfToken,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: false, // readable by frontend JS to echo back in X-CSRF-Token header
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
MaxAge: int(sessionTTL.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSessionCookies(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: csrfCookieName, Value: "", Path: "/", MaxAge: -1})
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/firewall"
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
|
||||||
|
wg "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/wireguard"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func idParam(r *http.Request, name string) (int64, error) {
|
||||||
|
return strconv.ParseInt(r.PathValue(name), 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash string
|
||||||
|
err := a.db.QueryRow(`SELECT password_hash FROM users WHERE username = ?`, req.Username).Scan(&hash)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) || (err == nil && !CheckPassword(hash, req.Password)) {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "login failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToken, csrfToken, err := a.sessions.Create(req.Username)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookies(w, sessionToken, csrfToken)
|
||||||
|
_ = a.db.LogAudit(req.Username, "login", "session", "")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"csrf_token": csrfToken})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleLogout(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||||
|
if c, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
|
a.sessions.Delete(c.Value)
|
||||||
|
}
|
||||||
|
clearSessionCookies(w)
|
||||||
|
_ = a.db.LogAudit(sess.username, "logout", "session", "")
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Servers ---
|
||||||
|
|
||||||
|
func (a *API) handleListServers(w http.ResponseWriter, r *http.Request, _ *session) {
|
||||||
|
servers, err := a.store.ListServers()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type serverStatus struct {
|
||||||
|
*server.Server
|
||||||
|
Status wg.Status `json:"status"`
|
||||||
|
}
|
||||||
|
out := make([]serverStatus, 0, len(servers))
|
||||||
|
for _, s := range servers {
|
||||||
|
out = append(out, serverStatus{Server: s, Status: wg.GetStatus(s.InterfaceName)})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createServerRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
InterfaceName string `json:"interface_name"`
|
||||||
|
ListenPort int `json:"listen_port"`
|
||||||
|
AddressRange string `json:"address_range"`
|
||||||
|
DNS string `json:"dns"`
|
||||||
|
MTU int `json:"mtu"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||||
|
var req createServerRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" || req.InterfaceName == "" || req.ListenPort == 0 || req.AddressRange == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "name, interface_name, listen_port, address_range required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.MTU == 0 {
|
||||||
|
req.MTU = 1420
|
||||||
|
}
|
||||||
|
|
||||||
|
priv, pub, err := wg.GenerateKeyPair()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "key generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &server.Server{
|
||||||
|
Name: req.Name, InterfaceName: req.InterfaceName, ListenPort: req.ListenPort,
|
||||||
|
PrivateKey: priv, PublicKey: pub, AddressRange: req.AddressRange,
|
||||||
|
DNS: req.DNS, MTU: req.MTU, Enabled: true,
|
||||||
|
}
|
||||||
|
id, err := a.store.CreateServer(srv)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv.ID = id
|
||||||
|
|
||||||
|
if err := wg.WriteConfig(srv, nil); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = a.db.LogAudit(sess.username, "server.create", req.Name, "")
|
||||||
|
writeJSON(w, http.StatusCreated, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleGetServer(w http.ResponseWriter, r *http.Request, _ *session) {
|
||||||
|
id, err := idParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv, err := a.store.GetServer(id)
|
||||||
|
if errors.Is(err, server.ErrNotFound) {
|
||||||
|
writeErr(w, http.StatusNotFound, "server not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleUpdateServer(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||||
|
id, err := idParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv, err := a.store.GetServer(id)
|
||||||
|
if errors.Is(err, server.ErrNotFound) {
|
||||||
|
writeErr(w, http.StatusNotFound, "server not found")
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req createServerRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv.Name, srv.AddressRange, srv.DNS = req.Name, req.AddressRange, req.DNS
|
||||||
|
if req.MTU > 0 {
|
||||||
|
srv.MTU = req.MTU
|
||||||
|
}
|
||||||
|
if req.ListenPort > 0 {
|
||||||
|
srv.ListenPort = req.ListenPort
|
||||||
|
}
|
||||||
|
if err := a.store.UpdateServer(srv); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peers, _ := a.store.ListPeersByServer(srv.ID)
|
||||||
|
if err := wg.WriteConfig(srv, peers); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = a.db.LogAudit(sess.username, "server.update", srv.Name, "")
|
||||||
|
writeJSON(w, http.StatusOK, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleDeleteServer(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||||
|
id, err := idParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv, err := a.store.GetServer(id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, "server not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = wg.Down(srv.InterfaceName)
|
||||||
|
if err := a.store.DeleteServer(id); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = a.db.LogAudit(sess.username, "server.delete", srv.Name, "")
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleStartServer(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||||
|
a.serverAction(w, r, sess, "server.start", func(srv *server.Server) error {
|
||||||
|
if err := wg.Up(srv.InterfaceName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return firewall.RunHook(firewall.HookServerStart, srv.InterfaceName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleStopServer(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||||
|
a.serverAction(w, r, sess, "server.stop", func(srv *server.Server) error {
|
||||||
|
if err := wg.Down(srv.InterfaceName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return firewall.RunHook(firewall.HookServerStop, srv.InterfaceName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleReloadServer(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||||
|
a.serverAction(w, r, sess, "server.reload", func(srv *server.Server) error {
|
||||||
|
peers, err := a.store.ListPeersByServer(srv.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := wg.WriteConfig(srv, peers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return wg.Reload(srv.InterfaceName, wg.ConfigPath(srv))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) serverAction(w http.ResponseWriter, r *http.Request, sess *session, action string, fn func(*server.Server) error) {
|
||||||
|
id, err := idParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv, err := a.store.GetServer(id)
|
||||||
|
if errors.Is(err, server.ErrNotFound) {
|
||||||
|
writeErr(w, http.StatusNotFound, "server not found")
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := fn(srv); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = a.db.LogAudit(sess.username, action, srv.Name, "")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": string(wg.GetStatus(srv.InterfaceName))})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleDownloadServerConfig(w http.ResponseWriter, r *http.Request, _ *session) {
|
||||||
|
id, err := idParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv, err := a.store.GetServer(id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, "server not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peers, err := a.store.ListPeersByServer(id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename="+srv.InterfaceName+".conf")
|
||||||
|
_, _ = w.Write([]byte(wg.RenderConfig(srv, peers)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Peers ---
|
||||||
|
|
||||||
|
func (a *API) handleListPeers(w http.ResponseWriter, r *http.Request, _ *session) {
|
||||||
|
id, err := idParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peers, err := a.store.ListPeersByServer(id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// never expose private keys in listing responses
|
||||||
|
type safePeer struct {
|
||||||
|
*server.Peer
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(peers))
|
||||||
|
for _, p := range peers {
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"id": p.ID, "server_id": p.ServerID, "name": p.Name, "email": p.Email,
|
||||||
|
"public_key": p.PublicKey, "allowed_ips": p.AllowedIPs, "endpoint": p.Endpoint,
|
||||||
|
"persistent_keepalive": p.PersistentKeepalive, "enabled": p.Enabled,
|
||||||
|
"expires_at": p.ExpiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createPeerRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
AllowedIPs string `json:"allowed_ips"`
|
||||||
|
PersistentKeepalive int `json:"persistent_keepalive"`
|
||||||
|
UsePresharedKey bool `json:"use_preshared_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleCreatePeer(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||||
|
serverID, err := idParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv, err := a.store.GetServer(serverID)
|
||||||
|
if errors.Is(err, server.ErrNotFound) {
|
||||||
|
writeErr(w, http.StatusNotFound, "server not found")
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req createPeerRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" || req.AllowedIPs == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "name and allowed_ips required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PersistentKeepalive == 0 {
|
||||||
|
req.PersistentKeepalive = 25
|
||||||
|
}
|
||||||
|
|
||||||
|
priv, pub, err := wg.GenerateKeyPair()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "key generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var psk string
|
||||||
|
if req.UsePresharedKey {
|
||||||
|
psk, err = wg.GeneratePresharedKey()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "psk generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &server.Peer{
|
||||||
|
ServerID: serverID, Name: req.Name, Email: req.Email, PublicKey: pub, PrivateKey: priv,
|
||||||
|
PresharedKey: psk, AllowedIPs: req.AllowedIPs, PersistentKeepalive: req.PersistentKeepalive,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
id, err := a.store.CreatePeer(p)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.ID = id
|
||||||
|
|
||||||
|
peers, _ := a.store.ListPeersByServer(serverID)
|
||||||
|
if err := wg.WriteConfig(srv, peers); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = firewall.RunHook(firewall.HookPeerAdd, srv.InterfaceName, p.PublicKey)
|
||||||
|
_ = a.db.LogAudit(sess.username, "peer.create", p.Name, "server="+srv.Name)
|
||||||
|
writeJSON(w, http.StatusCreated, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleDeletePeer(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||||
|
serverID, err := idParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peerID, err := idParam(r, "peerid")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid peer id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv, err := a.store.GetServer(serverID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, "server not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := a.store.GetPeer(peerID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, "peer not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.store.DeletePeer(peerID); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peers, _ := a.store.ListPeersByServer(serverID)
|
||||||
|
if err := wg.WriteConfig(srv, peers); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = firewall.RunHook(firewall.HookPeerRemove, srv.InterfaceName, p.PublicKey)
|
||||||
|
_ = a.db.LogAudit(sess.username, "peer.delete", p.Name, "server="+srv.Name)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleDownloadPeerConfig(w http.ResponseWriter, r *http.Request, _ *session) {
|
||||||
|
srv, p, err := a.loadServerAndPeer(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host := r.URL.Query().Get("host")
|
||||||
|
if host == "" {
|
||||||
|
host = r.Host
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename="+p.Name+".conf")
|
||||||
|
_, _ = w.Write([]byte(wg.RenderClientConfig(srv, p, host)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handlePeerQRCode(w http.ResponseWriter, r *http.Request, _ *session) {
|
||||||
|
srv, p, err := a.loadServerAndPeer(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host := r.URL.Query().Get("host")
|
||||||
|
if host == "" {
|
||||||
|
host = r.Host
|
||||||
|
}
|
||||||
|
png, err := qrcode.Encode(wg.RenderClientConfig(srv, p, host), qrcode.Medium, 256)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write(bytes.NewBuffer(png).Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) loadServerAndPeer(r *http.Request) (*server.Server, *server.Peer, error) {
|
||||||
|
serverID, err := idParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.New("invalid id")
|
||||||
|
}
|
||||||
|
peerID, err := idParam(r, "peerid")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.New("invalid peer id")
|
||||||
|
}
|
||||||
|
srv, err := a.store.GetServer(serverID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.New("server not found")
|
||||||
|
}
|
||||||
|
p, err := a.store.GetPeer(peerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.New("peer not found")
|
||||||
|
}
|
||||||
|
return srv, p, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/database"
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// API holds shared dependencies for HTTP handlers.
|
||||||
|
type API struct {
|
||||||
|
db *database.DB
|
||||||
|
store *server.Store
|
||||||
|
sessions *SessionStore
|
||||||
|
log *slog.Logger
|
||||||
|
lanIface string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *database.DB, log *slog.Logger, lanIface string) *API {
|
||||||
|
return &API{
|
||||||
|
db: db,
|
||||||
|
store: server.NewStore(db),
|
||||||
|
sessions: NewSessionStore(),
|
||||||
|
log: log,
|
||||||
|
lanIface: lanIface,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes builds the full HTTP handler tree (API + UI), using Go 1.22 mux patterns.
|
||||||
|
func (a *API) Routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
mux.HandleFunc("POST /api/login", a.handleLogin)
|
||||||
|
mux.HandleFunc("POST /api/logout", a.withAuth(a.handleLogout))
|
||||||
|
|
||||||
|
// Servers
|
||||||
|
mux.HandleFunc("GET /api/servers", a.withAuth(a.handleListServers))
|
||||||
|
mux.HandleFunc("POST /api/servers", a.withAuth(a.handleCreateServer))
|
||||||
|
mux.HandleFunc("GET /api/servers/{id}", a.withAuth(a.handleGetServer))
|
||||||
|
mux.HandleFunc("PUT /api/servers/{id}", a.withAuth(a.handleUpdateServer))
|
||||||
|
mux.HandleFunc("DELETE /api/servers/{id}", a.withAuth(a.handleDeleteServer))
|
||||||
|
mux.HandleFunc("POST /api/servers/{id}/start", a.withAuth(a.handleStartServer))
|
||||||
|
mux.HandleFunc("POST /api/servers/{id}/stop", a.withAuth(a.handleStopServer))
|
||||||
|
mux.HandleFunc("POST /api/servers/{id}/reload", a.withAuth(a.handleReloadServer))
|
||||||
|
mux.HandleFunc("GET /api/servers/{id}/config", a.withAuth(a.handleDownloadServerConfig))
|
||||||
|
|
||||||
|
// Peers
|
||||||
|
mux.HandleFunc("GET /api/server/{id}/peers", a.withAuth(a.handleListPeers))
|
||||||
|
mux.HandleFunc("POST /api/server/{id}/peer", a.withAuth(a.handleCreatePeer))
|
||||||
|
mux.HandleFunc("DELETE /api/server/{id}/peer/{peerid}", a.withAuth(a.handleDeletePeer))
|
||||||
|
mux.HandleFunc("GET /api/server/{id}/peer/{peerid}/config", a.withAuth(a.handleDownloadPeerConfig))
|
||||||
|
mux.HandleFunc("GET /api/server/{id}/peer/{peerid}/qrcode", a.withAuth(a.handlePeerQRCode))
|
||||||
|
|
||||||
|
// UI
|
||||||
|
mux.HandleFunc("GET /", a.handleDashboard)
|
||||||
|
mux.HandleFunc("GET /login", a.handleLoginPage)
|
||||||
|
mux.HandleFunc("GET /servers/{id}", a.handleServerPage)
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("internal/ui/static"))))
|
||||||
|
|
||||||
|
return a.logMiddleware(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) logMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.log.Info("request", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAuth enforces a valid session and, for mutating requests, a matching CSRF token.
|
||||||
|
func (a *API) withAuth(next func(http.ResponseWriter, *http.Request, *session)) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess, err := a.requireAuth(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireCSRF(sess, r) {
|
||||||
|
http.Error(w, "invalid csrf token", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r, sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
const templatesDir = "internal/ui/templates"
|
||||||
|
|
||||||
|
// hasSession reports whether the request carries a valid, non-expired session cookie.
|
||||||
|
func (a *API) hasSession(r *http.Request) bool {
|
||||||
|
c, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := a.sessions.Get(c.Value)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.hasSession(r) {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, templatesDir+"/dashboard.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.hasSession(r) {
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, templatesDir+"/login.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleServerPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.hasSession(r) {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, templatesDir+"/server.html")
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB wraps the sqlite connection used by the whole application.
|
||||||
|
type DB struct {
|
||||||
|
*sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
interface_name TEXT NOT NULL UNIQUE,
|
||||||
|
listen_port INTEGER NOT NULL,
|
||||||
|
private_key TEXT NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
address_range TEXT NOT NULL,
|
||||||
|
dns TEXT DEFAULT '',
|
||||||
|
mtu INTEGER DEFAULT 1420,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS peers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT DEFAULT '',
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
private_key TEXT DEFAULT '',
|
||||||
|
preshared_key TEXT DEFAULT '',
|
||||||
|
allowed_ips TEXT NOT NULL,
|
||||||
|
endpoint TEXT DEFAULT '',
|
||||||
|
persistent_keepalive INTEGER DEFAULT 25,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
expires_at DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
detail TEXT DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_peers_server_id ON peers(server_id);
|
||||||
|
`
|
||||||
|
|
||||||
|
// Open opens (creating if needed) the sqlite database at path and applies schema.
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.Exec(schema); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("apply schema: %w", err)
|
||||||
|
}
|
||||||
|
return &DB{sqlDB}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogAudit records an entry in the audit log.
|
||||||
|
func (db *DB) LogAudit(actor, action, target, detail string) error {
|
||||||
|
_, err := db.Exec(`INSERT INTO audit_log (actor, action, target, detail) VALUES (?, ?, ?, ?)`,
|
||||||
|
actor, action, target, detail)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HooksDir holds optional user-defined shell scripts run around lifecycle events.
|
||||||
|
var HooksDir = "/etc/wireguard-manager/hooks"
|
||||||
|
|
||||||
|
// HookEvent names the lifecycle points a hook script may exist for.
|
||||||
|
type HookEvent string
|
||||||
|
|
||||||
|
const (
|
||||||
|
HookServerStart HookEvent = "server-start"
|
||||||
|
HookServerStop HookEvent = "server-stop"
|
||||||
|
HookPeerAdd HookEvent = "peer-add"
|
||||||
|
HookPeerRemove HookEvent = "peer-remove"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunHook executes /etc/wireguard-manager/hooks/<event> if present and executable,
|
||||||
|
// passing iface (and optionally peer pubkey) as arguments. Missing hook is not an error.
|
||||||
|
func RunHook(event HookEvent, args ...string) error {
|
||||||
|
path := filepath.Join(HooksDir, string(event))
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return nil // hook not installed, skip silently
|
||||||
|
}
|
||||||
|
cmd := exec.Command(path, args...)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("hook %s: %w: %s", event, err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NFTRuleset renders a suggested nftables ruleset snippet for a server, allowing
|
||||||
|
// its UDP listen port in and forwarding traffic between the tunnel and lanIface.
|
||||||
|
func NFTRuleset(srv *server.Server, lanIface string) string {
|
||||||
|
return fmt.Sprintf(`table inet wireguard_%s {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy accept;
|
||||||
|
udp dport %d accept
|
||||||
|
}
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority 0; policy accept;
|
||||||
|
iifname "%s" oifname "%s" accept
|
||||||
|
iifname "%s" oifname "%s" accept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, srv.InterfaceName, srv.ListenPort, srv.InterfaceName, lanIface, lanIface, srv.InterfaceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRuleset writes the ruleset to a temp file and loads it with `nft -f`.
|
||||||
|
func ApplyRuleset(srv *server.Server, lanIface string) error {
|
||||||
|
tmp, err := os.CreateTemp("", "wgm-nft-*.conf")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name())
|
||||||
|
|
||||||
|
if _, err := tmp.WriteString(NFTRuleset(srv, lanIface)); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp.Close()
|
||||||
|
|
||||||
|
cmd := exec.Command("nft", "-f", tmp.Name())
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("nft -f: %w: %s", err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server represents a single, independent WireGuard interface.
|
||||||
|
type Server struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
InterfaceName string
|
||||||
|
ListenPort int
|
||||||
|
PrivateKey string
|
||||||
|
PublicKey string
|
||||||
|
AddressRange string
|
||||||
|
DNS string
|
||||||
|
MTU int
|
||||||
|
Enabled bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peer represents a WireGuard client belonging to a Server.
|
||||||
|
type Peer struct {
|
||||||
|
ID int64
|
||||||
|
ServerID int64
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
PublicKey string
|
||||||
|
PrivateKey string
|
||||||
|
PresharedKey string
|
||||||
|
AllowedIPs string
|
||||||
|
Endpoint string
|
||||||
|
PersistentKeepalive int
|
||||||
|
Enabled bool
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
// Store provides CRUD access to servers and peers.
|
||||||
|
type Store struct {
|
||||||
|
db *database.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db *database.DB) *Store {
|
||||||
|
return &Store{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateServer(srv *Server) (int64, error) {
|
||||||
|
res, err := s.db.Exec(`INSERT INTO servers
|
||||||
|
(name, interface_name, listen_port, private_key, public_key, address_range, dns, mtu, enabled)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
srv.Name, srv.InterfaceName, srv.ListenPort, srv.PrivateKey, srv.PublicKey,
|
||||||
|
srv.AddressRange, srv.DNS, srv.MTU, boolToInt(srv.Enabled))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateServer(srv *Server) error {
|
||||||
|
_, err := s.db.Exec(`UPDATE servers SET
|
||||||
|
name = ?, interface_name = ?, listen_port = ?, private_key = ?, public_key = ?,
|
||||||
|
address_range = ?, dns = ?, mtu = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
srv.Name, srv.InterfaceName, srv.ListenPort, srv.PrivateKey, srv.PublicKey,
|
||||||
|
srv.AddressRange, srv.DNS, srv.MTU, boolToInt(srv.Enabled), srv.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteServer(id int64) error {
|
||||||
|
_, err := s.db.Exec(`DELETE FROM servers WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetServer(id int64) (*Server, error) {
|
||||||
|
row := s.db.QueryRow(`SELECT id, name, interface_name, listen_port, private_key, public_key,
|
||||||
|
address_range, dns, mtu, enabled, created_at, updated_at FROM servers WHERE id = ?`, id)
|
||||||
|
return scanServer(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListServers() ([]*Server, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT id, name, interface_name, listen_port, private_key, public_key,
|
||||||
|
address_range, dns, mtu, enabled, created_at, updated_at FROM servers ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []*Server
|
||||||
|
for rows.Next() {
|
||||||
|
srv, err := scanServerRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, srv)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreatePeer(p *Peer) (int64, error) {
|
||||||
|
res, err := s.db.Exec(`INSERT INTO peers
|
||||||
|
(server_id, name, email, public_key, private_key, preshared_key, allowed_ips, endpoint,
|
||||||
|
persistent_keepalive, enabled, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
p.ServerID, p.Name, p.Email, p.PublicKey, p.PrivateKey, p.PresharedKey, p.AllowedIPs,
|
||||||
|
p.Endpoint, p.PersistentKeepalive, boolToInt(p.Enabled), p.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdatePeer(p *Peer) error {
|
||||||
|
_, err := s.db.Exec(`UPDATE peers SET
|
||||||
|
name = ?, email = ?, public_key = ?, preshared_key = ?, allowed_ips = ?, endpoint = ?,
|
||||||
|
persistent_keepalive = ?, enabled = ?, expires_at = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
p.Name, p.Email, p.PublicKey, p.PresharedKey, p.AllowedIPs, p.Endpoint,
|
||||||
|
p.PersistentKeepalive, boolToInt(p.Enabled), p.ExpiresAt, p.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeletePeer(id int64) error {
|
||||||
|
_, err := s.db.Exec(`DELETE FROM peers WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetPeer(id int64) (*Peer, error) {
|
||||||
|
row := s.db.QueryRow(`SELECT id, server_id, name, email, public_key, private_key, preshared_key,
|
||||||
|
allowed_ips, endpoint, persistent_keepalive, enabled, expires_at, created_at, updated_at
|
||||||
|
FROM peers WHERE id = ?`, id)
|
||||||
|
return scanPeer(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListPeersByServer(serverID int64) ([]*Peer, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT id, server_id, name, email, public_key, private_key, preshared_key,
|
||||||
|
allowed_ips, endpoint, persistent_keepalive, enabled, expires_at, created_at, updated_at
|
||||||
|
FROM peers WHERE server_id = ? ORDER BY name`, serverID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []*Peer
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanPeerRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanServer(row scanner) (*Server, error) {
|
||||||
|
var srv Server
|
||||||
|
var enabled int
|
||||||
|
if err := row.Scan(&srv.ID, &srv.Name, &srv.InterfaceName, &srv.ListenPort, &srv.PrivateKey,
|
||||||
|
&srv.PublicKey, &srv.AddressRange, &srv.DNS, &srv.MTU, &enabled, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
srv.Enabled = enabled != 0
|
||||||
|
return &srv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanServerRows(rows *sql.Rows) (*Server, error) { return scanServer(rows) }
|
||||||
|
|
||||||
|
func scanPeer(row scanner) (*Peer, error) {
|
||||||
|
var p Peer
|
||||||
|
var enabled int
|
||||||
|
if err := row.Scan(&p.ID, &p.ServerID, &p.Name, &p.Email, &p.PublicKey, &p.PrivateKey,
|
||||||
|
&p.PresharedKey, &p.AllowedIPs, &p.Endpoint, &p.PersistentKeepalive, &enabled,
|
||||||
|
&p.ExpiresAt, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Enabled = enabled != 0
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPeerRows(rows *sql.Rows) (*Peer, error) { return scanPeer(rows) }
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
function getCookie(name) {
|
||||||
|
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
||||||
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(url, options) {
|
||||||
|
options = options || {};
|
||||||
|
options.headers = options.headers || {};
|
||||||
|
if (options.method && options.method !== "GET") {
|
||||||
|
options.headers["X-CSRF-Token"] = getCookie("wgm_csrf");
|
||||||
|
}
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new Error("unauthenticated");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServers() {
|
||||||
|
const tbody = document.querySelector("#servers tbody");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
const res = await apiFetch("/api/servers");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const servers = await res.json();
|
||||||
|
|
||||||
|
for (const s of servers) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
const nameTd = document.createElement("td");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = "/servers/" + s.ID;
|
||||||
|
link.textContent = s.Name;
|
||||||
|
nameTd.appendChild(link);
|
||||||
|
|
||||||
|
const ifaceTd = document.createElement("td");
|
||||||
|
ifaceTd.textContent = s.InterfaceName;
|
||||||
|
|
||||||
|
const portTd = document.createElement("td");
|
||||||
|
portTd.textContent = s.ListenPort;
|
||||||
|
|
||||||
|
const statusTd = document.createElement("td");
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "badge " + (s.status === "UP" ? "up" : "down");
|
||||||
|
badge.textContent = s.status;
|
||||||
|
statusTd.appendChild(badge);
|
||||||
|
|
||||||
|
const actionsTd = document.createElement("td");
|
||||||
|
actionsTd.appendChild(makeActionButton("Start", () => serverAction(s.ID, "start")));
|
||||||
|
actionsTd.appendChild(makeActionButton("Stop", () => serverAction(s.ID, "stop")));
|
||||||
|
actionsTd.appendChild(makeActionButton("Reload", () => serverAction(s.ID, "reload")));
|
||||||
|
|
||||||
|
tr.appendChild(nameTd);
|
||||||
|
tr.appendChild(ifaceTd);
|
||||||
|
tr.appendChild(portTd);
|
||||||
|
tr.appendChild(statusTd);
|
||||||
|
tr.appendChild(actionsTd);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeActionButton(label, onClick) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.className = "secondary";
|
||||||
|
btn.addEventListener("click", onClick);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serverAction(id, action) {
|
||||||
|
await apiFetch("/api/servers/" + id + "/" + action, { method: "POST" });
|
||||||
|
loadServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("new-server").addEventListener("click", async () => {
|
||||||
|
const name = prompt("Name des Servers (z.B. WGhome):");
|
||||||
|
if (!name) return;
|
||||||
|
const interfaceName = prompt("Interface (z.B. wg-home):");
|
||||||
|
if (!interfaceName) return;
|
||||||
|
const listenPort = parseInt(prompt("Listen Port (z.B. 51822):"), 10);
|
||||||
|
if (!listenPort) return;
|
||||||
|
const addressRange = prompt("Address Range (z.B. 10.20.22.0/24):");
|
||||||
|
if (!addressRange) return;
|
||||||
|
const dns = prompt("DNS (optional):") || "";
|
||||||
|
|
||||||
|
const res = await apiFetch("/api/servers", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name,
|
||||||
|
interface_name: interfaceName,
|
||||||
|
listen_port: listenPort,
|
||||||
|
address_range: addressRange,
|
||||||
|
dns: dns,
|
||||||
|
mtu: 1420,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
alert(data.error || "Server konnte nicht erstellt werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadServers();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadServers();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
document.getElementById("login-form").addEventListener("submit", async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const errEl = document.getElementById("login-error");
|
||||||
|
errEl.textContent = "";
|
||||||
|
|
||||||
|
const form = e.target;
|
||||||
|
const username = form.username.value;
|
||||||
|
const password = form.password.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
errEl.textContent = data.error || "Anmeldung fehlgeschlagen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = "/";
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = "Verbindung fehlgeschlagen.";
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
function getCookie(name) {
|
||||||
|
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
||||||
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(url, options) {
|
||||||
|
options = options || {};
|
||||||
|
options.headers = options.headers || {};
|
||||||
|
if (options.method && options.method !== "GET") {
|
||||||
|
options.headers["X-CSRF-Token"] = getCookie("wgm_csrf");
|
||||||
|
}
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new Error("unauthenticated");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverIDFromPath() {
|
||||||
|
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||||
|
return parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverID = serverIDFromPath();
|
||||||
|
const errEl = document.getElementById("server-error");
|
||||||
|
|
||||||
|
async function loadServer() {
|
||||||
|
errEl.textContent = "";
|
||||||
|
const res = await apiFetch("/api/servers/" + serverID);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
errEl.textContent = data.error || "Server konnte nicht geladen werden.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = await res.json();
|
||||||
|
document.getElementById("server-name").textContent = s.Name;
|
||||||
|
document.getElementById("d-interface").textContent = s.InterfaceName;
|
||||||
|
document.getElementById("d-port").textContent = s.ListenPort;
|
||||||
|
document.getElementById("d-address").textContent = s.AddressRange;
|
||||||
|
document.getElementById("d-dns").textContent = s.DNS || "-";
|
||||||
|
document.getElementById("d-mtu").textContent = s.MTU;
|
||||||
|
|
||||||
|
const statusRes = await apiFetch("/api/servers");
|
||||||
|
if (statusRes.ok) {
|
||||||
|
const servers = await statusRes.json();
|
||||||
|
const match = servers.find((x) => String(x.ID) === String(serverID));
|
||||||
|
const statusTd = document.getElementById("d-status");
|
||||||
|
statusTd.innerHTML = "";
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
const status = match ? match.status : "DOWN";
|
||||||
|
badge.className = "badge " + (status === "UP" ? "up" : "down");
|
||||||
|
badge.textContent = status;
|
||||||
|
statusTd.appendChild(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPeers() {
|
||||||
|
const tbody = document.querySelector("#peers tbody");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
const res = await apiFetch("/api/server/" + serverID + "/peers");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const peers = await res.json();
|
||||||
|
|
||||||
|
for (const p of peers) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
const nameTd = document.createElement("td");
|
||||||
|
nameTd.textContent = p.name;
|
||||||
|
|
||||||
|
const emailTd = document.createElement("td");
|
||||||
|
emailTd.textContent = p.email || "-";
|
||||||
|
|
||||||
|
const allowedTd = document.createElement("td");
|
||||||
|
allowedTd.textContent = p.allowed_ips;
|
||||||
|
|
||||||
|
const enabledTd = document.createElement("td");
|
||||||
|
enabledTd.textContent = p.enabled ? "Ja" : "Nein";
|
||||||
|
|
||||||
|
const actionsTd = document.createElement("td");
|
||||||
|
|
||||||
|
const qrBtn = document.createElement("button");
|
||||||
|
qrBtn.textContent = "QR-Code";
|
||||||
|
qrBtn.className = "secondary";
|
||||||
|
qrBtn.addEventListener("click", () => showQRCode(p.id));
|
||||||
|
actionsTd.appendChild(qrBtn);
|
||||||
|
|
||||||
|
const dlLink = document.createElement("a");
|
||||||
|
dlLink.href = "/api/server/" + serverID + "/peer/" + p.id + "/config?host=" + encodeURIComponent(window.location.hostname);
|
||||||
|
dlLink.textContent = "Config";
|
||||||
|
dlLink.style.marginLeft = "0.5rem";
|
||||||
|
actionsTd.appendChild(dlLink);
|
||||||
|
|
||||||
|
const delBtn = document.createElement("button");
|
||||||
|
delBtn.textContent = "Löschen";
|
||||||
|
delBtn.className = "danger";
|
||||||
|
delBtn.addEventListener("click", () => deletePeer(p.id));
|
||||||
|
actionsTd.appendChild(delBtn);
|
||||||
|
|
||||||
|
tr.appendChild(nameTd);
|
||||||
|
tr.appendChild(emailTd);
|
||||||
|
tr.appendChild(allowedTd);
|
||||||
|
tr.appendChild(enabledTd);
|
||||||
|
tr.appendChild(actionsTd);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQRCode(peerID) {
|
||||||
|
const modal = document.getElementById("qrcode-modal");
|
||||||
|
const img = document.getElementById("qrcode-img");
|
||||||
|
img.src = "/api/server/" + serverID + "/peer/" + peerID + "/qrcode?host=" + encodeURIComponent(window.location.hostname) + "&t=" + Date.now();
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("qrcode-close").addEventListener("click", () => {
|
||||||
|
document.getElementById("qrcode-modal").classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deletePeer(peerID) {
|
||||||
|
if (!confirm("Peer wirklich löschen?")) return;
|
||||||
|
await apiFetch("/api/server/" + serverID + "/peer/" + peerID, { method: "DELETE" });
|
||||||
|
loadPeers();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("btn-start").addEventListener("click", async () => {
|
||||||
|
await apiFetch("/api/servers/" + serverID + "/start", { method: "POST" });
|
||||||
|
loadServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-stop").addEventListener("click", async () => {
|
||||||
|
await apiFetch("/api/servers/" + serverID + "/stop", { method: "POST" });
|
||||||
|
loadServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-reload").addEventListener("click", async () => {
|
||||||
|
await apiFetch("/api/servers/" + serverID + "/reload", { method: "POST" });
|
||||||
|
loadServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-download").addEventListener("click", () => {
|
||||||
|
window.location.href = "/api/servers/" + serverID + "/config";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("peer-form").addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errP = document.getElementById("peer-error");
|
||||||
|
errP.textContent = "";
|
||||||
|
const form = e.target;
|
||||||
|
const body = {
|
||||||
|
name: form.name.value,
|
||||||
|
email: form.email.value,
|
||||||
|
allowed_ips: form.allowed_ips.value,
|
||||||
|
persistent_keepalive: parseInt(form.persistent_keepalive.value, 10) || 25,
|
||||||
|
use_preshared_key: form.use_preshared_key.checked,
|
||||||
|
};
|
||||||
|
const res = await apiFetch("/api/server/" + serverID + "/peer", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
errP.textContent = data.error || "Peer konnte nicht erstellt werden.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.reset();
|
||||||
|
form.persistent_keepalive.value = 25;
|
||||||
|
loadPeers();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadServer();
|
||||||
|
loadPeers();
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
color: #1c1c1c;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1rem 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.details {
|
||||||
|
width: auto;
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #e2e2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
background: #f0f0f0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
margin: 0.15rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #e2e2e2;
|
||||||
|
border-radius: 6px;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form#login-form {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 3rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="number"] {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.up {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.down {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content img {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background: #17181a;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
table, form {
|
||||||
|
background: #212226;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
background: #2a2b30;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border-bottom: 1px solid #33343a;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: #26272c;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background: #1b1c1f;
|
||||||
|
color: #e6e6e6;
|
||||||
|
border: 1px solid #3a3b41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #212226;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>wireguard-ui-multi — Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>WireGuard Server</h1>
|
||||||
|
<table id="servers">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Interface</th><th>Port</th><th>Status</th><th>Aktionen</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<button id="new-server">Neuer Server</button>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>wireguard-ui-multi — Login</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form id="login-form">
|
||||||
|
<h1>Anmelden</h1>
|
||||||
|
<input type="text" name="username" placeholder="Benutzername" required>
|
||||||
|
<input type="password" name="password" placeholder="Passwort" required>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
<p id="login-error" class="error"></p>
|
||||||
|
</form>
|
||||||
|
<script src="/static/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>wireguard-ui-multi — Server</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p><a href="/">← Zurück zum Dashboard</a></p>
|
||||||
|
|
||||||
|
<h1 id="server-name">Server</h1>
|
||||||
|
<p id="server-error" class="error"></p>
|
||||||
|
|
||||||
|
<table class="details">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>Interface</th><td id="d-interface"></td></tr>
|
||||||
|
<tr><th>Port</th><td id="d-port"></td></tr>
|
||||||
|
<tr><th>Address Range</th><td id="d-address"></td></tr>
|
||||||
|
<tr><th>DNS</th><td id="d-dns"></td></tr>
|
||||||
|
<tr><th>MTU</th><td id="d-mtu"></td></tr>
|
||||||
|
<tr><th>Status</th><td id="d-status"></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="btn-start">Start</button>
|
||||||
|
<button id="btn-stop">Stop</button>
|
||||||
|
<button id="btn-reload">Neu laden</button>
|
||||||
|
<button id="btn-download">Config herunterladen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Peers</h2>
|
||||||
|
<table id="peers">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Email</th><th>Allowed IPs</th><th>Aktiv</th><th>Aktionen</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Neuen Peer hinzufügen</h2>
|
||||||
|
<form id="peer-form">
|
||||||
|
<input type="text" name="name" placeholder="Name" required>
|
||||||
|
<input type="email" name="email" placeholder="Email">
|
||||||
|
<input type="text" name="allowed_ips" placeholder="Allowed IPs, z.B. 10.20.22.5/32" required>
|
||||||
|
<input type="number" name="persistent_keepalive" placeholder="Persistent Keepalive (s)" value="25">
|
||||||
|
<label><input type="checkbox" name="use_preshared_key"> Preshared Key verwenden</label>
|
||||||
|
<button type="submit">Peer hinzufügen</button>
|
||||||
|
<p id="peer-error" class="error"></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="qrcode-modal" class="modal hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button id="qrcode-close">Schließen</button>
|
||||||
|
<img id="qrcode-img" alt="QR Code">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/server.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package wireguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigDir is where per-interface wgX.conf files are written, e.g. /etc/wireguard.
|
||||||
|
var ConfigDir = "/etc/wireguard"
|
||||||
|
|
||||||
|
// RenderConfig builds the wg-quick compatible config text for a server and its peers.
|
||||||
|
func RenderConfig(srv *server.Server, peers []*server.Peer) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, "[Interface]\n")
|
||||||
|
fmt.Fprintf(&b, "PrivateKey = %s\n", srv.PrivateKey)
|
||||||
|
fmt.Fprintf(&b, "Address = %s\n", srv.AddressRange)
|
||||||
|
fmt.Fprintf(&b, "ListenPort = %d\n", srv.ListenPort)
|
||||||
|
if srv.MTU > 0 {
|
||||||
|
fmt.Fprintf(&b, "MTU = %d\n", srv.MTU)
|
||||||
|
}
|
||||||
|
if srv.DNS != "" {
|
||||||
|
fmt.Fprintf(&b, "DNS = %s\n", srv.DNS)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range peers {
|
||||||
|
if !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("\n[Peer]\n")
|
||||||
|
fmt.Fprintf(&b, "# %s\n", p.Name)
|
||||||
|
fmt.Fprintf(&b, "PublicKey = %s\n", p.PublicKey)
|
||||||
|
if p.PresharedKey != "" {
|
||||||
|
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "AllowedIPs = %s\n", p.AllowedIPs)
|
||||||
|
if p.PersistentKeepalive > 0 {
|
||||||
|
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", p.PersistentKeepalive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderClientConfig builds the config a peer/client would use to connect to srv.
|
||||||
|
func RenderClientConfig(srv *server.Server, p *server.Peer, endpointHost string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString("[Interface]\n")
|
||||||
|
fmt.Fprintf(&b, "PrivateKey = %s\n", p.PrivateKey)
|
||||||
|
fmt.Fprintf(&b, "Address = %s\n", p.AllowedIPs)
|
||||||
|
if srv.DNS != "" {
|
||||||
|
fmt.Fprintf(&b, "DNS = %s\n", srv.DNS)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n[Peer]\n")
|
||||||
|
fmt.Fprintf(&b, "PublicKey = %s\n", srv.PublicKey)
|
||||||
|
if p.PresharedKey != "" {
|
||||||
|
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "Endpoint = %s:%d\n", endpointHost, srv.ListenPort)
|
||||||
|
fmt.Fprintf(&b, "AllowedIPs = 0.0.0.0/0, ::/0\n")
|
||||||
|
if p.PersistentKeepalive > 0 {
|
||||||
|
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", p.PersistentKeepalive)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteConfig writes the rendered server config to ConfigDir/<interface>.conf with 0600 perms.
|
||||||
|
func WriteConfig(srv *server.Server, peers []*server.Peer) error {
|
||||||
|
if err := os.MkdirAll(ConfigDir, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(ConfigDir, srv.InterfaceName+".conf")
|
||||||
|
return os.WriteFile(path, []byte(RenderConfig(srv, peers)), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigPath returns the on-disk path for a server's config file.
|
||||||
|
func ConfigPath(srv *server.Server) string {
|
||||||
|
return filepath.Join(ConfigDir, srv.InterfaceName+".conf")
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package wireguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/curve25519"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateKeyPair creates a new WireGuard-compatible Curve25519 key pair,
|
||||||
|
// base64-encoded like `wg genkey` / `wg pubkey`.
|
||||||
|
func GenerateKeyPair() (privateKey, publicKey string, err error) {
|
||||||
|
var priv [32]byte
|
||||||
|
if _, err := rand.Read(priv[:]); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
// Clamp per RFC 7748 / WireGuard convention.
|
||||||
|
priv[0] &= 248
|
||||||
|
priv[31] &= 127
|
||||||
|
priv[31] |= 64
|
||||||
|
|
||||||
|
var pub [32]byte
|
||||||
|
curve25519.ScalarBaseMult(&pub, &priv)
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicFromPrivate derives the public key for an existing base64 private key.
|
||||||
|
func PublicFromPrivate(privateKeyB64 string) (string, error) {
|
||||||
|
privBytes, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var priv, pub [32]byte
|
||||||
|
copy(priv[:], privBytes)
|
||||||
|
curve25519.ScalarBaseMult(&pub, &priv)
|
||||||
|
return base64.StdEncoding.EncodeToString(pub[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePresharedKey creates a random base64 preshared key.
|
||||||
|
func GeneratePresharedKey() (string, error) {
|
||||||
|
var key [32]byte
|
||||||
|
if _, err := rand.Read(key[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(key[:]), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package wireguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status of a WireGuard interface.
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusUp Status = "UP"
|
||||||
|
StatusDown Status = "DOWN"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Up brings up the given interface via wg-quick.
|
||||||
|
func Up(iface string) error {
|
||||||
|
return run("wg-quick", "up", iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down brings down the given interface via wg-quick.
|
||||||
|
func Down(iface string) error {
|
||||||
|
return run("wg-quick", "down", iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload applies config changes to a running interface without a full restart,
|
||||||
|
// using `wg syncconf` against a stripped config (wg-quick strip).
|
||||||
|
func Reload(iface, confPath string) error {
|
||||||
|
strip := exec.Command("wg-quick", "strip", confPath)
|
||||||
|
stripped, err := strip.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wg-quick strip: %w", err)
|
||||||
|
}
|
||||||
|
sync := exec.Command("wg", "syncconf", iface, "/dev/stdin")
|
||||||
|
sync.Stdin = strings.NewReader(string(stripped))
|
||||||
|
if out, err := sync.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("wg syncconf: %w: %s", err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUp checks whether the interface currently exists / is up.
|
||||||
|
func IsUp(iface string) bool {
|
||||||
|
cmd := exec.Command("wg", "show", iface)
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetStatus(iface string) Status {
|
||||||
|
if IsUp(iface) {
|
||||||
|
return StatusUp
|
||||||
|
}
|
||||||
|
return StatusDown
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableService enables and starts the systemd wg-quick@<iface>.service unit.
|
||||||
|
func EnableService(iface string) error {
|
||||||
|
if err := run("systemctl", "enable", "wg-quick@"+iface); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return run("systemctl", "start", "wg-quick@"+iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableService stops and disables the systemd wg-quick@<iface>.service unit.
|
||||||
|
func DisableService(iface string) error {
|
||||||
|
if err := run("systemctl", "stop", "wg-quick@"+iface); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return run("systemctl", "disable", "wg-quick@"+iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(name string, args ...string) error {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package wireguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParsedLegacyConfig is the parsed result of a legacy wg-quick style config file.
|
||||||
|
type ParsedLegacyConfig struct {
|
||||||
|
PrivateKey string
|
||||||
|
Address string // e.g. "10.10.0.1/24" (used as AddressRange for the new Server)
|
||||||
|
ListenPort int
|
||||||
|
DNS string
|
||||||
|
MTU int
|
||||||
|
Peers []ParsedLegacyPeer
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedLegacyPeer is a single [Peer] section from a legacy config.
|
||||||
|
type ParsedLegacyPeer struct {
|
||||||
|
Name string
|
||||||
|
PublicKey string
|
||||||
|
PresharedKey string
|
||||||
|
AllowedIPs string
|
||||||
|
Endpoint string
|
||||||
|
PersistentKeepalive int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLegacyConfig reads and parses a wg-quick INI-style config file (e.g. /etc/wireguard/wg0.conf).
|
||||||
|
func ParseLegacyConfig(path string) (*ParsedLegacyConfig, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
cfg := &ParsedLegacyConfig{}
|
||||||
|
var curSection string
|
||||||
|
var curPeer *ParsedLegacyPeer
|
||||||
|
|
||||||
|
// pendingName holds a comment found on the line(s) immediately before a
|
||||||
|
// "[Peer]" header, e.g. "# client-laptop". wg-quick has no native peer
|
||||||
|
// name field, so this is the only place a human-readable name can come
|
||||||
|
// from; it's consumed (and reset) as soon as the next [Peer] section starts.
|
||||||
|
var pendingName string
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||||
|
pendingName = strings.TrimSpace(strings.TrimLeft(line, "#;"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip inline comments.
|
||||||
|
if idx := strings.IndexAny(line, "#;"); idx >= 0 {
|
||||||
|
line = strings.TrimSpace(line[:idx])
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||||
|
section := strings.ToLower(strings.TrimSpace(line[1 : len(line)-1]))
|
||||||
|
switch section {
|
||||||
|
case "interface":
|
||||||
|
curSection = "interface"
|
||||||
|
curPeer = nil
|
||||||
|
case "peer":
|
||||||
|
curSection = "peer"
|
||||||
|
cfg.Peers = append(cfg.Peers, ParsedLegacyPeer{Name: pendingName})
|
||||||
|
curPeer = &cfg.Peers[len(cfg.Peers)-1]
|
||||||
|
default:
|
||||||
|
curSection = ""
|
||||||
|
curPeer = nil
|
||||||
|
}
|
||||||
|
pendingName = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key, value, ok := splitKV(line)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch curSection {
|
||||||
|
case "interface":
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(key, "PrivateKey"):
|
||||||
|
cfg.PrivateKey = value
|
||||||
|
case strings.EqualFold(key, "Address"):
|
||||||
|
cfg.Address = value
|
||||||
|
case strings.EqualFold(key, "ListenPort"):
|
||||||
|
cfg.ListenPort, _ = strconv.Atoi(value)
|
||||||
|
case strings.EqualFold(key, "DNS"):
|
||||||
|
cfg.DNS = value
|
||||||
|
case strings.EqualFold(key, "MTU"):
|
||||||
|
cfg.MTU, _ = strconv.Atoi(value)
|
||||||
|
}
|
||||||
|
case "peer":
|
||||||
|
if curPeer == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(key, "PublicKey"):
|
||||||
|
curPeer.PublicKey = value
|
||||||
|
case strings.EqualFold(key, "PresharedKey"):
|
||||||
|
curPeer.PresharedKey = value
|
||||||
|
case strings.EqualFold(key, "AllowedIPs"):
|
||||||
|
curPeer.AllowedIPs = value
|
||||||
|
case strings.EqualFold(key, "Endpoint"):
|
||||||
|
curPeer.Endpoint = value
|
||||||
|
case strings.EqualFold(key, "PersistentKeepalive"):
|
||||||
|
curPeer.PersistentKeepalive, _ = strconv.Atoi(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitKV(line string) (key, value string, ok bool) {
|
||||||
|
idx := strings.Index(line, "=")
|
||||||
|
if idx < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(line[:idx])
|
||||||
|
value = strings.TrimSpace(line[idx+1:])
|
||||||
|
if key == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return key, value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportLegacyServer parses legacyConfPath and creates a corresponding Server + its Peers
|
||||||
|
// in the given store, using serverName and interfaceName for the new Server record.
|
||||||
|
// Returns the new server's ID.
|
||||||
|
func ImportLegacyServer(store *server.Store, legacyConfPath, serverName, interfaceName string) (int64, error) {
|
||||||
|
parsed, err := ParseLegacyConfig(legacyConfPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("parse legacy config %q: %w", legacyConfPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey, err := PublicFromPrivate(parsed.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("derive public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mtu := parsed.MTU
|
||||||
|
if mtu == 0 {
|
||||||
|
mtu = 1420
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &server.Server{
|
||||||
|
Name: serverName,
|
||||||
|
InterfaceName: interfaceName,
|
||||||
|
ListenPort: parsed.ListenPort,
|
||||||
|
PrivateKey: parsed.PrivateKey,
|
||||||
|
PublicKey: pubKey,
|
||||||
|
AddressRange: parsed.Address,
|
||||||
|
DNS: parsed.DNS,
|
||||||
|
MTU: mtu,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
serverID, err := store.CreateServer(srv)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, pp := range parsed.Peers {
|
||||||
|
name := pp.Name
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("peer-%d", i+1)
|
||||||
|
}
|
||||||
|
peer := &server.Peer{
|
||||||
|
ServerID: serverID,
|
||||||
|
Name: name,
|
||||||
|
PublicKey: pp.PublicKey,
|
||||||
|
PresharedKey: pp.PresharedKey,
|
||||||
|
AllowedIPs: pp.AllowedIPs,
|
||||||
|
Endpoint: pp.Endpoint,
|
||||||
|
PersistentKeepalive: pp.PersistentKeepalive,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if _, err := store.CreatePeer(peer); err != nil {
|
||||||
|
return serverID, fmt.Errorf("create peer %q (index %d): %w", name, i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverID, nil
|
||||||
|
}
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Idempotent installer for wireguard-ui-multi. Expects the binary to already be
|
||||||
|
# built at ./wireguard-ui-multi (run `go build ./cmd/wireguard-ui-multi` first).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BIN_SRC="./wireguard-ui-multi"
|
||||||
|
BIN_DST="/usr/local/bin/wireguard-ui-multi"
|
||||||
|
CONFIG_DIR="/etc/wireguard-ui-multi"
|
||||||
|
DATA_DIR="/var/lib/wireguard-ui-multi"
|
||||||
|
HOOKS_DIR="/etc/wireguard-manager/hooks"
|
||||||
|
SERVICE_SRC="systemd/wireguard-ui-multi.service"
|
||||||
|
SERVICE_DST="/etc/systemd/system/wireguard-ui-multi.service"
|
||||||
|
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
echo "This installer must be run as root (it writes to /usr/local/bin, /etc, /var/lib)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$BIN_SRC" ]]; then
|
||||||
|
if [[ -d "./cmd/wireguard-ui-multi" ]] && command -v go >/dev/null 2>&1; then
|
||||||
|
echo "Binary not found, building from source with 'go build'..."
|
||||||
|
go build -o "$BIN_SRC" ./cmd/wireguard-ui-multi
|
||||||
|
else
|
||||||
|
echo "Binary not found at $BIN_SRC and cannot build (need Go toolchain + source). Build it first, e.g.:" >&2
|
||||||
|
echo " go build -o wireguard-ui-multi ./cmd/wireguard-ui-multi" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating directories..."
|
||||||
|
mkdir -p /usr/local/bin
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
mkdir -p "$HOOKS_DIR"
|
||||||
|
|
||||||
|
echo "Installing binary to $BIN_DST..."
|
||||||
|
cp "$BIN_SRC" "$BIN_DST"
|
||||||
|
chmod 0755 "$BIN_DST"
|
||||||
|
|
||||||
|
echo "Installing systemd unit to $SERVICE_DST..."
|
||||||
|
cp "$SERVICE_SRC" "$SERVICE_DST"
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# The database holds bcrypt password hashes; keep the directory private.
|
||||||
|
echo "Restricting permissions on $DATA_DIR (0700)..."
|
||||||
|
chmod 0700 "$DATA_DIR"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Installation complete."
|
||||||
|
echo "This script does NOT start the service automatically. To enable and start it, run:"
|
||||||
|
echo
|
||||||
|
echo " systemctl enable --now wireguard-ui-multi.service"
|
||||||
|
echo
|
||||||
|
echo "Then check status with:"
|
||||||
|
echo
|
||||||
|
echo " systemctl status wireguard-ui-multi.service"
|
||||||
|
echo " journalctl -u wireguard-ui-multi.service -f"
|
||||||
Executable
+177
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs on a Proxmox VE host. Creates a privileged LXC container prepared for
|
||||||
|
# WireGuard (kernel module + /dev/net/tun + CAP_NET_ADMIN + nftables), copies
|
||||||
|
# this repo into it, and runs scripts/install.sh inside the container.
|
||||||
|
#
|
||||||
|
# Privileged container is required: WireGuard needs CAP_NET_ADMIN and access
|
||||||
|
# to /dev/net/tun, which unprivileged LXC containers cannot reliably get.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<EOF
|
||||||
|
Usage: $0 --vmid <id> --hostname <name> [options]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--vmid <id> numeric LXC container ID (e.g. 200)
|
||||||
|
--hostname <name> container hostname (e.g. wireguard-ui-multi)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--storage <name> Proxmox storage for rootfs (default: local-lvm)
|
||||||
|
--template <path> LXC template volid (default: auto-detect + download newest debian-* template)
|
||||||
|
--bridge <name> network bridge (default: vmbr0)
|
||||||
|
--ip <cidr|dhcp> static CIDR (e.g. 10.0.0.50/24) or "dhcp" (default: dhcp)
|
||||||
|
--gw <ip> gateway IP, required if --ip is a static CIDR
|
||||||
|
--disk <GB> rootfs size in GB (default: 4)
|
||||||
|
--memory <MB> RAM in MB (default: 512)
|
||||||
|
--cores <n> CPU cores (default: 1)
|
||||||
|
--repo-src <path> path to this repo on the Proxmox host (default: script's own repo root)
|
||||||
|
--start start the container after creation (default: created but stopped, then started to run the installer, left running)
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
VMID=""
|
||||||
|
HOSTNAME=""
|
||||||
|
STORAGE="local-lvm"
|
||||||
|
TEMPLATE=""
|
||||||
|
BRIDGE="vmbr0"
|
||||||
|
IP="dhcp"
|
||||||
|
GW=""
|
||||||
|
DISK="4"
|
||||||
|
MEMORY="512"
|
||||||
|
CORES="1"
|
||||||
|
REPO_SRC=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--vmid) VMID="$2"; shift 2 ;;
|
||||||
|
--hostname) HOSTNAME="$2"; shift 2 ;;
|
||||||
|
--storage) STORAGE="$2"; shift 2 ;;
|
||||||
|
--template) TEMPLATE="$2"; shift 2 ;;
|
||||||
|
--bridge) BRIDGE="$2"; shift 2 ;;
|
||||||
|
--ip) IP="$2"; shift 2 ;;
|
||||||
|
--gw) GW="$2"; shift 2 ;;
|
||||||
|
--disk) DISK="$2"; shift 2 ;;
|
||||||
|
--memory) MEMORY="$2"; shift 2 ;;
|
||||||
|
--cores) CORES="$2"; shift 2 ;;
|
||||||
|
--repo-src) REPO_SRC="$2"; shift 2 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -z "$VMID" || -z "$HOSTNAME" ]] && usage
|
||||||
|
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
echo "Must run as root on the Proxmox host." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v pct >/dev/null 2>&1; then
|
||||||
|
echo "pct not found - this script must run on a Proxmox VE host." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$REPO_SRC" ]]; then
|
||||||
|
REPO_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$TEMPLATE" ]]; then
|
||||||
|
echo "No --template given, looking up newest debian-* template..."
|
||||||
|
pveam update >/dev/null
|
||||||
|
TEMPLATE_FILE="$(pveam available --section system \
|
||||||
|
| awk '{print $2}' \
|
||||||
|
| grep -E '^debian-[0-9]+-standard_' \
|
||||||
|
| sort -V \
|
||||||
|
| tail -n1)"
|
||||||
|
if [[ -z "$TEMPLATE_FILE" ]]; then
|
||||||
|
echo "Could not find any debian-* template via 'pveam available'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! pveam list "$STORAGE" | grep -q "$TEMPLATE_FILE"; then
|
||||||
|
echo "Downloading $TEMPLATE_FILE to storage $STORAGE..."
|
||||||
|
pveam download "$STORAGE" "$TEMPLATE_FILE"
|
||||||
|
fi
|
||||||
|
TEMPLATE="${STORAGE}:vztmpl/${TEMPLATE_FILE}"
|
||||||
|
echo "Using template: $TEMPLATE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
NET_CONFIG="name=eth0,bridge=${BRIDGE},firewall=1"
|
||||||
|
if [[ "$IP" == "dhcp" ]]; then
|
||||||
|
NET_CONFIG="${NET_CONFIG},ip=dhcp"
|
||||||
|
else
|
||||||
|
if [[ -z "$GW" ]]; then
|
||||||
|
echo "--gw is required when --ip is a static CIDR." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
NET_CONFIG="${NET_CONFIG},ip=${IP},gw=${GW}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating privileged LXC $VMID ($HOSTNAME)..."
|
||||||
|
pct create "$VMID" "$TEMPLATE" \
|
||||||
|
--hostname "$HOSTNAME" \
|
||||||
|
--storage "$STORAGE" \
|
||||||
|
--rootfs "${STORAGE}:${DISK}" \
|
||||||
|
--memory "$MEMORY" \
|
||||||
|
--cores "$CORES" \
|
||||||
|
--net0 "$NET_CONFIG" \
|
||||||
|
--unprivileged 0 \
|
||||||
|
--features "nesting=1,keyctl=1" \
|
||||||
|
--onboot 1
|
||||||
|
|
||||||
|
echo "Enabling /dev/net/tun and WireGuard kernel module passthrough..."
|
||||||
|
CONF="/etc/pve/lxc/${VMID}.conf"
|
||||||
|
{
|
||||||
|
echo "lxc.cgroup2.devices.allow: c 10:200 rwm"
|
||||||
|
echo "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
|
||||||
|
} >> "$CONF"
|
||||||
|
|
||||||
|
echo "Starting container..."
|
||||||
|
pct start "$VMID"
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
echo "Installing base dependencies inside container (Go, git, wireguard-tools, nftables)..."
|
||||||
|
pct exec "$VMID" -- bash -c "
|
||||||
|
set -e
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y wireguard-tools nftables curl ca-certificates git
|
||||||
|
if ! command -v go >/dev/null 2>&1; then
|
||||||
|
ARCH=\$(dpkg --print-architecture)
|
||||||
|
curl -fsSL https://go.dev/dl/go1.22.5.linux-\${ARCH}.tar.gz -o /tmp/go.tar.gz
|
||||||
|
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||||
|
ln -sf /usr/local/go/bin/go /usr/local/bin/go
|
||||||
|
rm -f /tmp/go.tar.gz
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Copying repo into container..."
|
||||||
|
TMP_TAR="$(mktemp)"
|
||||||
|
tar -C "$REPO_SRC" -czf "$TMP_TAR" --exclude=.git .
|
||||||
|
pct push "$VMID" "$TMP_TAR" /tmp/wireguard-ui-multi.tar.gz
|
||||||
|
rm -f "$TMP_TAR"
|
||||||
|
|
||||||
|
pct exec "$VMID" -- bash -c "
|
||||||
|
set -e
|
||||||
|
mkdir -p /opt/wireguard-ui-multi-src
|
||||||
|
tar -C /opt/wireguard-ui-multi-src -xzf /tmp/wireguard-ui-multi.tar.gz
|
||||||
|
rm -f /tmp/wireguard-ui-multi.tar.gz
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Running native installer inside container..."
|
||||||
|
pct exec "$VMID" -- bash -c "
|
||||||
|
set -e
|
||||||
|
cd /opt/wireguard-ui-multi-src
|
||||||
|
go build -o wireguard-ui-multi ./cmd/wireguard-ui-multi
|
||||||
|
bash scripts/install.sh
|
||||||
|
"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Container $VMID ($HOSTNAME) is set up. wireguard-ui-multi is installed but not started."
|
||||||
|
echo "To enable and start it inside the container, run:"
|
||||||
|
echo
|
||||||
|
echo " pct exec $VMID -- systemctl enable --now wireguard-ui-multi.service"
|
||||||
|
echo
|
||||||
|
echo "Then check status with:"
|
||||||
|
echo
|
||||||
|
echo " pct exec $VMID -- systemctl status wireguard-ui-multi.service"
|
||||||
|
echo " pct exec $VMID -- journalctl -u wireguard-ui-multi.service -f"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=wireguard-ui-multi - native multi-server WireGuard management UI
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Runs as root because it shells out to wg-quick, systemctl and nft, which
|
||||||
|
# require CAP_NET_ADMIN (and in practice broad privileges for systemctl unit
|
||||||
|
# management). AmbientCapabilities is set as defense-in-depth in case this
|
||||||
|
# unit is ever adapted to run as a non-root user with File capabilities on
|
||||||
|
# the binary instead.
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
AmbientCapabilities=CAP_NET_ADMIN
|
||||||
|
ExecStart=/usr/local/bin/wireguard-ui-multi --db /var/lib/wireguard-ui-multi/wireguard-ui-multi.db --config-dir /etc/wireguard --hooks-dir /etc/wireguard-manager/hooks
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
WorkingDirectory=/var/lib/wireguard-ui-multi
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user