Fixes & API unit testing (#58)

* api - add OperationID

  helps when using pyswagger and is visible via
  http://localhost:8123/swagger/index.html?displayOperationId=true
  gin-swagger can not set displayOperationId yet

* api - match paramters to their property equivalents

  pascalcase & sometimes replacing the name (e.g. device -> DeviceName)

* api - use ShouldBindJSON instead of BindJSON

 BindJSON sets the content-type text/plain

* api - we renamed, we regenerated

* device - allow - in DeviceName wg-example0.conf etc

* api - more pascalcase & argument renames

* api - marshal DeletedAt as string

  gorm.DeletedAt is of type sql.NullTime
  NullTime declares Time & Valid as properties
  DeletedAt marshals as time.Time
  swaggertype allows only basic types
  -> string

* Peer - export UID/DeviceType in json
 UID/DeviceType is required, skipping in json, skips it in marshalling,
 next unmarshalling fails

* assets - name forms for use with mechanize

* api - match error message

* add python3/pyswagger based unittesting
 - initializes a clean install by configuration via web service
 - tests the rest api

* tests - test address exhaustion

* tests - test network expansion

Co-authored-by: Markus Koetter <koetter@cispa.de>
This commit is contained in:
commonism 2021-09-29 18:41:13 +02:00 committed by GitHub
parent 93db475eee
commit 19c58fb5af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 669 additions and 108 deletions

View File

@ -200,7 +200,9 @@ wg:
### 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`.
under the URL `http://<your wg-portal ip/domain>/swagger/index.html?displayOperationId=true`.
The [API's unittesting](tests/test_API.py) may serve as an example how to make use of the API with python3 & pyswagger.
## What is out of scope
* Creating or removing WireGuard (wgX) interfaces.

View File

@ -28,7 +28,7 @@
<div id="configContent" class="tab-content">
<!-- server mode -->
<div class="tab-pane fade {{if eq .Device.Type "server"}}active show{{end}}" id="server">
<form method="post" enctype="multipart/form-data">
<form method="post" enctype="multipart/form-data" name="server">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<input type="hidden" name="device" value="{{.Device.DeviceName}}">
<input type="hidden" name="devicetype" value="server">
@ -162,7 +162,7 @@
<!-- client mode -->
<div class="tab-pane fade {{if eq .Device.Type "client"}}active show{{end}}" id="client">
<form method="post" enctype="multipart/form-data">
<form method="post" enctype="multipart/form-data" name="client">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<input type="hidden" name="device" value="{{.Device.DeviceName}}">
<input type="hidden" name="devicetype" value="client">

View File

@ -27,7 +27,7 @@
<div class="card mt-5">
<div class="card-header">Please sign in</div>
<div class="card-body">
<form class="form-signin" method="post">
<form class="form-signin" method="post" name="login">
<input type="hidden" name="_csrf" value="{{.Csrf}}">
<div class="form-group">
<label for="inputUsername">Username</label>

View File

@ -50,6 +50,7 @@ type ApiError struct {
// GetUsers godoc
// @Tags Users
// @Summary Retrieves all users
// @ID GetUsers
// @Produce json
// @Success 200 {object} []users.User
// @Failure 401 {object} ApiError
@ -66,8 +67,9 @@ func (s *ApiServer) GetUsers(c *gin.Context) {
// GetUser godoc
// @Tags Users
// @Summary Retrieves user based on given Email
// @ID GetUser
// @Produce json
// @Param email query string true "User Email"
// @Param Email query string true "User Email"
// @Success 200 {object} users.User
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
@ -76,9 +78,9 @@ func (s *ApiServer) GetUsers(c *gin.Context) {
// @Router /backend/user [get]
// @Security ApiBasicAuth
func (s *ApiServer) GetUser(c *gin.Context) {
email := strings.ToLower(strings.TrimSpace(c.Query("email")))
email := strings.ToLower(strings.TrimSpace(c.Query("Email")))
if email == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "Email parameter must be specified"})
return
}
@ -93,9 +95,10 @@ func (s *ApiServer) GetUser(c *gin.Context) {
// PostUser godoc
// @Tags Users
// @Summary Creates a new user based on the given user model
// @ID PostUser
// @Accept json
// @Produce json
// @Param user body users.User true "User Model"
// @Param User body users.User true "User Model"
// @Success 200 {object} users.User
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
@ -106,7 +109,7 @@ func (s *ApiServer) GetUser(c *gin.Context) {
// @Security ApiBasicAuth
func (s *ApiServer) PostUser(c *gin.Context) {
newUser := users.User{}
if err := c.BindJSON(&newUser); err != nil {
if err := c.ShouldBindJSON(&newUser); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
@ -132,10 +135,11 @@ func (s *ApiServer) PostUser(c *gin.Context) {
// PutUser godoc
// @Tags Users
// @Summary Updates a user based on the given user model
// @ID PutUser
// @Accept json
// @Produce json
// @Param email query string true "User Email"
// @Param user body users.User true "User Model"
// @Param Email query 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
@ -145,21 +149,21 @@ func (s *ApiServer) PostUser(c *gin.Context) {
// @Router /backend/user [put]
// @Security ApiBasicAuth
func (s *ApiServer) PutUser(c *gin.Context) {
email := strings.ToLower(strings.TrimSpace(c.Query("email")))
email := strings.ToLower(strings.TrimSpace(c.Query("Email")))
if email == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "Email parameter must be specified"})
return
}
updateUser := users.User{}
if err := c.BindJSON(&updateUser); err != nil {
if err := c.ShouldBindJSON(&updateUser); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
// Changing email address is not allowed
if email != updateUser.Email {
c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must match the model email address"})
c.JSON(http.StatusBadRequest, ApiError{Message: "Email parameter must match the model email address"})
return
}
@ -184,10 +188,11 @@ func (s *ApiServer) PutUser(c *gin.Context) {
// PatchUser godoc
// @Tags Users
// @Summary Updates a user based on the given partial user model
// @ID PatchUser
// @Accept json
// @Produce json
// @Param email query string true "User Email"
// @Param user body users.User true "User Model"
// @Param Email query 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
@ -197,7 +202,7 @@ func (s *ApiServer) PutUser(c *gin.Context) {
// @Router /backend/user [patch]
// @Security ApiBasicAuth
func (s *ApiServer) PatchUser(c *gin.Context) {
email := strings.ToLower(strings.TrimSpace(c.Query("email")))
email := strings.ToLower(strings.TrimSpace(c.Query("Email")))
if email == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must be specified"})
return
@ -250,8 +255,9 @@ func (s *ApiServer) PatchUser(c *gin.Context) {
// DeleteUser godoc
// @Tags Users
// @Summary Deletes the specified user
// @ID DeleteUser
// @Produce json
// @Param email query string true "User Email"
// @Param Email query string true "User Email"
// @Success 204 "No content"
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
@ -261,7 +267,7 @@ func (s *ApiServer) PatchUser(c *gin.Context) {
// @Router /backend/user [delete]
// @Security ApiBasicAuth
func (s *ApiServer) DeleteUser(c *gin.Context) {
email := strings.ToLower(strings.TrimSpace(c.Query("email")))
email := strings.ToLower(strings.TrimSpace(c.Query("Email")))
if email == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must be specified"})
return
@ -284,8 +290,9 @@ func (s *ApiServer) DeleteUser(c *gin.Context) {
// GetPeers godoc
// @Tags Peers
// @Summary Retrieves all peers for the given interface
// @ID GetPeers
// @Produce json
// @Param device query string true "Device Name"
// @Param DeviceName query string true "Device Name"
// @Success 200 {object} []wireguard.Peer
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
@ -293,9 +300,9 @@ func (s *ApiServer) DeleteUser(c *gin.Context) {
// @Router /backend/peers [get]
// @Security ApiBasicAuth
func (s *ApiServer) GetPeers(c *gin.Context) {
deviceName := strings.ToLower(strings.TrimSpace(c.Query("device")))
deviceName := strings.ToLower(strings.TrimSpace(c.Query("DeviceName")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must be specified"})
return
}
@ -312,8 +319,9 @@ func (s *ApiServer) GetPeers(c *gin.Context) {
// GetPeer godoc
// @Tags Peers
// @Summary Retrieves the peer for the given public key
// @ID GetPeer
// @Produce json
// @Param pkey query string true "Public Key (Base 64)"
// @Param PublicKey query string true "Public Key (Base 64)"
// @Success 200 {object} wireguard.Peer
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
@ -321,9 +329,9 @@ func (s *ApiServer) GetPeers(c *gin.Context) {
// @Router /backend/peer [get]
// @Security ApiBasicAuth
func (s *ApiServer) GetPeer(c *gin.Context) {
pkey := c.Query("pkey")
pkey := c.Query("PublicKey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must be specified"})
return
}
@ -338,10 +346,11 @@ func (s *ApiServer) GetPeer(c *gin.Context) {
// PostPeer godoc
// @Tags Peers
// @Summary Creates a new peer based on the given peer model
// @ID PostPeer
// @Accept json
// @Produce json
// @Param device query string true "Device Name"
// @Param peer body wireguard.Peer true "Peer Model"
// @Param DeviceName query 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
@ -351,9 +360,9 @@ func (s *ApiServer) GetPeer(c *gin.Context) {
// @Router /backend/peers [post]
// @Security ApiBasicAuth
func (s *ApiServer) PostPeer(c *gin.Context) {
deviceName := strings.ToLower(strings.TrimSpace(c.Query("device")))
deviceName := strings.ToLower(strings.TrimSpace(c.Query("DeviceName")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must be specified"})
return
}
@ -364,7 +373,7 @@ func (s *ApiServer) PostPeer(c *gin.Context) {
}
newPeer := wireguard.Peer{}
if err := c.BindJSON(&newPeer); err != nil {
if err := c.ShouldBindJSON(&newPeer); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
@ -390,10 +399,11 @@ func (s *ApiServer) PostPeer(c *gin.Context) {
// PutPeer godoc
// @Tags Peers
// @Summary Updates the given peer based on the given peer model
// @ID PutPeer
// @Accept json
// @Produce json
// @Param pkey query string true "Public Key"
// @Param peer body wireguard.Peer true "Peer Model"
// @Param PublicKey query 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
@ -404,14 +414,14 @@ func (s *ApiServer) PostPeer(c *gin.Context) {
// @Security ApiBasicAuth
func (s *ApiServer) PutPeer(c *gin.Context) {
updatePeer := wireguard.Peer{}
if err := c.BindJSON(&updatePeer); err != nil {
if err := c.ShouldBindJSON(&updatePeer); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
pkey := c.Query("pkey")
pkey := c.Query("PublicKey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must be specified"})
return
}
@ -422,7 +432,7 @@ func (s *ApiServer) PutPeer(c *gin.Context) {
// Changing public key is not allowed
if pkey != updatePeer.PublicKey {
c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must match the model public key"})
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must match the model public key"})
return
}
@ -446,10 +456,11 @@ func (s *ApiServer) PutPeer(c *gin.Context) {
// PatchPeer godoc
// @Tags Peers
// @Summary Updates the given peer based on the given partial peer model
// @ID PatchPeer
// @Accept json
// @Produce json
// @Param pkey query string true "Public Key"
// @Param peer body wireguard.Peer true "Peer Model"
// @Param PublicKey query 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
@ -465,7 +476,7 @@ func (s *ApiServer) PatchPeer(c *gin.Context) {
return
}
pkey := c.Query("pkey")
pkey := c.Query("PublicKey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must be specified"})
return
@ -498,7 +509,7 @@ func (s *ApiServer) PatchPeer(c *gin.Context) {
// Changing public key is not allowed
if pkey != mergedPeer.PublicKey {
c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must match the model public key"})
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must match the model public key"})
return
}
@ -522,8 +533,9 @@ func (s *ApiServer) PatchPeer(c *gin.Context) {
// DeletePeer godoc
// @Tags Peers
// @Summary Updates the given peer based on the given partial peer model
// @ID DeletePeer
// @Produce json
// @Param pkey query string true "Public Key"
// @Param PublicKey query string true "Public Key"
// @Success 202 "No Content"
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
@ -533,9 +545,9 @@ func (s *ApiServer) PatchPeer(c *gin.Context) {
// @Router /backend/peer [delete]
// @Security ApiBasicAuth
func (s *ApiServer) DeletePeer(c *gin.Context) {
pkey := c.Query("pkey")
pkey := c.Query("PublicKey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must be specified"})
return
}
@ -556,6 +568,7 @@ func (s *ApiServer) DeletePeer(c *gin.Context) {
// GetDevices godoc
// @Tags Interface
// @Summary Get all devices
// @ID GetDevices
// @Produce json
// @Success 200 {object} []wireguard.Device
// @Failure 400 {object} ApiError
@ -580,8 +593,9 @@ func (s *ApiServer) GetDevices(c *gin.Context) {
// GetDevice godoc
// @Tags Interface
// @Summary Get the given device
// @ID GetDevice
// @Produce json
// @Param device query string true "Device Name"
// @Param DeviceName query string true "Device Name"
// @Success 200 {object} wireguard.Device
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
@ -590,9 +604,9 @@ func (s *ApiServer) GetDevices(c *gin.Context) {
// @Router /backend/device [get]
// @Security ApiBasicAuth
func (s *ApiServer) GetDevice(c *gin.Context) {
deviceName := strings.ToLower(strings.TrimSpace(c.Query("device")))
deviceName := strings.ToLower(strings.TrimSpace(c.Query("DeviceName")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must be specified"})
return
}
@ -614,10 +628,11 @@ func (s *ApiServer) GetDevice(c *gin.Context) {
// PutDevice godoc
// @Tags Interface
// @Summary Updates the given device based on the given device model (UNIMPLEMENTED)
// @ID PutDevice
// @Accept json
// @Produce json
// @Param device query string true "Device Name"
// @Param body body wireguard.Device true "Device Model"
// @Param DeviceName query string true "Device Name"
// @Param Device body wireguard.Device true "Device Model"
// @Success 200 {object} wireguard.Device
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
@ -628,14 +643,14 @@ func (s *ApiServer) GetDevice(c *gin.Context) {
// @Security ApiBasicAuth
func (s *ApiServer) PutDevice(c *gin.Context) {
updateDevice := wireguard.Device{}
if err := c.BindJSON(&updateDevice); err != nil {
if err := c.ShouldBindJSON(&updateDevice); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}
deviceName := strings.ToLower(strings.TrimSpace(c.Query("device")))
deviceName := strings.ToLower(strings.TrimSpace(c.Query("DeviceName")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must be specified"})
return
}
@ -653,7 +668,7 @@ func (s *ApiServer) PutDevice(c *gin.Context) {
// Changing device name is not allowed
if deviceName != updateDevice.DeviceName {
c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must match the model device name"})
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must match the model device name"})
return
}
@ -665,10 +680,11 @@ func (s *ApiServer) PutDevice(c *gin.Context) {
// PatchDevice godoc
// @Tags Interface
// @Summary Updates the given device based on the given partial device model (UNIMPLEMENTED)
// @ID PatchDevice
// @Accept json
// @Produce json
// @Param device query string true "Device Name"
// @Param body body wireguard.Device true "Device Model"
// @Param DeviceName query string true "Device Name"
// @Param Device body wireguard.Device true "Device Model"
// @Success 200 {object} wireguard.Device
// @Failure 400 {object} ApiError
// @Failure 401 {object} ApiError
@ -684,9 +700,9 @@ func (s *ApiServer) PatchDevice(c *gin.Context) {
return
}
deviceName := strings.ToLower(strings.TrimSpace(c.Query("device")))
deviceName := strings.ToLower(strings.TrimSpace(c.Query("DeviceName")))
if deviceName == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must be specified"})
return
}
@ -723,7 +739,7 @@ func (s *ApiServer) PatchDevice(c *gin.Context) {
// Changing device name is not allowed
if deviceName != mergedDevice.DeviceName {
c.JSON(http.StatusBadRequest, ApiError{Message: "device parameter must match the model device name"})
c.JSON(http.StatusBadRequest, ApiError{Message: "DeviceName parameter must match the model device name"})
return
}
@ -742,8 +758,9 @@ type PeerDeploymentInformation struct {
// GetPeerDeploymentInformation godoc
// @Tags Provisioning
// @Summary Retrieves all active peers for the given email address
// @ID GetPeerDeploymentInformation
// @Produce json
// @Param email query string true "Email Address"
// @Param Email query string true "Email Address"
// @Success 200 {object} []PeerDeploymentInformation "All active WireGuard peers"
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
@ -751,9 +768,9 @@ type PeerDeploymentInformation struct {
// @Router /provisioning/peers [get]
// @Security GeneralBasicAuth
func (s *ApiServer) GetPeerDeploymentInformation(c *gin.Context) {
email := c.Query("email")
email := c.Query("Email")
if email == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "Email parameter must be specified"})
return
}
@ -792,8 +809,9 @@ func (s *ApiServer) GetPeerDeploymentInformation(c *gin.Context) {
// GetPeerDeploymentConfig godoc
// @Tags Provisioning
// @Summary Retrieves the peer config for the given public key
// @ID GetPeerDeploymentConfig
// @Produce plain
// @Param pkey query string true "Public Key (Base 64)"
// @Param PublicKey query string true "Public Key (Base 64)"
// @Success 200 {object} string "The WireGuard configuration file"
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
@ -801,9 +819,9 @@ func (s *ApiServer) GetPeerDeploymentInformation(c *gin.Context) {
// @Router /provisioning/peer [get]
// @Security GeneralBasicAuth
func (s *ApiServer) GetPeerDeploymentConfig(c *gin.Context) {
pkey := c.Query("pkey")
pkey := c.Query("PublicKey")
if pkey == "" {
c.JSON(http.StatusBadRequest, ApiError{Message: "pkey parameter must be specified"})
c.JSON(http.StatusBadRequest, ApiError{Message: "PublicKey parameter must be specified"})
return
}
@ -849,9 +867,10 @@ type ProvisioningRequest struct {
// PostPeerDeploymentConfig godoc
// @Tags Provisioning
// @Summary Creates the requested peer config and returns the config file
// @ID PostPeerDeploymentConfig
// @Accept json
// @Produce plain
// @Param body body ProvisioningRequest true "Provisioning Request Model"
// @Param ProvisioningRequest body ProvisioningRequest true "Provisioning Request Model"
// @Success 200 {object} string "The WireGuard configuration file"
// @Failure 401 {object} ApiError
// @Failure 403 {object} ApiError
@ -860,7 +879,7 @@ type ProvisioningRequest struct {
// @Security GeneralBasicAuth
func (s *ApiServer) PostPeerDeploymentConfig(c *gin.Context) {
req := ProvisioningRequest{}
if err := c.BindJSON(&req); err != nil {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()})
return
}

View File

@ -44,11 +44,12 @@ var doc = `{
"Interface"
],
"summary": "Get the given device",
"operationId": "GetDevice",
"parameters": [
{
"type": "string",
"description": "Device Name",
"name": "device",
"name": "DeviceName",
"in": "query",
"required": true
}
@ -102,17 +103,18 @@ var doc = `{
"Interface"
],
"summary": "Updates the given device based on the given device model (UNIMPLEMENTED)",
"operationId": "PutDevice",
"parameters": [
{
"type": "string",
"description": "Device Name",
"name": "device",
"name": "DeviceName",
"in": "query",
"required": true
},
{
"description": "Device Model",
"name": "body",
"name": "Device",
"in": "body",
"required": true,
"schema": {
@ -175,17 +177,18 @@ var doc = `{
"Interface"
],
"summary": "Updates the given device based on the given partial device model (UNIMPLEMENTED)",
"operationId": "PatchDevice",
"parameters": [
{
"type": "string",
"description": "Device Name",
"name": "device",
"name": "DeviceName",
"in": "query",
"required": true
},
{
"description": "Device Model",
"name": "body",
"name": "Device",
"in": "body",
"required": true,
"schema": {
@ -247,6 +250,7 @@ var doc = `{
"Interface"
],
"summary": "Get all devices",
"operationId": "GetDevices",
"responses": {
"200": {
"description": "OK",
@ -298,11 +302,12 @@ var doc = `{
"Peers"
],
"summary": "Retrieves the peer for the given public key",
"operationId": "GetPeer",
"parameters": [
{
"type": "string",
"description": "Public Key (Base 64)",
"name": "pkey",
"name": "PublicKey",
"in": "query",
"required": true
}
@ -350,17 +355,18 @@ var doc = `{
"Peers"
],
"summary": "Updates the given peer based on the given peer model",
"operationId": "PutPeer",
"parameters": [
{
"type": "string",
"description": "Public Key",
"name": "pkey",
"name": "PublicKey",
"in": "query",
"required": true
},
{
"description": "Peer Model",
"name": "peer",
"name": "Peer",
"in": "body",
"required": true,
"schema": {
@ -420,11 +426,12 @@ var doc = `{
"Peers"
],
"summary": "Updates the given peer based on the given partial peer model",
"operationId": "DeletePeer",
"parameters": [
{
"type": "string",
"description": "Public Key",
"name": "pkey",
"name": "PublicKey",
"in": "query",
"required": true
}
@ -481,17 +488,18 @@ var doc = `{
"Peers"
],
"summary": "Updates the given peer based on the given partial peer model",
"operationId": "PatchPeer",
"parameters": [
{
"type": "string",
"description": "Public Key",
"name": "pkey",
"name": "PublicKey",
"in": "query",
"required": true
},
{
"description": "Peer Model",
"name": "peer",
"name": "Peer",
"in": "body",
"required": true,
"schema": {
@ -553,11 +561,12 @@ var doc = `{
"Peers"
],
"summary": "Retrieves all peers for the given interface",
"operationId": "GetPeers",
"parameters": [
{
"type": "string",
"description": "Device Name",
"name": "device",
"name": "DeviceName",
"in": "query",
"required": true
}
@ -608,17 +617,18 @@ var doc = `{
"Peers"
],
"summary": "Creates a new peer based on the given peer model",
"operationId": "PostPeer",
"parameters": [
{
"type": "string",
"description": "Device Name",
"name": "device",
"name": "DeviceName",
"in": "query",
"required": true
},
{
"description": "Peer Model",
"name": "peer",
"name": "Peer",
"in": "body",
"required": true,
"schema": {
@ -680,11 +690,12 @@ var doc = `{
"Users"
],
"summary": "Retrieves user based on given Email",
"operationId": "GetUser",
"parameters": [
{
"type": "string",
"description": "User Email",
"name": "email",
"name": "Email",
"in": "query",
"required": true
}
@ -738,17 +749,18 @@ var doc = `{
"Users"
],
"summary": "Updates a user based on the given user model",
"operationId": "PutUser",
"parameters": [
{
"type": "string",
"description": "User Email",
"name": "email",
"name": "Email",
"in": "query",
"required": true
},
{
"description": "User Model",
"name": "user",
"name": "User",
"in": "body",
"required": true,
"schema": {
@ -808,11 +820,12 @@ var doc = `{
"Users"
],
"summary": "Deletes the specified user",
"operationId": "DeleteUser",
"parameters": [
{
"type": "string",
"description": "User Email",
"name": "email",
"name": "Email",
"in": "query",
"required": true
}
@ -869,17 +882,18 @@ var doc = `{
"Users"
],
"summary": "Updates a user based on the given partial user model",
"operationId": "PatchUser",
"parameters": [
{
"type": "string",
"description": "User Email",
"name": "email",
"name": "Email",
"in": "query",
"required": true
},
{
"description": "User Model",
"name": "user",
"name": "User",
"in": "body",
"required": true,
"schema": {
@ -941,6 +955,7 @@ var doc = `{
"Users"
],
"summary": "Retrieves all users",
"operationId": "GetUsers",
"responses": {
"200": {
"description": "OK",
@ -987,10 +1002,11 @@ var doc = `{
"Users"
],
"summary": "Creates a new user based on the given user model",
"operationId": "PostUser",
"parameters": [
{
"description": "User Model",
"name": "user",
"name": "User",
"in": "body",
"required": true,
"schema": {
@ -1052,11 +1068,12 @@ var doc = `{
"Provisioning"
],
"summary": "Retrieves the peer config for the given public key",
"operationId": "GetPeerDeploymentConfig",
"parameters": [
{
"type": "string",
"description": "Public Key (Base 64)",
"name": "pkey",
"name": "PublicKey",
"in": "query",
"required": true
}
@ -1103,11 +1120,12 @@ var doc = `{
"Provisioning"
],
"summary": "Retrieves all active peers for the given email address",
"operationId": "GetPeerDeploymentInformation",
"parameters": [
{
"type": "string",
"description": "Email Address",
"name": "email",
"name": "Email",
"in": "query",
"required": true
}
@ -1158,10 +1176,11 @@ var doc = `{
"Provisioning"
],
"summary": "Creates the requested peer config and returns the config file",
"operationId": "PostPeerDeploymentConfig",
"parameters": [
{
"description": "Provisioning Request Model",
"name": "body",
"name": "ProvisioningRequest",
"in": "body",
"required": true,
"schema": {
@ -1199,18 +1218,6 @@ var doc = `{
}
},
"definitions": {
"gorm.DeletedAt": {
"type": "object",
"properties": {
"Time": {
"type": "string"
},
"Valid": {
"description": "Valid is true if Time is not NULL",
"type": "boolean"
}
}
},
"server.ApiError": {
"type": "object",
"properties": {
@ -1280,7 +1287,7 @@ var doc = `{
"type": "string"
},
"DeletedAt": {
"$ref": "#/definitions/gorm.DeletedAt"
"type": "string"
},
"Email": {
"description": "required fields",
@ -1403,9 +1410,11 @@ var doc = `{
"type": "object",
"required": [
"DeviceName",
"DeviceType",
"Email",
"Identifier",
"PublicKey"
"PublicKey",
"UID"
],
"properties": {
"AllowedIPsSrvStr": {
@ -1432,6 +1441,9 @@ var doc = `{
"DeviceName": {
"type": "string"
},
"DeviceType": {
"type": "string"
},
"Email": {
"type": "string"
},
@ -1467,6 +1479,10 @@ var doc = `{
"description": "Core WireGuard Settings",
"type": "string"
},
"UID": {
"description": "uid for html identification",
"type": "string"
},
"UpdatedAt": {
"type": "string"
},

View File

@ -42,5 +42,5 @@ type User struct {
// database internal fields
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index" json:",omitempty"`
DeletedAt gorm.DeletedAt `gorm:"index" json:",omitempty" swaggertype:"string"`
}

View File

@ -66,9 +66,9 @@ type Peer struct {
Peer *wgtypes.Peer `gorm:"-" json:"-"` // WireGuard peer
Config string `gorm:"-" json:"-"`
UID string `form:"uid" binding:"required,alphanum" json:"-"` // uid for html identification
UID string `form:"uid" binding:"required,alphanum"` // uid for html identification
DeviceName string `gorm:"index" form:"device" binding:"required"`
DeviceType DeviceType `gorm:"-" form:"devicetype" binding:"required,oneof=client server" json:"-"`
DeviceType DeviceType `gorm:"-" form:"devicetype" binding:"required,oneof=client server"`
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"`
@ -244,7 +244,7 @@ type Device struct {
Peers []Peer `gorm:"foreignKey:DeviceName" binding:"-" json:"-"` // linked WireGuard peers
Type DeviceType `form:"devicetype" binding:"required,oneof=client server"`
DeviceName string `form:"device" gorm:"primaryKey" binding:"required,alphanum"`
DeviceName string `form:"device" gorm:"primaryKey" binding:"required" validator:"regexp=[0-9a-zA-Z\-]+"`
DisplayName string `form:"displayname" binding:"omitempty,max=200"`
// Core WireGuard Settings (Interface section)

27
tests/conf/config.yml Normal file
View File

@ -0,0 +1,27 @@
core:
listeningAddress: :8123
externalUrl: https://wg.example.org
title: Example WireGuard VPN
company: Example.org
mailFrom: WireGuard VPN <noreply+wg@example.org>
logoUrl: /img/logo.png
adminUser: wg@example.org
adminPass: abadchoice
editableKeys: true
createDefaultPeer: true
selfProvisioning: true
ldapEnabled: false
database:
typ: sqlite
database: test.db
# :memory: does not work
email:
host: 127.0.0.1
port: 25
tls: false
wg:
devices:
- wg-example0
defaultDevice: wg-example0
configDirectory: /etc/wireguard
manageIPAddresses: true

View File

@ -0,0 +1,16 @@
# AUTOGENERATED FILE - DO NOT EDIT
# -WGP- Interface: wg-example / Updated: 2021-09-27 08:52:05.537618409 +0000 UTC / Created: 2021-09-24 10:06:46.903674496 +0000 UTC
# -WGP- Interface display name: TheInterface
# -WGP- Interface mode: server
# -WGP- PublicKey = HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=
[Interface]
# Core settings
PrivateKey = yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=
Address = 10.0.0.0/24
# Misc. settings (optional)
ListenPort = 51820
FwMark = 1
SaveConfig = true

481
tests/test_API.py Normal file
View File

@ -0,0 +1,481 @@
import ipaddress
import collections
import string
import unittest
import datetime
import re
import uuid
import subprocess
import random
import logging
import logging.config
import mechanize
from pyswagger import App, Security
from pyswagger.contrib.client.requests import Client
log = logging.getLogger("api")
class HttpFormatter(logging.Formatter):
def _formatHeaders(self, d):
return '\n'.join(f'{k}: {v}' for k, v in d.items())
def formatMessage(self, record):
result = super().formatMessage(record)
if record.name == 'api':
result += '''
---------------- request ----------------
{req.method} {req.url}
{reqhdrs}
{req.body}
---------------- response ----------------
{res.status_code} {res.reason} {res.url}
{reshdrs}
{res.text}
---------------- end ----------------
'''.format(req=record.req, res=record.res, reqhdrs=self._formatHeaders(record.req.headers),
reshdrs=self._formatHeaders(record.res.headers), )
return result
logging.config.dictConfig(
{
"version": 1,
"formatters": {
"http": {
"()": HttpFormatter,
"format": "{asctime} {levelname} {name} {message}",
"style":'{',
},
"detailed": {
"class": "logging.Formatter",
"format": "%(asctime)s %(name)-9s %(levelname)-4s %(message)s",
},
"plain": {
"class": "logging.Formatter",
"format": "%(message)s",
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "detailed",
},
"console_http": {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "http",
},
},
"root": {
"level": "DEBUG",
"handlers": ["console"],
"propagate": True
},
'loggers': {
'api': {
"level": "INFO",
"handlers": ["console_http"]
},
"requests.packages.urllib3": {
"level": "DEBUG",
"handlers": ["console"],
"propagate": True
},
},
}
)
log = logging.getLogger("api")
class ApiError(Exception):
pass
def logHttp(response, *args, **kwargs):
extra = {'req': response.request, 'res': response}
log.debug('HTTP', extra=extra)
class WGPClient:
def __init__(self, url, *auths):
app = App._create_(url)
auth = Security(app)
for t, cred in auths:
auth.update_with(t, cred)
client = Client(auth)
self.app, self.client = app, client
self.client._Client__s.hooks['response'] = logHttp
def call(self, name, **kwargs):
# print(f"{name} {kwargs}")
op = self.app.op[name]
req, resp = op(**kwargs)
now = datetime.datetime.now()
resp = self.client.request((req, resp))
then = datetime.datetime.now()
delta = then - now
# print(f"{resp.status} {delta}")
if 200 <= resp.status <= 299:
pass
elif 400 <= resp.status <= 499:
raise ApiError(resp.data["Message"])
elif 500 == resp.status:
raise ValueError(resp.data["Message"])
elif 501 == resp.status:
raise NotImplementedError(resp.data["Message"])
elif 502 <= resp.status <= 599:
raise ApiError(resp.data["Message"])
return resp
def GetDevice(self, **kwargs):
return self.call("GetDevice", **kwargs).data
def PatchDevice(self, **kwargs):
return self.call("PatchDevice", **kwargs).data
def PutDevice(self, **kwargs):
return self.call("PutDevice", **kwargs).data
def GetDevices(self, **kwargs):
# FIXME - could return empty list?
return self.call("GetDevices", **kwargs).data or []
def DeletePeer(self, **kwargs):
return self.call("DeletePeer", **kwargs).data
def GetPeer(self, **kwargs):
return self.call("GetPeer", **kwargs).data
def PatchPeer(self, **kwargs):
return self.call("PatchPeer", **kwargs).data
def PostPeer(self, **kwargs):
return self.call("PostPeer", **kwargs).data
def PutPeer(self, **kwargs):
return self.call("PutPeer", **kwargs).data
def GetPeerDeploymentConfig(self, **kwargs):
return self.call("GetPeerDeploymentConfig", **kwargs).data
def PostPeerDeploymentConfig(self, **kwargs):
return self.call("PostPeerDeploymentConfig", **kwargs).raw
def GetPeerDeploymentInformation(self, **kwargs):
return self.call("GetPeerDeploymentInformation", **kwargs).data
def GetPeers(self, **kwargs):
return self.call("GetPeers", **kwargs).data
def DeleteUser(self, **kwargs):
return self.call("DeleteUser", **kwargs).data
def GetUser(self, **kwargs):
return self.call("GetUser", **kwargs).data
def PatchUser(self, **kwargs):
return self.call("PatchUser", **kwargs).data
def PostUser(self, **kwargs):
return self.call("PostUser", **kwargs).data
def PutUser(self, **kwargs):
return self.call("PutUser", **kwargs).data
def GetUsers(self, **kwargs):
return self.call("GetUsers", **kwargs).data
def generate_wireguard_keys():
"""
Generate a WireGuard private & public key
Requires that the 'wg' command is available on PATH
Returns (private_key, public_key), both strings
"""
privkey = subprocess.check_output("wg genkey", shell=True).decode("utf-8").strip()
pubkey = subprocess.check_output(f"echo '{privkey}' | wg pubkey", shell=True).decode("utf-8").strip()
return (privkey, pubkey)
KeyTuple = collections.namedtuple("Keys", "private public")
class TestAPI(unittest.TestCase):
URL = 'http://localhost:8123/swagger/doc.json'
AUTH = {
"api": ('ApiBasicAuth', ("wg@example.org", "abadchoice")),
"general": ('GeneralBasicAuth', ("wg@example.org", "abadchoice"))
}
DEVICE = "wg-example0"
IFADDR = "10.17.0.0/24"
log = logging.getLogger("TestAPI")
def _client(self, *auth):
auth = ["general"] if auth is None else auth
self.c = WGPClient(self.URL, *[self.AUTH[i] for i in auth])
@property
def randmail(self):
return 'test+' + ''.join(
[random.choice(string.ascii_lowercase + string.digits) for i in range(6)]) + '@example.org'
@classmethod
def setUpClass(cls) -> None:
cls.finishInstallation()
@classmethod
def finishInstallation(cls) -> None:
import http.cookiejar
# Fake Cookie Policy to send the Secure cookies via http
class InSecureCookiePolicy(http.cookiejar.DefaultCookiePolicy):
def set_ok(self, cookie, request):
return True
def return_ok(self, cookie, request):
return True
def domain_return_ok(self, domain, request):
return True
def path_return_ok(self, path, request):
return True
b = mechanize.Browser()
b.set_cookiejar(http.cookiejar.CookieJar(InSecureCookiePolicy()))
b.set_handle_robots(False)
b.open("http://localhost:8123/")
b.follow_link(text="Login")
b.select_form(name="login")
username, password = cls.AUTH['api'][1]
b.form.set_value(username, "username")
b.form.set_value(password, "password")
b.submit()
b.follow_link(text="Administration")
b.follow_link(predicate=lambda x: any([a == ('title', 'Edit interface settings') for a in x.attrs]))
b.select_form("server")
values = {
"displayname": "example0",
"endpoint": "wg.example.org:51280",
"ip": cls.IFADDR
}
for k, v in values.items():
b.form.set_value(v, k)
b.submit()
b.select_form("server")
# cls.log.debug(b.form.get_value("ip"))
def setUp(self) -> None:
self._client('api')
self.user = self.randmail
# create a user …
self.c.PostUser(User={"Firstname": "Test", "Lastname": "User", "Email": self.user})
self.keys = KeyTuple(*generate_wireguard_keys())
def _test_generate(self):
def key_of(op):
a, *b = list(filter(lambda x: len(x), re.split("([A-Z][a-z]+)", op.operationId)))
return ''.join(b), a
for op in sorted(self.c.app.op.values(), key=key_of):
print(f"""
def {op.operationId}(self, **kwargs):
return self. call("{op.operationId}", **kwargs)
""")
def test_ops(self):
for op in sorted(self.c.app.op.values(), key=lambda op: op.operationId):
self.assertTrue(hasattr(self.c, op.operationId), f"{op.operationId} is missing")
def test_Device(self):
# FIXME device has to be completed via webif to be valid before it can be used via API
devices = self.c.GetDevices()
self.assertTrue(len(devices) > 0)
for device in devices:
dev = self.c.GetDevice(DeviceName=device.DeviceName)
new = self.c.PutDevice(DeviceName=dev.DeviceName,
Device={
"DeviceName": dev.DeviceName,
"IPsStr": dev.IPsStr,
"PrivateKey": dev.PrivateKey,
"Type": "client",
"PublicKey": dev.PublicKey}
)
new = self.c.PatchDevice(DeviceName=dev.DeviceName,
Device={
"DeviceName": dev.DeviceName,
"IPsStr": dev.IPsStr,
"PrivateKey": dev.PrivateKey,
"Type": "client",
"PublicKey": dev.PublicKey}
)
def easy_peer(self):
data = self.c.PostPeerDeploymentConfig(ProvisioningRequest={"Email": self.user, "Identifier": "debug"})
data = data.decode()
pubkey = re.search("# -WGP- PublicKey: (?P<pubkey>[^\n]+)\n", data, re.MULTILINE)['pubkey']
privkey = re.search("PrivateKey = (?P<key>[^\n]+)\n", data, re.MULTILINE)['key']
self.keys = KeyTuple(privkey, pubkey)
def test_Peers(self):
privkey, pubkey = generate_wireguard_keys()
peer = {"UID": uuid.uuid4().hex,
"Identifier": uuid.uuid4().hex,
"DeviceName": self.DEVICE,
"PublicKey": pubkey,
"DeviceType": "client",
"IPsStr": str(self.IFADDR),
"Email": self.user}
# keypair is created server side if private key is not submitted
with self.assertRaisesRegex(ApiError, "peer not found"):
self.c.PostPeer(DeviceName=self.DEVICE, Peer=peer)
# create
peer["PrivateKey"] = privkey
p = self.c.PostPeer(DeviceName=self.DEVICE, Peer=peer)
self.assertListEqual([p.PrivateKey, p.PublicKey], [privkey, pubkey])
# lookup created peer
for p in self.c.GetPeers(DeviceName=self.DEVICE):
if pubkey == p.PublicKey:
break
else:
self.assertTrue(False)
# get
gp = self.c.GetPeer(PublicKey=p.PublicKey)
self.assertListEqual([gp.PrivateKey, gp.PublicKey], [p.PrivateKey, p.PublicKey])
# change?
peer['Identifier'] = 'changed'
n = self.c.PatchPeer(PublicKey=p.PublicKey, Peer=peer)
self.assertListEqual([n.PrivateKey, n.PublicKey], [privkey, pubkey])
# change ?
peer['Identifier'] = 'changedagain'
n = self.c.PutPeer(PublicKey=p.PublicKey, Peer=peer)
self.assertListEqual([n.PrivateKey, n.PublicKey], [privkey, pubkey])
# invalid change operations
n = peer.copy()
n['PrivateKey'], n['PublicKey'] = generate_wireguard_keys()
with self.assertRaisesRegex(ApiError, "PublicKey parameter must match the model public key"):
self.c.PutPeer(PublicKey=p.PublicKey, Peer=n)
with self.assertRaisesRegex(ApiError, "PublicKey parameter must match the model public key"):
self.c.PatchPeer(PublicKey=p.PublicKey, Peer=n)
n = self.c.DeletePeer(PublicKey=p.PublicKey)
def test_Deployment(self):
log.setLevel(logging.DEBUG)
self._client("general")
self.easy_peer()
self.c.GetPeerDeploymentConfig(PublicKey=self.keys.public)
self.c.GetPeerDeploymentInformation(Email=self.user)
log.setLevel(logging.INFO)
def test_User(self):
u = self.c.PostUser(User={"Firstname": "Test", "Lastname": "User", "Email": self.randmail})
for i in self.c.GetUsers():
if i.Email == u.Email:
break
else:
self.assertTrue(False)
u = self.c.GetUser(Email=u.Email)
self.c.PutUser(Email=u.Email, User={"Firstname": "Test", "Lastname": "User", "Email": u.Email})
self.c.PatchUser(Email=u.Email, User={"Firstname": "Test", "Lastname": "User", "Email": u.Email})
# list a deleted user
self.c.DeleteUser(Email=u.Email)
for i in self.c.GetUsers():
break
def _clear_peers(self):
for p in self.c.GetPeers(DeviceName=self.DEVICE):
self.c.DeletePeer(PublicKey=p.PublicKey)
def _clear_users(self):
for p in self.c.GetUsers():
if p.Email == self.AUTH['api'][1][0]:
continue
self.c.DeleteUser(Email=p.Email)
def _createPeer(self):
privkey, pubkey = generate_wireguard_keys()
peer = {"UID": uuid.uuid4().hex,
"Identifier": uuid.uuid4().hex,
"DeviceName": self.DEVICE,
"PublicKey": pubkey,
"PrivateKey": privkey,
"DeviceType": "client",
# "IPsStr": str(self.ifaddr),
"Email": self.user}
self.c.PostPeer(DeviceName=self.DEVICE, Peer=peer)
return pubkey
def test_address_exhaustion(self):
global log
self._clear_peers()
self._clear_users()
self.NETWORK = ipaddress.ip_network("10.0.0.0/29")
addr = ipaddress.ip_address(
random.randrange(int(self.NETWORK.network_address) + 1, int(self.NETWORK.broadcast_address) - 1))
self.__class__.IFADDR = str(ipaddress.ip_interface(f"{addr}/{self.NETWORK.prefixlen}"))
# reconfigure via web ui - set the ifaddr with less addrs in pool
self.finishInstallation()
keys = set()
EADDRESSEXHAUSTED = "failed to get available IP addresses: no more available address from cidr"
with self.assertRaisesRegex(ValueError, EADDRESSEXHAUSTED):
for i in range(self.NETWORK.num_addresses + 1):
keys.add(self._createPeer())
n = keys.pop()
self.c.DeletePeer(PublicKey=n)
self._createPeer()
with self.assertRaisesRegex(ValueError, EADDRESSEXHAUSTED):
self._createPeer()
# expand network
self.NETWORK = ipaddress.ip_network("10.0.0.0/28")
addr = ipaddress.ip_address(
random.randrange(int(self.NETWORK.network_address) + 1, int(self.NETWORK.broadcast_address) - 1))
self.__class__.IFADDR = str(ipaddress.ip_interface(f"{addr}/{self.NETWORK.prefixlen}"))
self.finishInstallation()
self._createPeer()