From 8040783c77b630ab9f3cf6424215522bf2de8a81 Mon Sep 17 00:00:00 2001 From: Sajad Hosseinzade Date: Thu, 7 May 2026 01:08:08 +0330 Subject: [PATCH] refactor: streamline WireGuard interface management and enhance user experience - Removed direct PCQ application calls in favor of a centralized function to apply PCQ across all active WireGuard interfaces. - Updated WireGuard configuration handling to support multiple interfaces, allowing users to create and manage additional WireGuard tunnels. - Enhanced user interface for managing WireGuard users and interfaces, including improved sorting and filtering options. - Adjusted database schema to support new WireGuard interface attributes and ensure unique usernames per interface. - Improved error handling and logging for WireGuard operations, ensuring better feedback during configuration changes. --- cmd/netplug/main.go | 10 +- internal/app/handlers.go | 344 +++++++++++++++--- internal/app/pcq_handlers.go | 38 +- internal/app/routes.go | 2 + internal/db/migrate.go | 6 +- internal/db/schema_patches.go | 113 +++++- internal/pcq/load.go | 4 +- .../templates/partials/add_user_modal.tmpl | 28 +- internal/view/templates/users.tmpl | 44 ++- internal/view/templates/wireguard.tmpl | 86 +++++ internal/wireguard/apply.go | 5 + internal/wireguard/clientconf.go | 7 +- internal/wireguard/config.go | 90 +++-- internal/wireguard/deploy.go | 61 ++++ internal/wireguard/peers.go | 36 +- internal/wireguard/setup.go | 80 ++++ internal/wireguard/state.go | 4 +- internal/wireguard/syncer.go | 117 ++++-- 18 files changed, 927 insertions(+), 148 deletions(-) create mode 100644 internal/wireguard/deploy.go diff --git a/cmd/netplug/main.go b/cmd/netplug/main.go index 1da9746..791279e 100644 --- a/cmd/netplug/main.go +++ b/cmd/netplug/main.go @@ -21,7 +21,6 @@ import ( "netplug-go/internal/app" "netplug-go/internal/assets" "netplug-go/internal/db" - "netplug-go/internal/pcq" "netplug-go/internal/view" "netplug-go/internal/wireguard" webstatic "netplug-go/web/static" @@ -118,14 +117,7 @@ func main() { } else { log.Printf("wireguard startup: interface is up") if !cfg.PCQDisabled { - if _, err := pcq.Apply(sqlDB, cfg.WGInterface, false, pcq.ApplyOpts{ - Debug: cfg.Debug, - Logger: svc.Logger, - }); err != nil { - if svc.Logger == nil { - log.Printf("pcq startup: %v", err) - } - } + app.ApplyPCQAllInterfaces(svc) } } } diff --git a/internal/app/handlers.go b/internal/app/handlers.go index 73fe987..be7111b 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -396,11 +396,10 @@ func (h *Handlers) SetupWireGuardPost(w http.ResponseWriter, r *http.Request) { http.Error(w, "server error", http.StatusInternalServerError) return } - if err := wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir); err != nil { - http.Error(w, "failed to write wg0.conf", http.StatusInternalServerError) + if err := wireguard.WriteAndApplyAll(h.svc.DB, h.svc.Config.DataDir, h.svc.Config.WGInterface); err != nil { + http.Error(w, "failed to write WireGuard configuration", http.StatusInternalServerError) return } - _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) h.reconcilePCQ() http.Redirect(w, r, "/ui", http.StatusFound) @@ -584,11 +583,10 @@ func (h *Handlers) SetupWireGuardImportPost(w http.ResponseWriter, r *http.Reque return } - if err := wireguard.WriteWireGuardConfig(h.svc.DB, dataDir); err != nil { - http.Error(w, "failed to write wg0.conf", http.StatusInternalServerError) + if err := wireguard.WriteAndApplyAll(h.svc.DB, dataDir, h.svc.Config.WGInterface); err != nil { + http.Error(w, "failed to write WireGuard configuration", http.StatusInternalServerError) return } - _ = wireguard.ApplyConfig(dataDir, h.svc.Config.WGInterface) h.reconcilePCQ() http.Redirect(w, r, "/ui", http.StatusFound) } @@ -935,27 +933,43 @@ func relativeAgoLastHandshake(lastHS, connectedAt sql.NullString) string { } func (h *Handlers) UsersPage(w http.ResponseWriter, r *http.Request) { - rows, err := h.svc.DB.Query(` + filterServer := strings.TrimSpace(r.URL.Query().Get("server")) + sortKey := strings.TrimSpace(r.URL.Query().Get("sort")) + if sortKey == "" { + sortKey = "username" + } + sortDir := strings.TrimSpace(r.URL.Query().Get("dir")) + if sortDir != "desc" { + sortDir = "asc" + } + + orderSQL := usersOrderByClause(sortKey, sortDir) + q := ` SELECT - id, - username, - allowed_ips, - endpoint, - is_enabled, - is_connected, - bytes_received, - bytes_sent, - total_bytes_received, - total_bytes_sent, - remaining_days, - remaining_traffic_bytes, - connected_at, - last_handshake, - peer_icon - FROM vpn_users - ORDER BY username ASC + u.id, + u.username, + u.allowed_ips, + u.endpoint, + u.is_enabled, + u.is_connected, + u.bytes_received, + u.bytes_sent, + u.total_bytes_received, + u.total_bytes_sent, + u.remaining_days, + u.remaining_traffic_bytes, + u.connected_at, + u.last_handshake, + u.peer_icon, + IFNULL(s.name, ''), + IFNULL(s.wg_interface, '') + FROM vpn_users u + LEFT JOIN vpn_servers s ON s.id = u.server_id + WHERE (TRIM(?) = '' OR u.server_id = ?) + ORDER BY ` + orderSQL + ` LIMIT 500 - `) + ` + rows, err := h.svc.DB.Query(q, filterServer, filterServer) if err != nil { http.Error(w, "server error", http.StatusInternalServerError) return @@ -978,6 +992,8 @@ func (h *Handlers) UsersPage(w http.ResponseWriter, r *http.Request) { ConnectedAt string LastHandshakeAgo string PeerIcon string + WGServerName string + WGInterface string } var users []userRow for rows.Next() { @@ -1007,6 +1023,8 @@ func (h *Handlers) UsersPage(w http.ResponseWriter, r *http.Request) { &connectedAt, &lastHandshake, &peerIcon, + &u.WGServerName, + &u.WGInterface, ); err != nil { http.Error(w, "server error", http.StatusInternalServerError) return @@ -1020,7 +1038,6 @@ func (h *Handlers) UsersPage(w http.ResponseWriter, r *http.Request) { } u.LastHandshakeAgo = relativeAgoLastHandshake(lastHandshake, connectedAt) if allowedIPs.Valid { - // First IP/CIDR is the peer tunnel address. first := strings.TrimSpace(allowedIPs.String) if i := strings.IndexByte(first, ','); i >= 0 { first = strings.TrimSpace(first[:i]) @@ -1048,9 +1065,87 @@ func (h *Handlers) UsersPage(w http.ResponseWriter, r *http.Request) { return } + base := url.Values{} + if filterServer != "" { + base.Set("server", filterServer) + } + sortHref := func(col string) string { + v := url.Values{} + for k, vals := range base { + for _, x := range vals { + v.Add(k, x) + } + } + v.Set("sort", col) + if sortKey == col { + if sortDir == "asc" { + v.Set("dir", "desc") + } else { + v.Set("dir", "asc") + } + } else { + v.Set("dir", "asc") + } + return "/ui/users?" + strings.ReplaceAll(v.Encode(), "+", "%20") + } + + iconFor := func(col string) string { + if sortKey != col { + return "" + } + if sortDir == "desc" { + return "↓" + } + return "↑" + } + + var wgServers []struct { + ID string + Name string + Interface string + } + srvRows, qerr := h.svc.DB.Query(` + SELECT id, name, IFNULL(TRIM(wg_interface), '') + FROM vpn_servers + WHERE protocol = 'wireguard' AND is_active = 1 + ORDER BY name COLLATE NOCASE ASC + `) + if qerr == nil { + defer srvRows.Close() + for srvRows.Next() { + var id, name, iface string + if err := srvRows.Scan(&id, &name, &iface); err != nil { + break + } + wgServers = append(wgServers, struct { + ID string + Name string + Interface string + }{ID: id, Name: name, Interface: iface}) + } + } + view.Render(w, r, "users.tmpl", view.M{ - "Title": "Users", - "Users": users, + "Title": "Users", + "Users": users, + "FilterServer": filterServer, + "CurrentSort": sortKey, + "CurrentDir": sortDir, + "WireGuardServers": wgServers, + "SortHrefUsername": sortHref("username"), + "SortHrefInterface": sortHref("interface"), + "SortHrefIP": sortHref("ip"), + "SortHrefStatus": sortHref("status"), + "SortHrefUsage": sortHref("usage"), + "SortHrefRemDays": sortHref("rem_days"), + "SortHrefRemTraffic": sortHref("rem_traffic"), + "SortIconUsername": iconFor("username"), + "SortIconInterface": iconFor("interface"), + "SortIconIP": iconFor("ip"), + "SortIconStatus": iconFor("status"), + "SortIconUsage": iconFor("usage"), + "SortIconRemDays": iconFor("rem_days"), + "SortIconRemTraffic": iconFor("rem_traffic"), }) } @@ -1066,11 +1161,21 @@ func (h *Handlers) UserCreatePost(w http.ResponseWriter, r *http.Request) { } vpnIP := strings.TrimSpace(r.FormValue("vpn_ip")) + serverID := strings.TrimSpace(r.FormValue("server_id")) + if serverID == "" { + serverID = "wireguard" + } + var srvProto string + if err := h.svc.DB.QueryRow(`SELECT protocol FROM vpn_servers WHERE id = ? LIMIT 1`, serverID).Scan(&srvProto); err != nil || srvProto != "wireguard" { + http.Error(w, "invalid WireGuard server", http.StatusBadRequest) + return + } + if vpnIP == "" { vpnIP = strings.TrimSpace(r.FormValue("allowed_ips")) } if vpnIP == "" { - alloc, err := wireguard.NextClientAllowedIP(h.svc.DB) + alloc, err := wireguard.NextClientAllowedIPForServer(h.svc.DB, serverID) if err != nil { http.Error(w, "ip allocation failed", http.StatusInternalServerError) return @@ -1135,16 +1240,14 @@ func (h *Handlers) UserCreatePost(w http.ResponseWriter, r *http.Request) { remaining_days, remaining_traffic_bytes, server_id, is_enabled ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'wireguard', ?) - `, id, username, allowedIPs, privateKey, publicKey, nullIfEmpty(psk), remainingDays, remainingTrafficBytes, isEnabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, id, username, allowedIPs, privateKey, publicKey, nullIfEmpty(psk), remainingDays, remainingTrafficBytes, serverID, isEnabled) if err != nil { http.Error(w, "create failed", http.StatusInternalServerError) return } - _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) - _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) - h.reconcilePCQ() + h.applyWireGuardKernel() http.Redirect(w, r, "/ui/users", http.StatusFound) } @@ -1157,9 +1260,40 @@ func nullIfEmpty(s string) any { } func (h *Handlers) AddUserModalPartial(w http.ResponseWriter, r *http.Request) { - nextIP, _ := wireguard.NextClientAllowedIP(h.svc.DB) + defServer := strings.TrimSpace(r.URL.Query().Get("server_id")) + if defServer == "" { + defServer = "wireguard" + } + nextIP, _ := wireguard.NextClientAllowedIPForServer(h.svc.DB, defServer) + var wgServers []struct { + ID string + Name string + Interface string + } + rows, err := h.svc.DB.Query(` + SELECT id, name, IFNULL(TRIM(wg_interface), '') + FROM vpn_servers + WHERE protocol = 'wireguard' AND is_active = 1 + ORDER BY name COLLATE NOCASE ASC + `) + if err == nil { + defer rows.Close() + for rows.Next() { + var id, name, iface string + if err := rows.Scan(&id, &name, &iface); err != nil { + break + } + wgServers = append(wgServers, struct { + ID string + Name string + Interface string + }{ID: id, Name: name, Interface: iface}) + } + } view.RenderPartial(w, r, "partials/add_user_modal.tmpl", view.M{ - "NextIP": nextIP, + "NextIP": nextIP, + "WireGuardServers": wgServers, + "DefaultServerID": defServer, }) } @@ -1346,9 +1480,7 @@ func (h *Handlers) UserUpdatePost(w http.ResponseWriter, r *http.Request) { return } - _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) - _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) - h.reconcilePCQ() + h.applyWireGuardKernel() // Close modal and show a toast; then refresh list. w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -1397,9 +1529,7 @@ func (h *Handlers) UserDeletePost(w http.ResponseWriter, r *http.Request) { return } - _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) - _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) - h.reconcilePCQ() + h.applyWireGuardKernel() msg := "User deleted." if strings.TrimSpace(username) != "" { @@ -1543,7 +1673,11 @@ func (h *Handlers) UsersGeneratePSKAPI(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) UsersNextIPAPI(w http.ResponseWriter, r *http.Request) { - ip, err := wireguard.NextClientAllowedIP(h.svc.DB) + sid := strings.TrimSpace(r.URL.Query().Get("server_id")) + if sid == "" { + sid = "wireguard" + } + ip, err := wireguard.NextClientAllowedIPForServer(h.svc.DB, sid) if err != nil { http.Error(w, `{"error":"failed"}`, http.StatusInternalServerError) return @@ -1568,9 +1702,7 @@ func (h *Handlers) UserTogglePost(w http.ResponseWriter, r *http.Request) { http.Error(w, "update failed", http.StatusInternalServerError) return } - _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) - _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) - h.reconcilePCQ() + h.applyWireGuardKernel() http.Redirect(w, r, "/ui/users", http.StatusFound) } @@ -1653,6 +1785,37 @@ func (h *Handlers) WireGuardPage(w http.ResponseWriter, r *http.Request) { msg := strings.TrimSpace(r.URL.Query().Get("msg")) msgType := strings.TrimSpace(r.URL.Query().Get("msgType")) + type wgExtraRow struct { + ID string + Name string + Host string + Port int + ConfigPath string + WGInterface string + ServerTunnel string + ClientRange string + } + var extras []wgExtraRow + exRows, qerr := h.svc.DB.Query(` + SELECT id, name, host, COALESCE(port, 0), IFNULL(config_path, ''), + IFNULL(TRIM(wg_interface), ''), IFNULL(TRIM(wg_server_address), ''), IFNULL(TRIM(wg_client_range), '') + FROM vpn_servers + WHERE protocol = 'wireguard' AND is_active = 1 AND id <> 'wireguard' + ORDER BY name COLLATE NOCASE ASC + `) + if qerr == nil { + defer exRows.Close() + for exRows.Next() { + var x wgExtraRow + var port int + if err := exRows.Scan(&x.ID, &x.Name, &x.Host, &port, &x.ConfigPath, &x.WGInterface, &x.ServerTunnel, &x.ClientRange); err != nil { + break + } + x.Port = port + extras = append(extras, x) + } + } + view.Render(w, r, "wireguard.tmpl", view.M{ "Title": "Wireguard", "WGConfig": cfg, @@ -1662,9 +1825,70 @@ func (h *Handlers) WireGuardPage(w http.ResponseWriter, r *http.Request) { "TunnelUptimeSeconds": tunUp, "SaveMessage": msg, "SaveMessageType": msgType, + "ExtraWGInterfaces": extras, }) } +func (h *Handlers) WireGuardInterfaceCreatePost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape("invalid form"), http.StatusFound) + return + } + name := strings.TrimSpace(r.FormValue("iface_name")) + host := strings.TrimSpace(r.FormValue("iface_host")) + port := mustAtoi(r.FormValue("iface_port"), 0) + wgIface := strings.TrimSpace(r.FormValue("wg_interface")) + serverAddr := strings.TrimSpace(r.FormValue("wg_server_address")) + clientRange := strings.TrimSpace(r.FormValue("wg_client_range")) + priv := strings.TrimSpace(r.FormValue("iface_private_key")) + pub := strings.TrimSpace(r.FormValue("iface_public_key")) + + if port < 1 || port > 65535 { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape("listen port must be between 1 and 65535"), http.StatusFound) + return + } + + if _, err := wireguard.CreateWireGuardInterface(h.svc.DB, h.svc.Config.DataDir, name, host, port, wgIface, serverAddr, clientRange, priv, pub); err != nil { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + if err := wireguard.WriteAndApplyAll(h.svc.DB, h.svc.Config.DataDir, h.svc.Config.WGInterface); err != nil { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + h.reconcilePCQ() + http.Redirect(w, r, "/ui/wireguard?msgType=success&msg="+urlQueryEscape("Additional WireGuard interface added."), http.StatusFound) +} + +func (h *Handlers) WireGuardInterfaceDeletePost(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" || id == "wireguard" { + http.NotFound(w, r) + return + } + var n int + if err := h.svc.DB.QueryRow(`SELECT COUNT(*) FROM vpn_users WHERE server_id = ?`, id).Scan(&n); err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if n > 0 { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape("Remove or delete VPN users on this interface before removing it."), http.StatusFound) + return + } + res, err := h.svc.DB.Exec(`DELETE FROM vpn_servers WHERE id = ? AND id <> 'wireguard' AND protocol = 'wireguard'`, id) + if err != nil { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + if rows, _ := res.RowsAffected(); rows == 0 { + http.NotFound(w, r) + return + } + _ = wireguard.WriteAndApplyAll(h.svc.DB, h.svc.Config.DataDir, h.svc.Config.WGInterface) + h.reconcilePCQ() + http.Redirect(w, r, "/ui/wireguard?msgType=success&msg="+urlQueryEscape("Interface removed."), http.StatusFound) +} + func (h *Handlers) SettingsPage(w http.ResponseWriter, r *http.Request) { view.Render(w, r, "settings.tmpl", view.M{ "Title": "Settings", @@ -1759,6 +1983,34 @@ func urlQueryEscape(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") } +func usersOrderByClause(sortKey, dir string) string { + d := "ASC" + if strings.EqualFold(strings.TrimSpace(dir), "desc") { + d = "DESC" + } + switch strings.ToLower(strings.TrimSpace(sortKey)) { + case "ip": + return "u.allowed_ips COLLATE NOCASE " + d + case "interface": + return "s.name COLLATE NOCASE " + d + case "status": + return "u.is_enabled " + d + case "usage": + return "(COALESCE(u.total_bytes_received,0)+COALESCE(u.bytes_received,0)+COALESCE(u.total_bytes_sent,0)+COALESCE(u.bytes_sent,0)) " + d + case "rem_days": + return "CASE WHEN u.remaining_days IS NULL THEN 1 ELSE 0 END ASC, u.remaining_days " + d + case "rem_traffic": + return "CASE WHEN u.remaining_traffic_bytes IS NULL THEN 1 ELSE 0 END ASC, u.remaining_traffic_bytes " + d + default: + return "u.username COLLATE NOCASE " + d + } +} + +func (h *Handlers) applyWireGuardKernel() { + _ = wireguard.WriteAndApplyAll(h.svc.DB, h.svc.Config.DataDir, h.svc.Config.WGInterface) + h.reconcilePCQ() +} + func (h *Handlers) BandwidthHistoryAPI(w http.ResponseWriter, r *http.Request) { mode := strings.TrimSpace(r.URL.Query().Get("mode")) if mode == "" { diff --git a/internal/app/pcq_handlers.go b/internal/app/pcq_handlers.go index 2cc56dc..9063bef 100644 --- a/internal/app/pcq_handlers.go +++ b/internal/app/pcq_handlers.go @@ -413,17 +413,43 @@ func (h *Handlers) GroupPCQTogglePost(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) reconcilePCQ() { - if h == nil || h.svc.DB == nil { + if h == nil || h.svc == nil { return } - if h.svc.Config.PCQDisabled { + ApplyPCQAllInterfaces(h.svc) +} + +// ApplyPCQAllInterfaces applies queue shaping on every active WireGuard interface (Linux). +func ApplyPCQAllInterfaces(svc *Services) { + if svc == nil || svc.DB == nil || svc.Config.PCQDisabled { return } opts := pcq.ApplyOpts{ - Debug: h.svc.Config.Debug, - Logger: h.svc.Logger, + Debug: svc.Config.Debug, + Logger: svc.Logger, + } + rows, err := svc.DB.Query(` + SELECT COALESCE(NULLIF(TRIM(wg_interface), ''), ?) + FROM vpn_servers + WHERE protocol = 'wireguard' AND is_active = 1 + `, svc.Config.WGInterface) + if err != nil { + return } - if _, err := pcq.Apply(h.svc.DB, h.svc.Config.WGInterface, false, opts); err != nil && h.svc.Logger == nil { - log.Printf("pcq.Apply: %v", err) + defer rows.Close() + seen := map[string]bool{} + for rows.Next() { + var iface string + if err := rows.Scan(&iface); err != nil { + continue + } + iface = strings.TrimSpace(iface) + if iface == "" || seen[iface] { + continue + } + seen[iface] = true + if _, err := pcq.Apply(svc.DB, iface, false, opts); err != nil && svc.Logger == nil { + log.Printf("pcq.Apply(%s): %v", iface, err) + } } } diff --git a/internal/app/routes.go b/internal/app/routes.go index 4a149d4..0659f5b 100644 --- a/internal/app/routes.go +++ b/internal/app/routes.go @@ -80,6 +80,8 @@ func RegisterRoutes(r chi.Router, svc *Services) { r.Get("/ui/wireguard", h.WireGuardPage) r.Post("/ui/wireguard/save", h.WireGuardSavePost) r.Post("/ui/wireguard/reload", h.WireGuardReloadPost) + r.Post("/ui/wireguard/interfaces", h.WireGuardInterfaceCreatePost) + r.Post("/ui/wireguard/interfaces/{id}/delete", h.WireGuardInterfaceDeletePost) r.Get("/ui/settings", h.SettingsPage) // HTMX partials diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 21336f2..5feda4b 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -67,13 +67,16 @@ CREATE TABLE IF NOT EXISTS vpn_servers ( is_active INTEGER NOT NULL DEFAULT 1, private_key TEXT NULL, public_key TEXT NULL, + wg_interface TEXT NULL, + wg_server_address TEXT NULL, + wg_client_range TEXT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS vpn_users ( id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, common_name TEXT NULL, allowed_ips TEXT NULL, endpoint TEXT NULL, @@ -99,6 +102,7 @@ CREATE TABLE IF NOT EXISTS vpn_users ( peer_icon TEXT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(server_id, username), FOREIGN KEY(server_id) REFERENCES vpn_servers(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_vpn_users_server_id ON vpn_users(server_id); diff --git a/internal/db/schema_patches.go b/internal/db/schema_patches.go index a7a683f..b879e16 100644 --- a/internal/db/schema_patches.go +++ b/internal/db/schema_patches.go @@ -1,6 +1,9 @@ package db -import "database/sql" +import ( + "database/sql" + "strings" +) // ApplySchemaPatches runs idempotent DDL for installs that already applied an older baseline schema. func ApplySchemaPatches(db *sql.DB) error { @@ -47,5 +50,113 @@ CREATE TABLE IF NOT EXISTS vpn_group_pcq ( } // Idempotent column addition for existing installs (ignore error if column already exists). _, _ = db.Exec(`ALTER TABLE vpn_group_pcq ADD COLUMN is_disabled INTEGER NOT NULL DEFAULT 0`) + + _, _ = db.Exec(`ALTER TABLE vpn_servers ADD COLUMN wg_interface TEXT NULL`) + _, _ = db.Exec(`ALTER TABLE vpn_servers ADD COLUMN wg_server_address TEXT NULL`) + _, _ = db.Exec(`ALTER TABLE vpn_servers ADD COLUMN wg_client_range TEXT NULL`) + + if err := migrateVPNUsersCompositeUnique(db); err != nil { + return err + } return nil } + +// migrateVPNUsersCompositeUnique replaces a global UNIQUE(username) with UNIQUE(server_id, username) +// so the same display name can exist on different WireGuard instances. +func migrateVPNUsersCompositeUnique(db *sql.DB) error { + var createSQL string + err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vpn_users'`).Scan(&createSQL) + if err != nil { + return err + } + if strings.Contains(createSQL, "UNIQUE (server_id, username)") { + return nil + } + if !strings.Contains(createSQL, "username TEXT NOT NULL UNIQUE") { + return nil + } + + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + _, _ = tx.Exec(`PRAGMA foreign_keys = OFF`) + _, err = tx.Exec(` +CREATE TABLE vpn_users__np ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + common_name TEXT NULL, + allowed_ips TEXT NULL, + endpoint TEXT NULL, + last_handshake TEXT NULL, + private_key TEXT NULL, + public_key TEXT NULL, + preshared_key TEXT NULL, + bytes_received INTEGER NOT NULL DEFAULT 0, + bytes_sent INTEGER NOT NULL DEFAULT 0, + prev_bytes_received INTEGER NOT NULL DEFAULT 0, + prev_bytes_sent INTEGER NOT NULL DEFAULT 0, + bytes_received_rate INTEGER NOT NULL DEFAULT 0, + bytes_sent_rate INTEGER NOT NULL DEFAULT 0, + total_bytes_received INTEGER NOT NULL DEFAULT 0, + total_bytes_sent INTEGER NOT NULL DEFAULT 0, + remaining_days INTEGER NULL, + remaining_traffic_bytes INTEGER NULL, + last_day_check TEXT NULL, + connected_at TEXT NULL, + is_connected INTEGER NOT NULL DEFAULT 0, + is_enabled INTEGER NOT NULL DEFAULT 1, + server_id TEXT NOT NULL, + peer_icon TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(server_id, username), + FOREIGN KEY(server_id) REFERENCES vpn_servers(id) ON DELETE CASCADE +)`) + if err != nil { + return err + } + + _, err = tx.Exec(` +INSERT INTO vpn_users__np ( + id, username, common_name, allowed_ips, endpoint, last_handshake, private_key, public_key, preshared_key, + bytes_received, bytes_sent, prev_bytes_received, prev_bytes_sent, bytes_received_rate, bytes_sent_rate, + total_bytes_received, total_bytes_sent, remaining_days, remaining_traffic_bytes, last_day_check, connected_at, + is_connected, is_enabled, server_id, peer_icon, created_at, updated_at +) +SELECT + id, username, common_name, allowed_ips, endpoint, last_handshake, private_key, public_key, preshared_key, + bytes_received, bytes_sent, prev_bytes_received, prev_bytes_sent, bytes_received_rate, bytes_sent_rate, + total_bytes_received, total_bytes_sent, remaining_days, remaining_traffic_bytes, last_day_check, connected_at, + is_connected, is_enabled, server_id, peer_icon, created_at, updated_at +FROM vpn_users`) + if err != nil { + return err + } + + _, err = tx.Exec(`DROP TABLE vpn_users`) + if err != nil { + return err + } + _, err = tx.Exec(`ALTER TABLE vpn_users__np RENAME TO vpn_users`) + if err != nil { + return err + } + + _, err = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_vpn_users_server_id ON vpn_users(server_id)`) + if err != nil { + return err + } + _, err = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_vpn_users_is_connected ON vpn_users(is_connected)`) + if err != nil { + return err + } + _, err = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_vpn_users_public_key ON vpn_users(public_key)`) + if err != nil { + return err + } + _, _ = tx.Exec(`PRAGMA foreign_keys = ON`) + return tx.Commit() +} diff --git a/internal/pcq/load.go b/internal/pcq/load.go index 6abf020..85705ef 100644 --- a/internal/pcq/load.go +++ b/internal/pcq/load.go @@ -54,7 +54,7 @@ func LoadPeerLimits(db *sql.DB) (map[string]PeerLimit, error) { FROM vpn_users u INNER JOIN vpn_group_members m ON m.vpn_user_id = u.id INNER JOIN vpn_group_pcq p ON p.group_id = m.group_id - WHERE u.server_id = 'wireguard' AND u.is_enabled = 1 AND p.is_disabled = 0 + WHERE u.server_id IN (SELECT id FROM vpn_servers WHERE protocol = 'wireguard') AND u.is_enabled = 1 AND p.is_disabled = 0 `) if err != nil { return nil, err @@ -111,7 +111,7 @@ func LoadPeerLimitsForGroup(db *sql.DB, groupID string) (map[string]PeerLimit, e FROM vpn_users u INNER JOIN vpn_group_members m ON m.vpn_user_id = u.id AND m.group_id = ? INNER JOIN vpn_group_pcq p ON p.group_id = m.group_id - WHERE u.server_id = 'wireguard' AND u.is_enabled = 1 AND p.is_disabled = 0 + WHERE u.server_id IN (SELECT id FROM vpn_servers WHERE protocol = 'wireguard') AND u.is_enabled = 1 AND p.is_disabled = 0 `, groupID) if err != nil { return nil, err diff --git a/internal/view/templates/partials/add_user_modal.tmpl b/internal/view/templates/partials/add_user_modal.tmpl index 9bf33d8..835f9d6 100644 --- a/internal/view/templates/partials/add_user_modal.tmpl +++ b/internal/view/templates/partials/add_user_modal.tmpl @@ -25,6 +25,20 @@
+ {{if .WireGuardServers}} +
+ + +
Users are tied to one interface; choose before generating keys.
+
+ {{else}} + + {{end}} +
@@ -39,7 +53,7 @@ />
- Must be unique. Changing username updates this peer everywhere in the dashboard. + Must be unique per interface. Changing username updates this peer everywhere in the dashboard.
@@ -182,6 +196,18 @@ }, 0); })(); + async function modalRefreshNextIP() { + const sel = document.getElementById('modal_server_id'); + const sid = sel && sel.value ? sel.value : ''; + let url = '/api/users/next-ip'; + if (sid) url += '?server_id=' + encodeURIComponent(sid); + const res = await fetch(url, { credentials: 'same-origin' }); + if (!res.ok) return; + const j = await res.json(); + const el = document.getElementById('modal_vpn_ip'); + if (el && j.allowedIps) el.value = j.allowedIps; + } + async function modalGenerateKeys() { const res = await fetch('/api/users/generate-keys', { method: 'POST', credentials: 'same-origin' }); if (!res.ok) return; diff --git a/internal/view/templates/users.tmpl b/internal/view/templates/users.tmpl index 661a597..274db57 100644 --- a/internal/view/templates/users.tmpl +++ b/internal/view/templates/users.tmpl @@ -14,7 +14,7 @@