wg-portal/internal/app/api/v1/handlers/endpoint_peer.go

403 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handlers
import (
"context"
"log/slog"
"net/http"
"github.com/go-pkgz/routegroup"
"github.com/fedor-git/wg-portal-2/internal/app"
"github.com/fedor-git/wg-portal-2/internal/app/api/core/request"
"github.com/fedor-git/wg-portal-2/internal/app/api/core/respond"
"github.com/fedor-git/wg-portal-2/internal/app/api/v1/models"
"github.com/fedor-git/wg-portal-2/internal/domain"
)
type PeerService interface {
GetForInterface(context.Context, domain.InterfaceIdentifier) ([]domain.Peer, error)
GetForUser(context.Context, domain.UserIdentifier) ([]domain.Peer, error)
GetById(context.Context, domain.PeerIdentifier) (*domain.Peer, error)
Prepare(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Peer, error)
Create(context.Context, *domain.Peer) (*domain.Peer, error)
Update(context.Context, domain.PeerIdentifier, *domain.Peer) (*domain.Peer, error)
Delete(context.Context, domain.PeerIdentifier) error
SyncAllPeersFromDB(ctx context.Context) (int, error)
}
type PeerEndpoint struct {
peers PeerService
authenticator Authenticator
validator Validator
bus app.EventBus
}
func NewPeerEndpoint(
authenticator Authenticator,
validator Validator, peerService PeerService,
) *PeerEndpoint {
return &PeerEndpoint{
authenticator: authenticator,
validator: validator,
peers: peerService,
}
}
func (e PeerEndpoint) GetName() string {
return "PeerEndpoint"
}
func (e *PeerEndpoint) SetEventBus(bus app.EventBus) {
e.bus = bus
}
func (e *PeerEndpoint) publish(topic string, args ...any) {
if e.bus == nil || topic == "" {
return
}
slog.Debug("[V1] publish", "topic", topic)
e.bus.Publish(topic, args...)
}
// 0-arg для штатних подій (внутрішні підписники)
func (e *PeerEndpoint) publish0(topic string) {
if e.bus == nil || topic == "" { return }
e.bus.Publish(topic) // без аргументів
}
// 1-arg для fanout (йому потрібен рівно ОДИН аргумент)
func (e *PeerEndpoint) publish1(topic string, arg any) {
if e.bus == nil || topic == "" { return }
e.bus.Publish(topic, arg)
}
func (e PeerEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup := g.Mount("/peer")
apiGroup.Use(e.authenticator.LoggedIn())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /by-interface/{id}",
e.handleAllForInterfaceGet())
apiGroup.HandleFunc("GET /by-user/{id}", e.handleAllForUserGet())
apiGroup.HandleFunc("GET /by-id/{id}", e.handleByIdGet())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /prepare/{id}", e.handlePrepareGet())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /new", e.handleCreatePost())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("PUT /by-id/{id}", e.handleUpdatePut())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("DELETE /by-id/{id}", e.handleDelete())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /sync", e.handleSyncPost())
}
// handleAllForInterfaceGet returns a gorm Handler function.
//
// @ID peers_handleAllForInterfaceGet
// @Tags Peers
// @Summary Get all peer records for a given WireGuard interface.
// @Param id path string true "The WireGuard interface identifier."
// @Produce json
// @Success 200 {object} []models.Peer
// @Failure 401 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/by-interface/{id} [get]
// @Security BasicAuth
func (e PeerEndpoint) handleAllForInterfaceGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := request.Path(r, "id")
if id == "" {
respond.JSON(w, http.StatusBadRequest,
models.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
interfacePeers, err := e.peers.GetForInterface(r.Context(), domain.InterfaceIdentifier(id))
if err != nil {
status, model := ParseServiceError(err)
respond.JSON(w, status, model)
return
}
respond.JSON(w, http.StatusOK, models.NewPeers(interfacePeers))
}
}
// handleAllForUserGet returns a gorm Handler function.
//
// @ID peers_handleAllForUserGet
// @Tags Peers
// @Summary Get all peer records for a given user.
// @Description Normal users can only access their own records. Admins can access all records.
// @Param id path string true "The user identifier."
// @Produce json
// @Success 200 {object} []models.Peer
// @Failure 401 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/by-user/{id} [get]
// @Security BasicAuth
func (e PeerEndpoint) handleAllForUserGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := request.Path(r, "id")
if id == "" {
respond.JSON(w, http.StatusBadRequest,
models.Error{Code: http.StatusBadRequest, Message: "missing user id"})
return
}
interfacePeers, err := e.peers.GetForUser(r.Context(), domain.UserIdentifier(id))
if err != nil {
status, model := ParseServiceError(err)
respond.JSON(w, status, model)
return
}
respond.JSON(w, http.StatusOK, models.NewPeers(interfacePeers))
}
}
// handleByIdGet returns a gorm Handler function.
//
// @ID peers_handleByIdGet
// @Tags Peers
// @Summary Get a specific peer record by its identifier (public key).
// @Description Normal users can only access their own records. Admins can access all records.
// @Param id path string true "The peer identifier (public key)."
// @Produce json
// @Success 200 {object} models.Peer
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/by-id/{id} [get]
// @Security BasicAuth
func (e PeerEndpoint) handleByIdGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := request.Path(r, "id")
if id == "" {
respond.JSON(w, http.StatusBadRequest,
models.Error{Code: http.StatusBadRequest, Message: "missing peer id"})
return
}
peer, err := e.peers.GetById(r.Context(), domain.PeerIdentifier(id))
if err != nil {
status, model := ParseServiceError(err)
respond.JSON(w, status, model)
return
}
respond.JSON(w, http.StatusOK, models.NewPeer(peer))
}
}
// handlePrepareGet returns a gorm handler function.
//
// @ID peers_handlePrepareGet
// @Tags Peers
// @Summary Prepare a new peer record for the given WireGuard interface.
// @Description This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.
// @Param id path string true "The interface identifier."
// @Produce json
// @Success 200 {object} models.Peer
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/prepare/{id} [get]
// @Security BasicAuth
func (e PeerEndpoint) handlePrepareGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := request.Path(r, "id")
if id == "" {
respond.JSON(w, http.StatusBadRequest,
models.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
peer, err := e.peers.Prepare(r.Context(), domain.InterfaceIdentifier(id))
if err != nil {
status, model := ParseServiceError(err)
respond.JSON(w, status, model)
return
}
respond.JSON(w, http.StatusOK, models.NewPeer(peer))
}
}
// handleCreatePost returns a gorm handler function.
//
// @ID peers_handleCreatePost
// @Tags Peers
// @Summary Create a new peer record.
// @Description Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).
// @Param request body models.Peer true "The peer data."
// @Produce json
// @Success 200 {object} models.Peer
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 409 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/new [post]
// @Security BasicAuth
func (e PeerEndpoint) handleCreatePost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var peer models.Peer
if err := request.BodyJson(r, &peer); err != nil {
respond.JSON(w, http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
if err := e.validator.Struct(peer); err != nil {
respond.JSON(w, http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
newPeer, err := e.peers.Create(r.Context(), models.NewDomainPeer(&peer))
if err != nil {
status, model := ParseServiceError(err)
respond.JSON(w, status, model)
return
}
// внутрішні
e.publish0(app.TopicPeerCreated)
e.publish0(app.TopicPeerUpdated)
// fanout
e.publish1("peer.save", newPeer)
e.publish1("peers.updated", "v1:create")
respond.JSON(w, http.StatusOK, models.NewPeer(newPeer))
}
}
// handleUpdatePut returns a gorm handler function.
//
// @ID peers_handleUpdatePut
// @Tags Peers
// @Summary Update a peer record.
// @Description Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).
// @Param id path string true "The peer identifier."
// @Param request body models.Peer true "The peer data."
// @Produce json
// @Success 200 {object} models.Peer
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/by-id/{id} [put]
// @Security BasicAuth
func (e PeerEndpoint) handleUpdatePut() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := request.Path(r, "id")
if id == "" {
respond.JSON(w, http.StatusBadRequest,
models.Error{Code: http.StatusBadRequest, Message: "missing peer id"})
return
}
var peer models.Peer
if err := request.BodyJson(r, &peer); err != nil {
respond.JSON(w, http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
if err := e.validator.Struct(peer); err != nil {
respond.JSON(w, http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
updatedPeer, err := e.peers.Update(r.Context(), domain.PeerIdentifier(id), models.NewDomainPeer(&peer))
if err != nil {
status, model := ParseServiceError(err)
respond.JSON(w, status, model)
return
}
// внутрішні
e.publish0(app.TopicPeerUpdated)
// fanout
e.publish1("peer.save", updatedPeer)
e.publish1("peers.updated", "v1:update")
respond.JSON(w, http.StatusOK, models.NewPeer(updatedPeer))
}
}
// handleDelete returns a gorm handler function.
//
// @ID peers_handleDelete
// @Tags Peers
// @Summary Delete the peer record.
// @Param id path string true "The peer identifier."
// @Produce json
// @Success 204 "No content if deletion was successful."
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 403 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /peer/by-id/{id} [delete]
// @Security BasicAuth
func (e PeerEndpoint) handleDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := request.Path(r, "id")
if id == "" {
respond.JSON(w, http.StatusBadRequest,
models.Error{Code: http.StatusBadRequest, Message: "missing peer id"})
return
}
err := e.peers.Delete(r.Context(), domain.PeerIdentifier(id))
if err != nil {
status, model := ParseServiceError(err)
respond.JSON(w, status, model)
return
}
// внутрішні
e.publish0(app.TopicPeerDeleted)
e.publish0(app.TopicPeerUpdated)
// fanout
e.publish1("peer.delete", domain.PeerIdentifier(id))
e.publish1("peers.updated", "v1:delete")
respond.Status(w, http.StatusNoContent)
}
}
// func (e PeerEndpoint) handleSyncPost() http.HandlerFunc {
// return func(w http.ResponseWriter, r *http.Request) {
// count, err := e.peers.SyncAllPeersFromDB(r.Context())
// if err != nil {
// status, model := ParseServiceError(err)
// respond.JSON(w, status, model)
// return
// }
// respond.JSON(w, http.StatusOK, map[string]any{
// "synced": count,
// })
// }
// }
func (e PeerEndpoint) handleSyncPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Якщо fanout поставив заголовок "не ехо", забороняємо локальні публікації
if r.Header.Get("X-WGP-NoEcho") == "1" {
ctx = app.WithNoFanout(ctx)
}
count, err := e.peers.SyncAllPeersFromDB(ctx)
if err != nil {
status, model := ParseServiceError(err)
respond.JSON(w, status, model)
return
}
respond.JSON(w, http.StatusOK, map[string]any{
"synced": count,
})
}
}