package main import ( "encoding/json" "net/http" "fmt" "github.com/gorilla/mux" "gorm.io/gorm" qrcode "github.com/skip2/go-qrcode" ) // Helper wrappers func respondJSON(w http.ResponseWriter, status int, payload interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func respondError(w http.ResponseWriter, status int, message string) { respondJSON(w, status, map[string]string{"error": message}) } // ListServers lists all registered WireGuard servers. // @Summary List all servers // @Description Returns a list of all registered WireGuard servers. // @Tags servers // @Accept json // @Produce json // @Success 200 {array} Server // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/servers [get] func getServers(w http.ResponseWriter, r *http.Request) { var servers []Server if err := db.Find(&servers).Error; err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, servers) } // CreateServer creates a new WireGuard server entry. // @Summary Create a server // @Description Creates a new WireGuard server with the provided configuration. // @Tags servers // @Accept json // @Produce json // @Param server body Server true "Server configuration" // @Success 201 {object} Server // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/servers [post] func createServer(w http.ResponseWriter, r *http.Request) { var s Server if err := json.NewDecoder(r.Body).Decode(&s); err != nil { respondError(w, http.StatusBadRequest, "invalid request payload") return } // Input validation if !ValidatePublicKey(s.PublicKey) { respondError(w, http.StatusBadRequest, "invalid public key") return } if err := db.Create(&s).Error; err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusCreated, s) } // GetServer retrieves a single WireGuard server by ID. // @Summary Get a server // @Description Returns a single WireGuard server by its ID. // @Tags servers // @Produce json // @Param id path string true "Server ID" // @Success 200 {object} Server // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/servers/{id} [get] func getServer(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var s Server if err := db.First(&s, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "server not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } respondJSON(w, http.StatusOK, s) } // UpdateServer updates an existing WireGuard server. // @Summary Update a server // @Description Updates fields of an existing WireGuard server. // @Tags servers // @Accept json // @Produce json // @Param id path string true "Server ID" // @Param server body Server true "Updated server fields" // @Success 200 {object} Server // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/servers/{id} [put] func updateServer(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var s Server if err := db.First(&s, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "server not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } var updates Server if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { respondError(w, http.StatusBadRequest, "invalid request payload") return } // Input validation for updated fields if updates.PublicKey != "" && !ValidatePublicKey(updates.PublicKey) { respondError(w, http.StatusBadRequest, "invalid public key") return } // Apply updates using fields defined in models.go s.Name = updates.Name s.Mode = updates.Mode s.PublicKey = updates.PublicKey s.Endpoint = updates.Endpoint if err := db.Save(&s).Error; err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, s) } // DeleteServer removes a WireGuard server and cascades to its peers and webhooks. // @Summary Delete a server // @Description Deletes a WireGuard server and cascade-removes its peers and webhooks. // @Tags servers // @Produce json // @Param id path string true "Server ID" // @Success 204 "No content" // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/servers/{id} [delete] func deleteServer(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var s Server if err := db.First(&s, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "server not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } if err := db.Delete(&s).Error; err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusNoContent, nil) } // ListServerPeers lists all peers for a given WireGuard server. // @Summary List server peers // @Description Returns all peers belonging to a specific WireGuard server. // @Tags peers // @Produce json // @Param id path string true "Server ID" // @Success 200 {array} Peer // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/servers/{id}/peers [get] func getServerPeers(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] // Ensure server exists var s Server if err := db.First(&s, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "server not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } var peers []Peer if err := db.Where("server_id = ?", id).Find(&peers).Error; err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, peers) } // CreateServerPeer creates a new peer for a WireGuard server. // @Summary Create a peer // @Description Creates a new WireGuard peer. Applies nftables rules in forward mode or triggers webhooks in standalone mode. // @Tags peers // @Accept json // @Produce json // @Param id path string true "Server ID" // @Param peer body Peer true "Peer configuration" // @Success 201 {object} Peer // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/servers/{id}/peers [post] func createServerPeer(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] // ensure server exists var s Server if err := db.First(&s, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "server not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } var p Peer if err := json.NewDecoder(r.Body).Decode(&p); err != nil { respondError(w, http.StatusBadRequest, "invalid request payload") return } // Input validation for peer if !ValidatePublicKey(p.PublicKey) { respondError(w, http.StatusBadRequest, "invalid public key") return } if !ValidateIP(p.IP) { respondError(w, http.StatusBadRequest, "invalid ip address for peer IP") return } // Optional: validate AllowAccess CIDRs if provided if len(p.AllowAccess) > 0 { var targets []string if err := json.Unmarshal(p.AllowAccess, &targets); err == nil { for _, t := range targets { if !ValidateCIDR(t) { respondError(w, http.StatusBadRequest, "invalid CIDR in AllowAccess") return } } } } p.ServerID = s.ID if err := db.Create(&p).Error; err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } // Trigger mode-specific actions after creation policyChanges := []string{"peer_created"} if s.Mode == "forward" { // Apply nftables rules for the new peer if len(p.AllowAccess) > 0 { var targets []string if err := json.Unmarshal(p.AllowAccess, &targets); err == nil { for _, t := range targets { _ = AddAccessRule(p.IP, t) } } } _ = SetInternetAccess(p.IP, p.AllowInternet) // Notify policy change _ = TriggerWebhook("policy_changed", &s, &p, "created", policyChanges) } else if s.Mode == "standalone" { // Notify remote via webhooks _ = TriggerWebhook("peer_created", &s, &p, "created", []string{}) // Also notify policy change _ = TriggerWebhook("policy_changed", &s, &p, "created", policyChanges) } respondJSON(w, http.StatusCreated, p) } // UpdatePeer updates an existing WireGuard peer. // @Summary Update a peer // @Description Updates a peer and computes diffs to apply incremental nftables changes. // @Tags peers // @Accept json // @Produce json // @Param id path string true "Peer ID" // @Param peer body Peer true "Updated peer fields" // @Success 200 {object} Peer // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/peers/{id} [put] func updatePeer(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var p Peer if err := db.First(&p, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "peer not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } var updates Peer if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { respondError(w, http.StatusBadRequest, "invalid request payload") return } // Preserve the original peer for diff computation oldP := p // Validate updated fields if updates.PublicKey != "" && !ValidatePublicKey(updates.PublicKey) { respondError(w, http.StatusBadRequest, "invalid public key") return } if updates.IP != "" && !ValidateIP(updates.IP) { respondError(w, http.StatusBadRequest, "invalid ip address for peer IP") return } if len(updates.AllowAccess) > 0 { var targets []string if err := json.Unmarshal(updates.AllowAccess, &targets); err == nil { for _, t := range targets { if !ValidateCIDR(t) { respondError(w, http.StatusBadRequest, "invalid CIDR in AllowAccess") return } } } } // Build a list of policy-related changes for notification purposes changes := []string{} if updates.PublicKey != "" && updates.PublicKey != p.PublicKey { changes = append(changes, "PublicKey updated") } if updates.IP != "" && updates.IP != p.IP { changes = append(changes, "IP updated") } if len(updates.AllowAccess) > 0 && string(updates.AllowAccess) != string(p.AllowAccess) { changes = append(changes, "AllowAccess updated") } if updates.AllowInternet != p.AllowInternet { changes = append(changes, "AllowInternet updated") } // Apply updates based on the Peer model fields p.PublicKey = updates.PublicKey p.IP = updates.IP p.AllowAccess = updates.AllowAccess p.AllowInternet = updates.AllowInternet if err := db.Save(&p).Error; err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } // If policy-related fields changed, apply mode-specific actions if len(changes) > 0 { var server Server if err := db.First(&server, "id = ?", p.ServerID).Error; err == nil { if server.Mode == "forward" { // nftables-based updates: diff AllowAccess and IP/internet changes // Compute targets diffs between old and new var oldTargets []string if len(oldP.AllowAccess) > 0 { _ = json.Unmarshal(oldP.AllowAccess, &oldTargets) } var newTargets []string if len(p.AllowAccess) > 0 { _ = json.Unmarshal(p.AllowAccess, &newTargets) } // Determine additions/removals added := []string{} for _, t := range newTargets { found := false for _, o := range oldTargets { if o == t { found = true; break } } if !found { added = append(added, t) } } removed := []string{} for _, o := range oldTargets { found := false for _, t := range newTargets { if o == t { found = true; break } } if !found { removed = append(removed, o) } } // Apply removals first for safety fromIP := oldP.IP if oldP.IP == "" { fromIP = p.IP } for _, t := range removed { _ = RemoveAccessRule(fromIP, t) } // Apply additions for the current IP toIP := p.IP for _, t := range added { _ = AddAccessRule(toIP, t) } // Internet access flag _ = SetInternetAccess(p.IP, p.AllowInternet) // Notify policy change _ = TriggerWebhook("policy_changed", &server, &p, "updated", changes) } else if server.Mode == "standalone" { _ = TriggerWebhook("peer_updated", &server, &p, "updated", changes) // Also notify policy change _ = TriggerWebhook("policy_changed", &server, &p, "updated", changes) } } } respondJSON(w, http.StatusOK, p) } // DeletePeer removes a WireGuard peer and cleans up nftables rules. // @Summary Delete a peer // @Description Deletes a peer, cleans up nftables rules, and triggers webhooks. // @Tags peers // @Produce json // @Param id path string true "Peer ID" // @Success 204 "No content" // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/peers/{id} [delete] func deletePeer(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var p Peer if err := db.First(&p, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "peer not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } // If in forward mode, remove any NFTables rules for this peer before deletion var server Server if err := db.First(&server, "id = ?", p.ServerID).Error; err == nil { if server.Mode == "forward" { var targets []string if len(p.AllowAccess) > 0 { _ = json.Unmarshal(p.AllowAccess, &targets) } for _, t := range targets { _ = RemoveAccessRule(p.IP, t) } _ = SetInternetAccess(p.IP, false) // Notify policy change _ = TriggerWebhook("policy_changed", &server, &p, "deleted", []string{"peer_deleted"}) } } if err := db.Delete(&p).Error; err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } // Notify external systems about the deletion according to server mode if err := db.First(&server, "id = ?", p.ServerID).Error; err == nil { if server.Mode == "standalone" { _ = TriggerWebhook("peer_deleted", &server, &p, "deleted", []string{}) // Also notify policy change _ = TriggerWebhook("policy_changed", &server, &p, "deleted", []string{"peer_deleted"}) } } respondJSON(w, http.StatusNoContent, nil) } // GetSMTPSettings retrieves the current SMTP configuration. // @Summary Get SMTP settings // @Description Returns the current SMTP configuration for email notifications. // @Tags settings // @Produce json // @Success 200 {object} SMTPSettings // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/settings/smtp [get] func getSMTPSettings(w http.ResponseWriter, r *http.Request) { settings, err := GetSMTPSettings() if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, settings) } // SetSMTPSettings saves SMTP configuration for email notifications. // @Summary Save SMTP settings // @Description Saves or updates SMTP configuration for email notifications. // @Tags settings // @Accept json // @Produce json // @Param settings body SMTPSettings true "SMTP configuration" // @Success 200 {object} SMTPSettings // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/settings/smtp [post] func setSMTPSettings(w http.ResponseWriter, r *http.Request) { var s SMTPSettings if err := json.NewDecoder(r.Body).Decode(&s); err != nil { respondError(w, http.StatusBadRequest, "invalid request payload") return } if err := SaveSMTPSettings(s); err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, s) } // ListServerWebhooks lists all webhooks for a WireGuard server. // @Summary List server webhooks // @Description Returns all webhooks configured for a specific WireGuard server. // @Tags webhooks // @Produce json // @Param id path string true "Server ID" // @Success 200 {array} Webhook // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/servers/{id}/webhooks [get] func getServerWebhooks(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var s Server if err := db.First(&s, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "server not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } var webhooks []Webhook if err := db.Where("server_id = ?", id).Find(&webhooks).Error; err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, webhooks) } // CreateServerWebhook creates a new webhook for a WireGuard server. // @Summary Create a webhook // @Description Creates a new webhook configuration for a WireGuard server. // @Tags webhooks // @Accept json // @Produce json // @Param id path string true "Server ID" // @Param webhook body Webhook true "Webhook configuration" // @Success 201 {object} Webhook // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/servers/{id}/webhooks [post] func createServerWebhook(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var s Server if err := db.First(&s, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "server not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } var wb Webhook if err := json.NewDecoder(r.Body).Decode(&wb); err != nil { respondError(w, http.StatusBadRequest, "invalid request payload") return } wb.ServerID = s.ID if err := db.Create(&wb).Error; err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusCreated, wb) } // DeleteWebhook removes a webhook configuration. // @Summary Delete a webhook // @Description Deletes a webhook by its ID. // @Tags webhooks // @Produce json // @Param id path string true "Webhook ID" // @Success 204 "No content" // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/webhooks/{id} [delete] func deleteWebhook(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var wb Webhook if err := db.First(&wb, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "webhook not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } if err := db.Delete(&wb).Error; err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusNoContent, nil) } // GetPeerConfig downloads the WireGuard configuration file for a peer. // @Summary Get peer config // @Description Downloads the WireGuard .conf file for a specific peer. // @Tags peers // @Produce plain // @Param id path string true "Peer ID" // @Success 200 {string} string "WireGuard configuration file" // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/peers/{id}/config [get] func getPeerConfig(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var p Peer if err := db.First(&p, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "peer not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } var s Server if err := db.First(&s, "id = ?", p.ServerID).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "server not found for peer") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } conf, err := GeneratePeerConfig(p, s) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=peer-%d.conf", p.ID)) w.Write(conf) } // GetPeerQRCode returns a QR code PNG image for the peer configuration. // @Summary Get peer QR code // @Description Returns a QR code PNG image of the peer config for mobile import. // @Tags peers // @Produce png // @Param id path string true "Peer ID" // @Success 200 {file} binary "QR code PNG image" // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/peers/{id}/qrcode [get] func getPeerQRCode(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var p Peer if err := db.First(&p, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "peer not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } var s Server if err := db.First(&s, "id = ?", p.ServerID).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "server not found for peer") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } conf, err := GeneratePeerConfig(p, s) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } png, err := qrcode.Encode(string(conf), qrcode.Medium, 256) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", "image/png") w.Write(png) } // GetStats returns global statistics for servers, peers, and webhooks. // @Summary Get global stats // @Description Returns global statistics including total servers, peers, and webhooks. // @Tags stats // @Produce json // @Success 200 {object} map[string]int64 // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/stats [get] func getStats(w http.ResponseWriter, r *http.Request) { var serverCount int64 var peerCount int64 var webhookCount int64 db.Model(&Server{}).Count(&serverCount) db.Model(&Peer{}).Count(&peerCount) db.Model(&Webhook{}).Count(&webhookCount) resp := map[string]interface{}{ "servers": serverCount, "peers": peerCount, "webhooks": webhookCount, } respondJSON(w, http.StatusOK, resp) } // GetServerStats returns per-server statistics. // @Summary Get server stats // @Description Returns per-server statistics including peer count and webhook count. // @Tags stats // @Produce json // @Param id path string true "Server ID" // @Success 200 {object} map[string]interface{} // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security ApiKeyAuth // @Security BearerAuth // @Router /api/servers/{id}/stats [get] func getServerStats(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] var s Server if err := db.First(&s, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { respondError(w, http.StatusNotFound, "server not found") } else { respondError(w, http.StatusInternalServerError, err.Error()) } return } var peers []Peer var webhooks []Webhook db.Where("server_id = ?", id).Find(&peers) db.Where("server_id = ?", id).Find(&webhooks) resp := map[string]interface{}{ "server": s, "peers": len(peers), "webhooks": len(webhooks), } respondJSON(w, http.StatusOK, resp) }