From 87964f8ec4348780125823e343193e7ccfe377ba Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Mon, 26 Apr 2021 22:00:50 +0200 Subject: [PATCH] RESTful API for WireGuard Portal (#11) --- README.md | 6 + .../providers/password/provider.go | 4 +- internal/server/api.go | 630 ++++++++++- internal/server/configuration.go | 23 +- internal/server/docs/docs.go | 1002 ++++++++++++++++- internal/server/handlers_user.go | 24 +- internal/server/routes.go | 50 +- internal/server/server.go | 8 - internal/server/server_helper.go | 22 + internal/users/manager.go | 1 + internal/users/user.go | 14 +- internal/wireguard/peermanager.go | 22 +- 12 files changed, 1724 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 145f6fb..aec0475 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ The following configuration options are available: | ADMIN_PASS | adminPass | core | wgportal | The administrator password. If unchanged, a random password will be set on first startup. | | EDITABLE_KEYS | editableKeys | core | true | Allow to edit key-pairs in the UI. | | CREATE_DEFAULT_PEER | createDefaultPeer | core | false | If an LDAP user logs in for the first time, a new WireGuard peer will be created on the WG_DEFAULT_DEVICE if this option is enabled. | +| SELF_PROVISIONING | selfProvisioning | core | false | Allow registered users to automatically create peers via the RESTful API. | | LDAP_ENABLED | ldapEnabled | core | false | Enable or disable the LDAP backend. | | SESSION_SECRET | sessionSecret | core | secret | Use a custom secret to encrypt session data. | | DATABASE_TYPE | typ | database | sqlite | Either mysql or sqlite. | @@ -191,6 +192,11 @@ wg: manageIPAddresses: true ``` +### RESTful API +WireGuard Portal offers a RESTful API to interact with. +The API is documented using OpenAPI 2.0, the Swagger UI can be found +under the URL `http:///swagger/index.html`. + ## What is out of scope * Generation or application of any `iptables` or `nftables` rules diff --git a/internal/authentication/providers/password/provider.go b/internal/authentication/providers/password/provider.go index 53ad77b..e185ac1 100644 --- a/internal/authentication/providers/password/provider.go +++ b/internal/authentication/providers/password/provider.go @@ -136,7 +136,7 @@ func (provider Provider) InitializeAdmin(email, password string) error { } admin.Email = email - admin.Password = string(hashedPassword) + admin.Password = users.PrivateString(hashedPassword) admin.Firstname = "WireGuard" admin.Lastname = "Administrator" admin.CreatedAt = time.Now() @@ -170,7 +170,7 @@ func (provider Provider) InitializeAdmin(email, password string) error { return errors.Wrap(err, "failed to hash admin password") } - admin.Password = string(hashedPassword) + admin.Password = users.PrivateString(hashedPassword) admin.IsAdmin = true admin.UpdatedAt = time.Now() diff --git a/internal/server/api.go b/internal/server/api.go index 3a0ed78..9576536 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -7,10 +7,13 @@ import ( "encoding/json" "net/http" "strings" + "time" jsonpatch "github.com/evanphx/json-patch" "github.com/gin-gonic/gin" + "github.com/h44z/wg-portal/internal/common" "github.com/h44z/wg-portal/internal/users" + "github.com/h44z/wg-portal/internal/wireguard" ) // @title WireGuard Portal API @@ -20,9 +23,18 @@ import ( // @license.name MIT // @license.url https://github.com/h44z/wg-portal/blob/master/LICENSE.txt +// @contact.name WireGuard Portal Project +// @contact.url https://github.com/h44z/wg-portal + // @securityDefinitions.basic ApiBasicAuth // @in header // @name Authorization +// @scope.admin Admin access required + +// @securityDefinitions.basic GeneralBasicAuth +// @in header +// @name Authorization +// @scope.user User access required // @BasePath /api/v1 @@ -36,24 +48,23 @@ type ApiError struct { } // GetUsers godoc +// @Tags Users // @Summary Retrieves all users // @Produce json // @Success 200 {object} []users.User // @Failure 401 {object} ApiError // @Failure 403 {object} ApiError // @Failure 404 {object} ApiError -// @Router /users [get] +// @Router /backend/users [get] // @Security ApiBasicAuth func (s *ApiServer) GetUsers(c *gin.Context) { allUsers := s.s.users.GetUsersUnscoped() - for i := range allUsers { - allUsers[i].Password = "" // do not publish password... - } c.JSON(http.StatusOK, allUsers) } // GetUser godoc +// @Tags Users // @Summary Retrieves user based on given Email // @Produce json // @Param email path string true "User Email" @@ -62,7 +73,7 @@ func (s *ApiServer) GetUsers(c *gin.Context) { // @Failure 401 {object} ApiError // @Failure 403 {object} ApiError // @Failure 404 {object} ApiError -// @Router /user/{email} [get] +// @Router /backend/user/{email} [get] // @Security ApiBasicAuth func (s *ApiServer) GetUser(c *gin.Context) { email := strings.ToLower(strings.TrimSpace(c.Param("email"))) @@ -76,20 +87,22 @@ func (s *ApiServer) GetUser(c *gin.Context) { c.JSON(http.StatusNotFound, ApiError{Message: "user not found"}) return } - user.Password = "" // do not send password... c.JSON(http.StatusOK, user) } // PostUser godoc +// @Tags Users // @Summary Creates a new user based on the given user model +// @Accept json // @Produce json +// @Param user body users.User true "User Model" // @Success 200 {object} users.User // @Failure 400 {object} ApiError // @Failure 401 {object} ApiError // @Failure 403 {object} ApiError // @Failure 404 {object} ApiError // @Failure 500 {object} ApiError -// @Router /users [post] +// @Router /backend/users [post] // @Security ApiBasicAuth func (s *ApiServer) PostUser(c *gin.Context) { newUser := users.User{} @@ -113,21 +126,23 @@ func (s *ApiServer) PostUser(c *gin.Context) { c.JSON(http.StatusNotFound, ApiError{Message: "user not found"}) return } - user.Password = "" // do not send password... c.JSON(http.StatusOK, user) } // PutUser godoc +// @Tags Users // @Summary Updates a user based on the given user model +// @Accept json // @Produce json // @Param email path string true "User Email" +// @Param user body users.User true "User Model" // @Success 200 {object} users.User // @Failure 400 {object} ApiError // @Failure 401 {object} ApiError // @Failure 403 {object} ApiError // @Failure 404 {object} ApiError // @Failure 500 {object} ApiError -// @Router /user/{email} [put] +// @Router /backend/user/{email} [put] // @Security ApiBasicAuth func (s *ApiServer) PutUser(c *gin.Context) { email := strings.ToLower(strings.TrimSpace(c.Param("email"))) @@ -163,21 +178,23 @@ func (s *ApiServer) PutUser(c *gin.Context) { c.JSON(http.StatusNotFound, ApiError{Message: "user not found"}) return } - user.Password = "" // do not send password... c.JSON(http.StatusOK, user) } // PatchUser godoc +// @Tags Users // @Summary Updates a user based on the given partial user model +// @Accept json // @Produce json // @Param email path string true "User Email" +// @Param user body users.User true "User Model" // @Success 200 {object} users.User // @Failure 400 {object} ApiError // @Failure 401 {object} ApiError // @Failure 403 {object} ApiError // @Failure 404 {object} ApiError // @Failure 500 {object} ApiError -// @Router /user/{email} [patch] +// @Router /backend/user/{email} [patch] // @Security ApiBasicAuth func (s *ApiServer) PatchUser(c *gin.Context) { email := strings.ToLower(strings.TrimSpace(c.Param("email"))) @@ -227,11 +244,11 @@ func (s *ApiServer) PatchUser(c *gin.Context) { c.JSON(http.StatusNotFound, ApiError{Message: "user not found"}) return } - user.Password = "" // do not send password... c.JSON(http.StatusOK, user) } // DeleteUser godoc +// @Tags Users // @Summary Deletes the specified user // @Produce json // @Param email path string true "User Email" @@ -241,7 +258,7 @@ func (s *ApiServer) PatchUser(c *gin.Context) { // @Failure 403 {object} ApiError // @Failure 404 {object} ApiError // @Failure 500 {object} ApiError -// @Router /user/{email} [delete] +// @Router /backend/user/{email} [delete] // @Security ApiBasicAuth func (s *ApiServer) DeleteUser(c *gin.Context) { email := strings.ToLower(strings.TrimSpace(c.Param("email"))) @@ -263,3 +280,590 @@ func (s *ApiServer) DeleteUser(c *gin.Context) { c.Status(http.StatusNoContent) } + +// GetPeers godoc +// @Tags Peers +// @Summary Retrieves all peers for the given interface +// @Produce json +// @Param device path string true "Device Name" +// @Success 200 {object} []wireguard.Peer +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Router /backend/peers/{device} [get] +// @Security ApiBasicAuth +func (s *ApiServer) GetPeers(c *gin.Context) { + deviceName := strings.ToLower(strings.TrimSpace(c.Param("device"))) + if deviceName == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must be specified"}) + return + } + + // validate device name + if !common.ListContains(s.s.config.WG.DeviceNames, deviceName) { + c.JSON(http.StatusNotFound, ApiError{Message: "unknown device"}) + return + } + + peers := s.s.peers.GetAllPeers(deviceName) + c.JSON(http.StatusOK, peers) +} + +// GetPeer godoc +// @Tags Peers +// @Summary Retrieves the peer for the given public key +// @Produce json +// @Param pkey path string true "Public Key (Base 64)" +// @Success 200 {object} wireguard.Peer +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Router /backend/peer/{pkey} [get] +// @Security ApiBasicAuth +func (s *ApiServer) GetPeer(c *gin.Context) { + pkey := c.Param("pkey") + if pkey == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must be specified"}) + return + } + + peer := s.s.peers.GetPeerByKey(pkey) + if !peer.IsValid() { + c.JSON(http.StatusNotFound, ApiError{Message: "peer does not exist"}) + return + } + c.JSON(http.StatusOK, peer) +} + +// PostPeer godoc +// @Tags Peers +// @Summary Creates a new peer based on the given peer model +// @Accept json +// @Produce json +// @Param device path string true "Device Name" +// @Param peer body wireguard.Peer true "Peer Model" +// @Success 200 {object} wireguard.Peer +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Failure 500 {object} ApiError +// @Router /backend/peers/{device} [post] +// @Security ApiBasicAuth +func (s *ApiServer) PostPeer(c *gin.Context) { + deviceName := strings.ToLower(strings.TrimSpace(c.Param("device"))) + if deviceName == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must be specified"}) + return + } + + // validate device name + if !common.ListContains(s.s.config.WG.DeviceNames, deviceName) { + c.JSON(http.StatusNotFound, ApiError{Message: "unknown device"}) + return + } + + newPeer := wireguard.Peer{} + if err := c.BindJSON(&newPeer); err != nil { + c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()}) + return + } + + if peer := s.s.peers.GetPeerByKey(newPeer.PublicKey); peer.IsValid() { + c.JSON(http.StatusBadRequest, ApiError{Message: "peer already exists"}) + return + } + + if err := s.s.CreatePeer(deviceName, newPeer); err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + peer := s.s.peers.GetPeerByKey(newPeer.PublicKey) + if !peer.IsValid() { + c.JSON(http.StatusNotFound, ApiError{Message: "peer not found"}) + return + } + c.JSON(http.StatusOK, peer) +} + +// PutPeer godoc +// @Tags Peers +// @Summary Updates the given peer based on the given peer model +// @Accept json +// @Produce json +// @Param pkey path string true "Public Key" +// @Param peer body wireguard.Peer true "Peer Model" +// @Success 200 {object} wireguard.Peer +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Failure 500 {object} ApiError +// @Router /backend/peer/{pkey} [put] +// @Security ApiBasicAuth +func (s *ApiServer) PutPeer(c *gin.Context) { + updatePeer := wireguard.Peer{} + if err := c.BindJSON(&updatePeer); err != nil { + c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()}) + return + } + + pkey := c.Param("pkey") + if pkey == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must be specified"}) + return + } + + if peer := s.s.peers.GetPeerByKey(pkey); !peer.IsValid() { + c.JSON(http.StatusNotFound, ApiError{Message: "peer does not exist"}) + return + } + + // Changing public key is not allowed + if pkey != updatePeer.PublicKey { + c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must match the model public key"}) + return + } + + now := time.Now() + if updatePeer.DeactivatedAt != nil { + updatePeer.DeactivatedAt = &now + } + if err := s.s.UpdatePeer(updatePeer, now); err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + peer := s.s.peers.GetPeerByKey(updatePeer.PublicKey) + if !peer.IsValid() { + c.JSON(http.StatusNotFound, ApiError{Message: "peer not found"}) + return + } + c.JSON(http.StatusOK, peer) +} + +// PatchPeer godoc +// @Tags Peers +// @Summary Updates the given peer based on the given partial peer model +// @Accept json +// @Produce json +// @Param pkey path string true "Public Key" +// @Param peer body wireguard.Peer true "Peer Model" +// @Success 200 {object} wireguard.Peer +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Failure 500 {object} ApiError +// @Router /backend/peer/{pkey} [patch] +// @Security ApiBasicAuth +func (s *ApiServer) PatchPeer(c *gin.Context) { + patch, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()}) + return + } + + pkey := c.Param("pkey") + if pkey == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must be specified"}) + return + } + + peer := s.s.peers.GetPeerByKey(pkey) + if !peer.IsValid() { + c.JSON(http.StatusNotFound, ApiError{Message: "peer does not exist"}) + return + } + + peerData, err := json.Marshal(peer) + if err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + mergedPeerData, err := jsonpatch.MergePatch(peerData, patch) + var mergedPeer wireguard.Peer + err = json.Unmarshal(mergedPeerData, &mergedPeer) + if err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + if !mergedPeer.IsValid() { + c.JSON(http.StatusBadRequest, ApiError{Message: "invalid peer model"}) + return + } + + // Changing public key is not allowed + if pkey != mergedPeer.PublicKey { + c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must match the model public key"}) + return + } + + now := time.Now() + if mergedPeer.DeactivatedAt != nil { + mergedPeer.DeactivatedAt = &now + } + if err := s.s.UpdatePeer(mergedPeer, now); err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + peer = s.s.peers.GetPeerByKey(mergedPeer.PublicKey) + if !peer.IsValid() { + c.JSON(http.StatusNotFound, ApiError{Message: "peer not found"}) + return + } + c.JSON(http.StatusOK, peer) +} + +// DeletePeer godoc +// @Tags Peers +// @Summary Updates the given peer based on the given partial peer model +// @Produce json +// @Param pkey path string true "Public Key" +// @Success 202 "No Content" +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Failure 500 {object} ApiError +// @Router /backend/peer/{pkey} [delete] +// @Security ApiBasicAuth +func (s *ApiServer) DeletePeer(c *gin.Context) { + pkey := c.Param("pkey") + if pkey == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must be specified"}) + return + } + + peer := s.s.peers.GetPeerByKey(pkey) + if peer.PublicKey == "" { + c.JSON(http.StatusNotFound, ApiError{Message: "peer does not exist"}) + return + } + + if err := s.s.DeletePeer(peer); err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + c.Status(http.StatusNoContent) +} + +// GetDevices godoc +// @Tags Interface +// @Summary Get all devices +// @Produce json +// @Success 200 {object} []wireguard.Device +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Router /backend/devices [get] +// @Security ApiBasicAuth +func (s *ApiServer) GetDevices(c *gin.Context) { + var devices []wireguard.Device + for _, deviceName := range s.s.config.WG.DeviceNames { + device := s.s.peers.GetDevice(deviceName) + if !device.IsValid() { + continue + } + devices = append(devices, device) + } + + c.JSON(http.StatusOK, devices) +} + +// GetDevice godoc +// @Tags Interface +// @Summary Get the given device +// @Produce json +// @Param device path string true "Device Name" +// @Success 200 {object} wireguard.Device +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Router /backend/device/{device} [get] +// @Security ApiBasicAuth +func (s *ApiServer) GetDevice(c *gin.Context) { + deviceName := strings.ToLower(strings.TrimSpace(c.Param("device"))) + if deviceName == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must be specified"}) + return + } + + // validate device name + if !common.ListContains(s.s.config.WG.DeviceNames, deviceName) { + c.JSON(http.StatusNotFound, ApiError{Message: "unknown device"}) + return + } + + device := s.s.peers.GetDevice(deviceName) + if !device.IsValid() { + c.JSON(http.StatusNotFound, ApiError{Message: "device not found"}) + return + } + + c.JSON(http.StatusOK, device) +} + +// PutDevice godoc +// @Tags Interface +// @Summary Updates the given device based on the given device model (UNIMPLEMENTED) +// @Accept json +// @Produce json +// @Param device path string true "Device Name" +// @Param body body wireguard.Device true "Device Model" +// @Success 200 {object} wireguard.Device +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Failure 500 {object} ApiError +// @Router /backend/device/{device} [put] +// @Security ApiBasicAuth +func (s *ApiServer) PutDevice(c *gin.Context) { + updateDevice := wireguard.Device{} + if err := c.BindJSON(&updateDevice); err != nil { + c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()}) + return + } + + deviceName := strings.ToLower(strings.TrimSpace(c.Param("device"))) + if deviceName == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must be specified"}) + return + } + + // validate device name + if !common.ListContains(s.s.config.WG.DeviceNames, deviceName) { + c.JSON(http.StatusNotFound, ApiError{Message: "unknown device"}) + return + } + + device := s.s.peers.GetDevice(deviceName) + if !device.IsValid() { + c.JSON(http.StatusNotFound, ApiError{Message: "peer not found"}) + return + } + + // Changing device name is not allowed + if deviceName != updateDevice.DeviceName { + c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must match the model device name"}) + return + } + + // TODO: implement + + c.JSON(http.StatusNotImplemented, device) +} + +// PatchDevice godoc +// @Tags Interface +// @Summary Updates the given device based on the given partial device model (UNIMPLEMENTED) +// @Accept json +// @Produce json +// @Param device path string true "Device Name" +// @Param body body wireguard.Device true "Device Model" +// @Success 200 {object} wireguard.Device +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Failure 500 {object} ApiError +// @Router /backend/device/{device} [patch] +// @Security ApiBasicAuth +func (s *ApiServer) PatchDevice(c *gin.Context) { + patch, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()}) + return + } + + deviceName := strings.ToLower(strings.TrimSpace(c.Param("device"))) + if deviceName == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must be specified"}) + return + } + + // validate device name + if !common.ListContains(s.s.config.WG.DeviceNames, deviceName) { + c.JSON(http.StatusNotFound, ApiError{Message: "unknown device"}) + return + } + + device := s.s.peers.GetDevice(deviceName) + if !device.IsValid() { + c.JSON(http.StatusNotFound, ApiError{Message: "peer not found"}) + return + } + + deviceData, err := json.Marshal(device) + if err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + mergedDeviceData, err := jsonpatch.MergePatch(deviceData, patch) + var mergedDevice wireguard.Device + err = json.Unmarshal(mergedDeviceData, &mergedDevice) + if err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + if !mergedDevice.IsValid() { + c.JSON(http.StatusBadRequest, ApiError{Message: "invalid device model"}) + return + } + + // Changing device name is not allowed + if deviceName != mergedDevice.DeviceName { + c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must match the model device name"}) + return + } + + // TODO: implement + + c.JSON(http.StatusNotImplemented, device) +} + +// GetPeerDeploymentConfig godoc +// @Tags Provisioning +// @Summary Retrieves the peer config for the given public key +// @Produce plain +// @Param pkey path string true "Public Key (Base 64)" +// @Success 200 {object} string "The WireGuard configuration file" +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Router /provisioning/peer/{pkey} [get] +// @Security GeneralBasicAuth +func (s *ApiServer) GetPeerDeploymentConfig(c *gin.Context) { + pkey := c.Param("pkey") + if pkey == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must be specified"}) + return + } + + peer := s.s.peers.GetPeerByKey(pkey) + if !peer.IsValid() { + c.JSON(http.StatusNotFound, ApiError{Message: "peer does not exist"}) + return + } + + // Get authenticated user to check permissions + username, _, _ := c.Request.BasicAuth() + user := s.s.users.GetUser(username) + + if !user.IsAdmin && user.Email == peer.Email { + c.JSON(http.StatusForbidden, ApiError{Message: "not enough permissions to access this resource"}) + return + } + + device := s.s.peers.GetDevice(peer.DeviceName) + config, err := peer.GetConfigFile(device) + if err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + c.Data(http.StatusOK, "text/plain", config) +} + +type ProvisioningRequest struct { + // DeviceName is optional, if not specified, the configured default device will be used. + DeviceName string `json:",omitempty"` + Identifier string `binding:"required"` + Email string `binding:"required"` + + // Client specific and optional settings + + AllowedIPsStr string `binding:"cidrlist" json:",omitempty"` + PersistentKeepalive int `binding:"gte=0" json:",omitempty"` + DNSStr string `binding:"iplist" json:",omitempty"` + Mtu int `binding:"gte=0,lte=1500" json:",omitempty"` +} + +// PostPeerDeploymentConfig godoc +// @Tags Provisioning +// @Summary Creates the requested peer config and returns the config file +// @Accept json +// @Produce plain +// @Param body body ProvisioningRequest true "Provisioning Request Model" +// @Success 200 {object} string "The WireGuard configuration file" +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Router /provisioning/peer [post] +// @Security GeneralBasicAuth +func (s *ApiServer) PostPeerDeploymentConfig(c *gin.Context) { + req := ProvisioningRequest{} + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()}) + return + } + + // Get authenticated user to check permissions + username, _, _ := c.Request.BasicAuth() + user := s.s.users.GetUser(username) + + if !user.IsAdmin && !s.s.config.Core.SelfProvisioningAllowed { + c.JSON(http.StatusForbidden, ApiError{Message: "peer provisioning service disabled"}) + return + } + + if !user.IsAdmin && user.Email == req.Email { + c.JSON(http.StatusForbidden, ApiError{Message: "not enough permissions to access this resource"}) + return + } + + deviceName := req.DeviceName + if deviceName == "" || !common.ListContains(s.s.config.WG.DeviceNames, deviceName) { + deviceName = s.s.config.WG.GetDefaultDeviceName() + } + device := s.s.peers.GetDevice(deviceName) + if device.Type != wireguard.DeviceTypeServer { + c.JSON(http.StatusForbidden, ApiError{Message: "invalid device, provisioning disabled"}) + return + } + + // check if private/public keys are set, if so check database for existing entries + peer, err := s.s.PrepareNewPeer(deviceName) + if err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + peer.Email = req.Email + peer.Identifier = req.Identifier + + if req.AllowedIPsStr != "" { + peer.AllowedIPsStr = req.AllowedIPsStr + } + if req.PersistentKeepalive != 0 { + peer.PersistentKeepalive = req.PersistentKeepalive + } + if req.DNSStr != "" { + peer.DNSStr = req.DNSStr + } + if req.Mtu != 0 { + peer.Mtu = req.Mtu + } + + if err := s.s.CreatePeer(deviceName, peer); err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + config, err := peer.GetConfigFile(device) + if err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + c.Data(http.StatusOK, "text/plain", config) +} diff --git a/internal/server/configuration.go b/internal/server/configuration.go index 7cf6ed6..6b8f2e6 100644 --- a/internal/server/configuration.go +++ b/internal/server/configuration.go @@ -55,17 +55,18 @@ func loadConfigEnv(cfg interface{}) error { type Config struct { Core struct { - ListeningAddress string `yaml:"listeningAddress" envconfig:"LISTENING_ADDRESS"` - ExternalUrl string `yaml:"externalUrl" envconfig:"EXTERNAL_URL"` - Title string `yaml:"title" envconfig:"WEBSITE_TITLE"` - CompanyName string `yaml:"company" envconfig:"COMPANY_NAME"` - MailFrom string `yaml:"mailFrom" envconfig:"MAIL_FROM"` - AdminUser string `yaml:"adminUser" envconfig:"ADMIN_USER"` // must be an email address - AdminPassword string `yaml:"adminPass" envconfig:"ADMIN_PASS"` - EditableKeys bool `yaml:"editableKeys" envconfig:"EDITABLE_KEYS"` - CreateDefaultPeer bool `yaml:"createDefaultPeer" envconfig:"CREATE_DEFAULT_PEER"` - LdapEnabled bool `yaml:"ldapEnabled" envconfig:"LDAP_ENABLED"` - SessionSecret string `yaml:"sessionSecret" envconfig:"SESSION_SECRET"` + ListeningAddress string `yaml:"listeningAddress" envconfig:"LISTENING_ADDRESS"` + ExternalUrl string `yaml:"externalUrl" envconfig:"EXTERNAL_URL"` + Title string `yaml:"title" envconfig:"WEBSITE_TITLE"` + CompanyName string `yaml:"company" envconfig:"COMPANY_NAME"` + MailFrom string `yaml:"mailFrom" envconfig:"MAIL_FROM"` + AdminUser string `yaml:"adminUser" envconfig:"ADMIN_USER"` // must be an email address + AdminPassword string `yaml:"adminPass" envconfig:"ADMIN_PASS"` + EditableKeys bool `yaml:"editableKeys" envconfig:"EDITABLE_KEYS"` + CreateDefaultPeer bool `yaml:"createDefaultPeer" envconfig:"CREATE_DEFAULT_PEER"` + SelfProvisioningAllowed bool `yaml:"selfProvisioning" envconfig:"SELF_PROVISIONING"` + LdapEnabled bool `yaml:"ldapEnabled" envconfig:"LDAP_ENABLED"` + SessionSecret string `yaml:"sessionSecret" envconfig:"SESSION_SECRET"` } `yaml:"core"` Database common.DatabaseConfig `yaml:"database"` Email common.MailConfig `yaml:"email"` diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go index c0d1f25..ab7026d 100644 --- a/internal/server/docs/docs.go +++ b/internal/server/docs/docs.go @@ -18,7 +18,10 @@ var doc = `{ "info": { "description": "{{.Description}}", "title": "{{.Title}}", - "contact": {}, + "contact": { + "name": "WireGuard Portal Project", + "url": "https://github.com/h44z/wg-portal" + }, "license": { "name": "MIT", "url": "https://github.com/h44z/wg-portal/blob/master/LICENSE.txt" @@ -28,7 +31,7 @@ var doc = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/user/{email}": { + "/backend/device/{device}": { "get": { "security": [ { @@ -38,6 +41,645 @@ var doc = `{ "produces": [ "application/json" ], + "tags": [ + "Interface" + ], + "summary": "Get the given device", + "parameters": [ + { + "type": "string", + "description": "Device Name", + "name": "device", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/wireguard.Device" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + }, + "put": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Interface" + ], + "summary": "Updates the given device based on the given device model (UNIMPLEMENTED)", + "parameters": [ + { + "type": "string", + "description": "Device Name", + "name": "device", + "in": "path", + "required": true + }, + { + "description": "Device Model", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/wireguard.Device" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/wireguard.Device" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + }, + "patch": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Interface" + ], + "summary": "Updates the given device based on the given partial device model (UNIMPLEMENTED)", + "parameters": [ + { + "type": "string", + "description": "Device Name", + "name": "device", + "in": "path", + "required": true + }, + { + "description": "Device Model", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/wireguard.Device" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/wireguard.Device" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + } + }, + "/backend/devices": { + "get": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Interface" + ], + "summary": "Get all devices", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/wireguard.Device" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + } + }, + "/backend/peer/{pkey}": { + "get": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Peers" + ], + "summary": "Retrieves the peer for the given public key", + "parameters": [ + { + "type": "string", + "description": "Public Key (Base 64)", + "name": "pkey", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/wireguard.Peer" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + }, + "put": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Peers" + ], + "summary": "Updates the given peer based on the given peer model", + "parameters": [ + { + "type": "string", + "description": "Public Key", + "name": "pkey", + "in": "path", + "required": true + }, + { + "description": "Peer Model", + "name": "peer", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/wireguard.Peer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/wireguard.Peer" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Peers" + ], + "summary": "Updates the given peer based on the given partial peer model", + "parameters": [ + { + "type": "string", + "description": "Public Key", + "name": "pkey", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + }, + "patch": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Peers" + ], + "summary": "Updates the given peer based on the given partial peer model", + "parameters": [ + { + "type": "string", + "description": "Public Key", + "name": "pkey", + "in": "path", + "required": true + }, + { + "description": "Peer Model", + "name": "peer", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/wireguard.Peer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/wireguard.Peer" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + } + }, + "/backend/peers/{device}": { + "get": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Peers" + ], + "summary": "Retrieves all peers for the given interface", + "parameters": [ + { + "type": "string", + "description": "Device Name", + "name": "device", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/wireguard.Peer" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + }, + "post": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Peers" + ], + "summary": "Creates a new peer based on the given peer model", + "parameters": [ + { + "type": "string", + "description": "Device Name", + "name": "device", + "in": "path", + "required": true + }, + { + "description": "Peer Model", + "name": "peer", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/wireguard.Peer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/wireguard.Peer" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + } + }, + "/backend/user/{email}": { + "get": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], "summary": "Retrieves user based on given Email", "parameters": [ { @@ -87,9 +729,15 @@ var doc = `{ "ApiBasicAuth": [] } ], + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], + "tags": [ + "Users" + ], "summary": "Updates a user based on the given user model", "parameters": [ { @@ -98,6 +746,15 @@ var doc = `{ "name": "email", "in": "path", "required": true + }, + { + "description": "User Model", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/users.User" + } } ], "responses": { @@ -148,6 +805,9 @@ var doc = `{ "produces": [ "application/json" ], + "tags": [ + "Users" + ], "summary": "Deletes the specified user", "parameters": [ { @@ -200,9 +860,15 @@ var doc = `{ "ApiBasicAuth": [] } ], + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], + "tags": [ + "Users" + ], "summary": "Updates a user based on the given partial user model", "parameters": [ { @@ -211,6 +877,15 @@ var doc = `{ "name": "email", "in": "path", "required": true + }, + { + "description": "User Model", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/users.User" + } } ], "responses": { @@ -253,7 +928,7 @@ var doc = `{ } } }, - "/users": { + "/backend/users": { "get": { "security": [ { @@ -263,6 +938,9 @@ var doc = `{ "produces": [ "application/json" ], + "tags": [ + "Users" + ], "summary": "Retrieves all users", "responses": { "200": { @@ -300,10 +978,27 @@ var doc = `{ "ApiBasicAuth": [] } ], + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], + "tags": [ + "Users" + ], "summary": "Creates a new user based on the given user model", + "parameters": [ + { + "description": "User Model", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/users.User" + } + } + ], "responses": { "200": { "description": "OK", @@ -343,6 +1038,113 @@ var doc = `{ } } } + }, + "/provisioning/peer": { + "post": { + "security": [ + { + "GeneralBasicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "Provisioning" + ], + "summary": "Creates the requested peer config and returns the config file", + "parameters": [ + { + "description": "Provisioning Request Model", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.ProvisioningRequest" + } + } + ], + "responses": { + "200": { + "description": "The WireGuard configuration file", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + } + }, + "/provisioning/peer/{pkey}": { + "get": { + "security": [ + { + "GeneralBasicAuth": [] + } + ], + "produces": [ + "text/plain" + ], + "tags": [ + "Provisioning" + ], + "summary": "Retrieves the peer config for the given public key", + "parameters": [ + { + "type": "string", + "description": "Public Key (Base 64)", + "name": "pkey", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The WireGuard configuration file", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + } } }, "definitions": { @@ -366,6 +1168,37 @@ var doc = `{ } } }, + "server.ProvisioningRequest": { + "type": "object", + "required": [ + "email", + "identifier" + ], + "properties": { + "allowedIPsStr": { + "type": "string" + }, + "deviceName": { + "description": "DeviceName is optional, if not specified, the configured default device will be used.", + "type": "string" + }, + "dnsstr": { + "type": "string" + }, + "email": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "mtu": { + "type": "integer" + }, + "persistentKeepalive": { + "type": "integer" + } + } + }, "users.User": { "type": "object", "required": [ @@ -409,11 +1242,174 @@ var doc = `{ "type": "string" } } + }, + "wireguard.Device": { + "type": "object", + "required": [ + "deviceName", + "ipsStr", + "privateKey", + "publicKey", + "type" + ], + "properties": { + "createdAt": { + "type": "string" + }, + "defaultAllowedIPsStr": { + "description": "comma separated list of IPs that are used in the client config file", + "type": "string" + }, + "defaultEndpoint": { + "description": "Settings that are applied to all peer by default", + "type": "string" + }, + "defaultPersistentKeepalive": { + "type": "integer" + }, + "deviceName": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "dnsstr": { + "description": "comma separated list of the DNS servers of the client, wg-quick addition", + "type": "string" + }, + "firewallMark": { + "type": "integer" + }, + "ipsStr": { + "description": "comma separated list of the IPs of the client, wg-quick addition", + "type": "string" + }, + "listenPort": { + "type": "integer" + }, + "mtu": { + "description": "the interface MTU, wg-quick addition", + "type": "integer" + }, + "postDown": { + "description": "post down script, wg-quick addition", + "type": "string" + }, + "postUp": { + "description": "post up script, wg-quick addition", + "type": "string" + }, + "preDown": { + "description": "pre down script, wg-quick addition", + "type": "string" + }, + "preUp": { + "description": "pre up script, wg-quick addition", + "type": "string" + }, + "privateKey": { + "description": "Core WireGuard Settings (Interface section)", + "type": "string" + }, + "publicKey": { + "description": "Misc. WireGuard Settings", + "type": "string" + }, + "routingTable": { + "description": "the routing table, wg-quick addition", + "type": "string" + }, + "saveConfig": { + "description": "if set to ` + "`" + `true', the configuration is saved from the current state of the interface upon shutdown, wg-quick addition", + "type": "boolean" + }, + "type": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "wireguard.Peer": { + "type": "object", + "required": [ + "deviceName", + "email", + "identifier", + "publicKey" + ], + "properties": { + "allowedIPsStr": { + "description": "a comma separated list of IPs that are used in the client config file", + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "createdBy": { + "type": "string" + }, + "deactivatedAt": { + "type": "string" + }, + "deviceName": { + "type": "string" + }, + "dnsstr": { + "description": "comma separated list of the DNS servers for the client", + "type": "string" + }, + "email": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "identifier": { + "description": "Identifier AND Email make a WireGuard peer unique", + "type": "string" + }, + "ignoreGlobalSettings": { + "type": "boolean" + }, + "ipsStr": { + "description": "a comma separated list of IPs of the client", + "type": "string" + }, + "mtu": { + "description": "Global Device Settings (can be ignored, only make sense if device is in server mode)", + "type": "integer" + }, + "persistentKeepalive": { + "type": "integer" + }, + "presharedKey": { + "type": "string" + }, + "privateKey": { + "description": "Misc. WireGuard Settings", + "type": "string" + }, + "publicKey": { + "description": "Core WireGuard Settings", + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "updatedBy": { + "type": "string" + } + } } }, "securityDefinitions": { "ApiBasicAuth": { "type": "basic" + }, + "GeneralBasicAuth": { + "type": "basic" } } }` diff --git a/internal/server/handlers_user.go b/internal/server/handlers_user.go index 643e56f..c0816f5 100644 --- a/internal/server/handlers_user.go +++ b/internal/server/handlers_user.go @@ -8,7 +8,6 @@ import ( "github.com/gin-gonic/gin" "github.com/h44z/wg-portal/internal/users" csrf "github.com/utrack/gin-csrf" - "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -105,19 +104,6 @@ func (s *Server) PostAdminUsersEdit(c *gin.Context) { return } - if formUser.Password != "" { - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(formUser.Password), bcrypt.DefaultCost) - if err != nil { - _ = s.updateFormInSession(c, formUser) - SetFlashMessage(c, "failed to hash admin password", "danger") - c.Redirect(http.StatusSeeOther, "/admin/users/edit?pkey="+urlEncodedKey+"&formerr=bind") - return - } - formUser.Password = string(hashedPassword) - } else { - formUser.Password = currentUser.Password - } - disabled := c.PostForm("isdisabled") != "" if disabled { formUser.DeletedAt = gorm.DeletedAt{ @@ -175,15 +161,7 @@ func (s *Server) PostAdminUsersCreate(c *gin.Context) { return } - if formUser.Password != "" { - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(formUser.Password), bcrypt.DefaultCost) - if err != nil { - SetFlashMessage(c, "failed to hash admin password", "danger") - c.Redirect(http.StatusSeeOther, "/admin/users/create?formerr=bind") - return - } - formUser.Password = string(hashedPassword) - } else { + if formUser.Password == "" { _ = s.updateFormInSession(c, formUser) SetFlashMessage(c, "invalid password", "danger") c.Redirect(http.StatusSeeOther, "/admin/users/create?formerr=create") diff --git a/internal/server/routes.go b/internal/server/routes.go index a876fa2..7c4598e 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -10,9 +10,18 @@ import ( _ "github.com/h44z/wg-portal/internal/server/docs" // docs is generated by Swag CLI, you have to import it. ginSwagger "github.com/swaggo/gin-swagger" "github.com/swaggo/gin-swagger/swaggerFiles" + csrf "github.com/utrack/gin-csrf" ) func SetupRoutes(s *Server) { + csrfMiddleware := csrf.Middleware(csrf.Options{ + Secret: s.config.Core.SessionSecret, + ErrorFunc: func(c *gin.Context) { + c.String(400, "CSRF token mismatch") + c.Abort() + }, + }) + // Startpage s.server.GET("/", s.GetIndex) s.server.GET("/favicon.ico", func(c *gin.Context) { @@ -26,12 +35,14 @@ func SetupRoutes(s *Server) { // Auth routes auth := s.server.Group("/auth") + auth.Use(csrfMiddleware) auth.GET("/login", s.GetLogin) auth.POST("/login", s.PostLogin) auth.GET("/logout", s.GetLogout) // Admin routes admin := s.server.Group("/admin") + admin.Use(csrfMiddleware) admin.Use(s.RequireAuthentication("admin")) admin.GET("/", s.GetAdminIndex) admin.GET("/device/edit", s.GetAdminEditInterface) @@ -57,6 +68,7 @@ func SetupRoutes(s *Server) { // User routes user := s.server.Group("/user") + user.Use(csrfMiddleware) user.Use(s.RequireAuthentication("")) // empty scope = all logged in users user.GET("/qrcode", s.GetPeerQRCode) user.GET("/profile", s.GetUserIndex) @@ -68,15 +80,35 @@ func SetupRoutes(s *Server) { func SetupApiRoutes(s *Server) { api := ApiServer{s: s} - // Auth routes - apiV1 := s.server.Group("/api/v1") - apiV1.Use(s.RequireApiAuthentication("admin")) - apiV1.GET("/users", api.GetUsers) - apiV1.POST("/users", api.PostUser) - apiV1.GET("/user/:email", api.GetUser) - apiV1.PUT("/user/:email", api.PutUser) - apiV1.PATCH("/user/:email", api.PatchUser) - apiV1.DELETE("/user/:email", api.DeleteUser) + // Admin authenticated routes + apiV1Backend := s.server.Group("/api/v1/backend") + apiV1Backend.Use(s.RequireApiAuthentication("admin")) + + apiV1Backend.GET("/users", api.GetUsers) + apiV1Backend.POST("/users", api.PostUser) + apiV1Backend.GET("/user/:email", api.GetUser) + apiV1Backend.PUT("/user/:email", api.PutUser) + apiV1Backend.PATCH("/user/:email", api.PatchUser) + apiV1Backend.DELETE("/user/:email", api.DeleteUser) + + apiV1Backend.GET("/peers/:device", api.GetPeers) + apiV1Backend.POST("/peers/:device", api.PostPeer) + apiV1Backend.GET("/peer/:pkey", api.GetPeer) + apiV1Backend.PUT("/peer/:pkey", api.PutPeer) + apiV1Backend.PATCH("/peer/:pkey", api.PatchPeer) + apiV1Backend.DELETE("/peer/:pkey", api.DeletePeer) + + apiV1Backend.GET("/devices", api.GetDevices) + apiV1Backend.GET("/device/:device", api.GetDevice) + apiV1Backend.PUT("/device/:device", api.PutDevice) + apiV1Backend.PATCH("/device/:device", api.PatchDevice) + + // Simple authenticated routes + apiV1Deployment := s.server.Group("/api/v1/provisioning") + apiV1Deployment.Use(s.RequireApiAuthentication("")) + + apiV1Deployment.GET("/peer/:pkey", api.GetPeerDeploymentConfig) + apiV1Deployment.POST("/peer", api.PostPeerDeploymentConfig) // Swagger doc/ui s.server.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) diff --git a/internal/server/server.go b/internal/server/server.go index 7577211..635819e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -26,7 +26,6 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" ginlogrus "github.com/toorop/gin-logrus" - csrf "github.com/utrack/gin-csrf" "gorm.io/gorm" ) @@ -118,13 +117,6 @@ func (s *Server) Setup(ctx context.Context) error { } s.server.Use(gin.Recovery()) s.server.Use(sessions.Sessions("authsession", memstore.NewStore([]byte(s.config.Core.SessionSecret)))) - s.server.Use(csrf.Middleware(csrf.Options{ - Secret: s.config.Core.SessionSecret, - ErrorFunc: func(c *gin.Context) { - c.String(400, "CSRF token mismatch") - c.Abort() - }, - })) s.server.SetFuncMap(template.FuncMap{ "formatBytes": common.ByteCountSI, "urlEncode": url.QueryEscape, diff --git a/internal/server/server_helper.go b/internal/server/server_helper.go index 5eca32d..2001c01 100644 --- a/internal/server/server_helper.go +++ b/internal/server/server_helper.go @@ -12,6 +12,7 @@ import ( "github.com/h44z/wg-portal/internal/wireguard" "github.com/pkg/errors" "github.com/sirupsen/logrus" + "golang.org/x/crypto/bcrypt" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "gorm.io/gorm" ) @@ -52,6 +53,7 @@ func (s *Server) PrepareNewPeer(device string) (wireguard.Peer, error) { peer.PersistentKeepalive = dev.DefaultPersistentKeepalive peer.AllowedIPsStr = dev.DefaultAllowedIPsStr peer.Mtu = dev.Mtu + peer.DeviceName = device case wireguard.DeviceTypeClient: peer.UID = "newendpoint" } @@ -225,6 +227,15 @@ func (s *Server) CreateUser(user users.User, device string) error { return s.UpdateUser(user) } + // Hash user password (if set) + if user.Password != "" { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) + if err != nil { + return errors.Wrap(err, "unable to hash password") + } + user.Password = users.PrivateString(hashedPassword) + } + // Create user in database if err := s.users.CreateUser(&user); err != nil { return errors.WithMessage(err, "failed to create user in manager") @@ -243,6 +254,17 @@ func (s *Server) UpdateUser(user users.User) error { currentUser := s.users.GetUserUnscoped(user.Email) + // Hash user password (if set) + if user.Password != "" { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) + if err != nil { + return errors.Wrap(err, "unable to hash password") + } + user.Password = users.PrivateString(hashedPassword) + } else { + user.Password = currentUser.Password // keep current password + } + // Update in database if err := s.users.UpdateUser(&user); err != nil { return errors.WithMessage(err, "failed to update user in manager") diff --git a/internal/users/manager.go b/internal/users/manager.go index 009c1ed..53fb5d4 100644 --- a/internal/users/manager.go +++ b/internal/users/manager.go @@ -142,6 +142,7 @@ func (m Manager) GetOrCreateUserUnscoped(email string) (*User, error) { func (m Manager) CreateUser(user *User) error { user.Email = strings.ToLower(user.Email) + user.Source = UserSourceDatabase res := m.db.Create(user) if res.Error != nil { return errors.Wrapf(res.Error, "failed to create user %s", user.Email) diff --git a/internal/users/user.go b/internal/users/user.go index 39a5851..f01fc87 100644 --- a/internal/users/user.go +++ b/internal/users/user.go @@ -14,6 +14,16 @@ const ( UserSourceOIDC UserSource = "oidc" // open id connect, TODO: implement ) +type PrivateString string + +func (PrivateString) MarshalJSON() ([]byte, error) { + return []byte(`""`), nil +} + +func (PrivateString) String() string { + return "" +} + // User is the user model that gets linked to peer entries, by default an empty usermodel with only the email address is created type User struct { // required fields @@ -27,10 +37,10 @@ type User struct { Phone string `form:"phone" binding:"omitempty"` // optional, integrated password authentication - Password string `form:"password" binding:"omitempty"` + Password PrivateString `form:"password" binding:"omitempty"` // database internal fields CreatedAt time.Time UpdatedAt time.Time - DeletedAt gorm.DeletedAt `gorm:"index"` + DeletedAt gorm.DeletedAt `gorm:"index" json:",omitempty"` } diff --git a/internal/wireguard/peermanager.go b/internal/wireguard/peermanager.go index b0aa929..5a3bdf4 100644 --- a/internal/wireguard/peermanager.go +++ b/internal/wireguard/peermanager.go @@ -63,21 +63,21 @@ func init() { // type Peer struct { - Peer *wgtypes.Peer `gorm:"-"` // WireGuard peer - Device *Device `gorm:"foreignKey:DeviceName" binding:"-"` // linked WireGuard device - Config string `gorm:"-"` + Peer *wgtypes.Peer `gorm:"-" json:"-"` // WireGuard peer + Device *Device `gorm:"foreignKey:DeviceName" binding:"-" json:"-"` // linked WireGuard device + Config string `gorm:"-" json:"-"` - UID string `form:"uid" binding:"required,alphanum"` // uid for html identification + UID string `form:"uid" binding:"required,alphanum" json:"-"` // uid for html identification DeviceName string `gorm:"index" form:"device" binding:"required"` - DeviceType DeviceType `gorm:"-" form:"devicetype" binding:"required,oneof=client server"` + DeviceType DeviceType `gorm:"-" form:"devicetype" binding:"required,oneof=client server" json:"-"` Identifier string `form:"identifier" binding:"required,max=64"` // Identifier AND Email make a WireGuard peer unique Email string `gorm:"index" form:"mail" binding:"required,email"` IgnoreGlobalSettings bool `form:"ignoreglobalsettings"` - IsOnline bool `gorm:"-"` - IsNew bool `gorm:"-"` - LastHandshake string `gorm:"-"` - LastHandshakeTime string `gorm:"-"` + IsOnline bool `gorm:"-" json:"-"` + IsNew bool `gorm:"-" json:"-"` + LastHandshake string `gorm:"-" json:"-"` + LastHandshakeTime string `gorm:"-" json:"-"` // Core WireGuard Settings PublicKey string `gorm:"primaryKey" form:"pubkey" binding:"required,base64"` // the public key of the peer itself @@ -93,7 +93,7 @@ type Peer struct { // Global Device Settings (can be ignored, only make sense if device is in server mode) Mtu int `form:"mtu" binding:"gte=0,lte=1500"` - DeactivatedAt *time.Time + DeactivatedAt *time.Time `json:",omitempty"` CreatedBy string UpdatedBy string CreatedAt time.Time @@ -226,7 +226,7 @@ const ( ) type Device struct { - Interface *wgtypes.Device `gorm:"-"` + Interface *wgtypes.Device `gorm:"-" json:"-"` Type DeviceType `form:"devicetype" binding:"required,oneof=client server"` DeviceName string `form:"device" gorm:"primaryKey" binding:"required,alphanum"`