From 19c58fb5af7388a5228d2300e5747afa8259a36b Mon Sep 17 00:00:00 2001 From: commonism Date: Wed, 29 Sep 2021 18:41:13 +0200 Subject: [PATCH] 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 --- README.md | 4 +- assets/tpl/admin_edit_interface.html | 4 +- assets/tpl/login.html | 2 +- internal/server/api.go | 143 ++++---- internal/server/docs/docs.go | 92 ++--- internal/users/user.go | 2 +- internal/wireguard/peermanager.go | 6 +- tests/conf/config.yml | 27 ++ tests/conf/wg-example0.conf | 16 + tests/test_API.py | 481 +++++++++++++++++++++++++++ 10 files changed, 669 insertions(+), 108 deletions(-) create mode 100644 tests/conf/config.yml create mode 100644 tests/conf/wg-example0.conf create mode 100644 tests/test_API.py diff --git a/README.md b/README.md index 998c888..765fbdc 100644 --- a/README.md +++ b/README.md @@ -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:///swagger/index.html`. +under the URL `http:///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. diff --git a/assets/tpl/admin_edit_interface.html b/assets/tpl/admin_edit_interface.html index 841c6c7..737f006 100644 --- a/assets/tpl/admin_edit_interface.html +++ b/assets/tpl/admin_edit_interface.html @@ -28,7 +28,7 @@
-
+ @@ -162,7 +162,7 @@
- + diff --git a/assets/tpl/login.html b/assets/tpl/login.html index 93a36ac..402be7d 100644 --- a/assets/tpl/login.html +++ b/assets/tpl/login.html @@ -27,7 +27,7 @@
Please sign in
- +
diff --git a/internal/server/api.go b/internal/server/api.go index aa38105..3f2c73c 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -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 } diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go index 25c0ff7..b0884b7 100644 --- a/internal/server/docs/docs.go +++ b/internal/server/docs/docs.go @@ -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" }, diff --git a/internal/users/user.go b/internal/users/user.go index f01fc87..24397c7 100644 --- a/internal/users/user.go +++ b/internal/users/user.go @@ -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"` } diff --git a/internal/wireguard/peermanager.go b/internal/wireguard/peermanager.go index 1ca27e4..5afa1be 100644 --- a/internal/wireguard/peermanager.go +++ b/internal/wireguard/peermanager.go @@ -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) diff --git a/tests/conf/config.yml b/tests/conf/config.yml new file mode 100644 index 0000000..6ec7297 --- /dev/null +++ b/tests/conf/config.yml @@ -0,0 +1,27 @@ +core: + listeningAddress: :8123 + externalUrl: https://wg.example.org + title: Example WireGuard VPN + company: Example.org + mailFrom: WireGuard VPN + 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 diff --git a/tests/conf/wg-example0.conf b/tests/conf/wg-example0.conf new file mode 100644 index 0000000..7144c96 --- /dev/null +++ b/tests/conf/wg-example0.conf @@ -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 diff --git a/tests/test_API.py b/tests/test_API.py new file mode 100644 index 0000000..da56f11 --- /dev/null +++ b/tests/test_API.py @@ -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[^\n]+)\n", data, re.MULTILINE)['pubkey'] + privkey = re.search("PrivateKey = (?P[^\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() +