diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d16f37b --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/DEVLOG.md b/DEVLOG.md new file mode 100644 index 0000000..7b31a1b --- /dev/null +++ b/DEVLOG.md @@ -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. + +--- diff --git a/README.md b/README.md index e69de29..efff888 100644 --- a/README.md +++ b/README.md @@ -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/.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/.conf` und ruft dann +`wg-quick up ` 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. diff --git a/cmd/wireguard-ui-multi/main.go b/cmd/wireguard-ui-multi/main.go new file mode 100644 index 0000000..3c7d6ab --- /dev/null +++ b/cmd/wireguard-ui-multi/main.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1a981a2 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..358a2bb --- /dev/null +++ b/internal/api/auth.go @@ -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}) +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..6721830 --- /dev/null +++ b/internal/api/handlers.go @@ -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 +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..5da0f27 --- /dev/null +++ b/internal/api/router.go @@ -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) + } +} diff --git a/internal/api/ui_handlers.go b/internal/api/ui_handlers.go new file mode 100644 index 0000000..d2769aa --- /dev/null +++ b/internal/api/ui_handlers.go @@ -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") +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..966e12f --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/firewall/nftables.go b/internal/firewall/nftables.go new file mode 100644 index 0000000..aa20573 --- /dev/null +++ b/internal/firewall/nftables.go @@ -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/ 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 +} diff --git a/internal/server/model.go b/internal/server/model.go new file mode 100644 index 0000000..483887c --- /dev/null +++ b/internal/server/model.go @@ -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 +} diff --git a/internal/ui/static/app.js b/internal/ui/static/app.js new file mode 100644 index 0000000..62bef60 --- /dev/null +++ b/internal/ui/static/app.js @@ -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(); diff --git a/internal/ui/static/login.js b/internal/ui/static/login.js new file mode 100644 index 0000000..a03bf56 --- /dev/null +++ b/internal/ui/static/login.js @@ -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."; + } +}); diff --git a/internal/ui/static/server.js b/internal/ui/static/server.js new file mode 100644 index 0000000..8630982 --- /dev/null +++ b/internal/ui/static/server.js @@ -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(); diff --git a/internal/ui/static/style.css b/internal/ui/static/style.css new file mode 100644 index 0000000..af5da0b --- /dev/null +++ b/internal/ui/static/style.css @@ -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; + } +} diff --git a/internal/ui/templates/dashboard.html b/internal/ui/templates/dashboard.html new file mode 100644 index 0000000..35a53ce --- /dev/null +++ b/internal/ui/templates/dashboard.html @@ -0,0 +1,19 @@ + + + + +wireguard-ui-multi — Dashboard + + + +

WireGuard Server

+ + + + + +
NameInterfacePortStatusAktionen
+ + + + diff --git a/internal/ui/templates/login.html b/internal/ui/templates/login.html new file mode 100644 index 0000000..8ce23d3 --- /dev/null +++ b/internal/ui/templates/login.html @@ -0,0 +1,18 @@ + + + + +wireguard-ui-multi — Login + + + +
+

Anmelden

+ + + +

+
+ + + diff --git a/internal/ui/templates/server.html b/internal/ui/templates/server.html new file mode 100644 index 0000000..ebe63f3 --- /dev/null +++ b/internal/ui/templates/server.html @@ -0,0 +1,60 @@ + + + + +wireguard-ui-multi — Server + + + +

← Zurück zum Dashboard

+ +

Server

+

+ + + + + + + + + + +
Interface
Port
Address Range
DNS
MTU
Status
+ +
+ + + + +
+ +

Peers

+ + + + + +
NameEmailAllowed IPsAktivAktionen
+ +

Neuen Peer hinzufügen

+
+ + + + + + +

+
+ + + + + + diff --git a/internal/wireguard/config.go b/internal/wireguard/config.go new file mode 100644 index 0000000..bc4d03e --- /dev/null +++ b/internal/wireguard/config.go @@ -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/.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") +} diff --git a/internal/wireguard/keys.go b/internal/wireguard/keys.go new file mode 100644 index 0000000..605683a --- /dev/null +++ b/internal/wireguard/keys.go @@ -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 +} diff --git a/internal/wireguard/manager.go b/internal/wireguard/manager.go new file mode 100644 index 0000000..fce1be4 --- /dev/null +++ b/internal/wireguard/manager.go @@ -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@.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@.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 +} diff --git a/internal/wireguard/migrate.go b/internal/wireguard/migrate.go new file mode 100644 index 0000000..654d794 --- /dev/null +++ b/internal/wireguard/migrate.go @@ -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 +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..a9cae9d --- /dev/null +++ b/scripts/install.sh @@ -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" diff --git a/scripts/proxmox-install.sh b/scripts/proxmox-install.sh new file mode 100755 index 0000000..98262fa --- /dev/null +++ b/scripts/proxmox-install.sh @@ -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 < --hostname [options] + +Required: + --vmid numeric LXC container ID (e.g. 200) + --hostname container hostname (e.g. wireguard-ui-multi) + +Options: + --storage Proxmox storage for rootfs (default: local-lvm) + --template LXC template volid (default: auto-detect + download newest debian-* template) + --bridge network bridge (default: vmbr0) + --ip static CIDR (e.g. 10.0.0.50/24) or "dhcp" (default: dhcp) + --gw gateway IP, required if --ip is a static CIDR + --disk rootfs size in GB (default: 4) + --memory RAM in MB (default: 512) + --cores CPU cores (default: 1) + --repo-src 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" diff --git a/systemd/wireguard-ui-multi.service b/systemd/wireguard-ui-multi.service new file mode 100644 index 0000000..899ed4d --- /dev/null +++ b/systemd/wireguard-ui-multi.service @@ -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