RESTful API for WireGuard Portal (#11)

This commit is contained in:
Christoph Haas 2021-04-26 22:00:50 +02:00
parent 35513ae994
commit 87964f8ec4
12 changed files with 1724 additions and 82 deletions

View File

@ -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://<your wg-portal ip/domain>/swagger/index.html`.
## What is out of scope
* Generation or application of any `iptables` or `nftables` rules

View File

@ -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()

View File

@ -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)
}

View File

@ -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"`

File diff suppressed because it is too large Load Diff

View File

@ -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")

View File

@ -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))

View File

@ -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,

View File

@ -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")

View File

@ -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)

View File

@ -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"`
}

View File

@ -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"`