From 38838eb4ce0dda9b73e603be2193cf70818df046 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sat, 11 Jan 2025 11:17:50 +0100 Subject: [PATCH] first provisioning endpoints --- cmd/wg-portal/main.go | 10 +- internal/adapters/database.go | 24 ++ .../app/api/core/assets/doc/v1_swagger.json | 256 ++++++++++++++++++ .../app/api/core/assets/doc/v1_swagger.yaml | 179 ++++++++++++ .../app/api/v0/handlers/endpoint_peers.go | 38 ++- .../api/v1/backend/provisioning_service.go | 130 +++++++++ .../api/v1/handlers/endpoint_provisioning.go | 155 +++++++++++ .../app/api/v1/models/models_provisioning.go | 58 ++++ internal/app/users/repos.go | 2 + internal/app/users/user_manager.go | 18 ++ 10 files changed, 856 insertions(+), 14 deletions(-) create mode 100644 internal/app/api/v1/backend/provisioning_service.go create mode 100644 internal/app/api/v1/handlers/endpoint_provisioning.go create mode 100644 internal/app/api/v1/models/models_provisioning.go diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index e9a5449..da2876a 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -108,10 +108,18 @@ func main() { apiV1BackendUsers := backendV1.NewUserService(cfg, userManager) apiV1BackendPeers := backendV1.NewPeerService(cfg, wireGuardManager, userManager) apiV1BackendInterfaces := backendV1.NewInterfaceService(cfg, wireGuardManager) + apiV1BackendProvisioning := backendV1.NewProvisioningService(cfg, userManager, wireGuardManager, cfgFileManager) apiV1EndpointUsers := handlersV1.NewUserEndpoint(apiV1BackendUsers) apiV1EndpointPeers := handlersV1.NewPeerEndpoint(apiV1BackendPeers) apiV1EndpointInterfaces := handlersV1.NewInterfaceEndpoint(apiV1BackendInterfaces) - apiV1 := handlersV1.NewRestApi(userManager, apiV1EndpointUsers, apiV1EndpointPeers, apiV1EndpointInterfaces) + apiV1EndpointProvisioning := handlersV1.NewProvisioningEndpoint(apiV1BackendProvisioning) + apiV1 := handlersV1.NewRestApi( + userManager, + apiV1EndpointUsers, + apiV1EndpointPeers, + apiV1EndpointInterfaces, + apiV1EndpointProvisioning, + ) webSrv, err := core.NewServer(cfg, apiFrontend, apiV1) internal.AssertNoError(err) diff --git a/internal/adapters/database.go b/internal/adapters/database.go index f4a778c..98e4967 100644 --- a/internal/adapters/database.go +++ b/internal/adapters/database.go @@ -698,6 +698,30 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai return &user, nil } +func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { + var users []domain.User + + err := r.db.WithContext(ctx).Where("email = ?", email).Find(&users).Error + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, err + } + + if len(users) == 0 { + return nil, domain.ErrNotFound + } + + if len(users) > 1 { + return nil, fmt.Errorf("found multiple users with email %s: %w", email, domain.ErrNotUnique) + } + + user := users[0] + + return &user, nil +} + func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) { var users []domain.User diff --git a/internal/app/api/core/assets/doc/v1_swagger.json b/internal/app/api/core/assets/doc/v1_swagger.json index d50d694..28cd5c0 100644 --- a/internal/app/api/core/assets/doc/v1_swagger.json +++ b/internal/app/api/core/assets/doc/v1_swagger.json @@ -665,6 +665,208 @@ } } }, + "/provisioning/data/peer-config": { + "get": { + "security": [ + { + "BasicAuth": [] + } + ], + "description": "Normal users can only access their own record. Admins can access all records.", + "produces": [ + "text/plain", + "application/json" + ], + "tags": [ + "Provisioning" + ], + "summary": "Get the peer configuration in wg-quick format.", + "operationId": "provisioning_handlePeerConfigGet", + "parameters": [ + { + "type": "string", + "description": "The peer identifier (public key) that should be queried.", + "name": "PeerId", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "The WireGuard configuration file", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.Error" + } + } + } + } + }, + "/provisioning/data/peer-qr": { + "get": { + "security": [ + { + "BasicAuth": [] + } + ], + "description": "Normal users can only access their own record. Admins can access all records.", + "produces": [ + "image/png", + "application/json" + ], + "tags": [ + "Provisioning" + ], + "summary": "Get the peer configuration as QR code.", + "operationId": "provisioning_handlePeerQrGet", + "parameters": [ + { + "type": "string", + "description": "The peer identifier (public key) that should be queried.", + "name": "PeerId", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "The WireGuard configuration QR code", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.Error" + } + } + } + } + }, + "/provisioning/data/user-info": { + "get": { + "security": [ + { + "BasicAuth": [] + } + ], + "description": "Normal users can only access their own record. Admins can access all records.", + "produces": [ + "application/json" + ], + "tags": [ + "Provisioning" + ], + "summary": "Get information about all peer records for a given user.", + "operationId": "provisioning_handleUserInfoGet", + "parameters": [ + { + "type": "string", + "description": "The user identifier that should be queried. If not set, the authenticated user is used.", + "name": "UserId", + "in": "query" + }, + { + "type": "string", + "description": "The email address that should be queried. If UserId is set, this is ignored.", + "name": "Email", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.UserInformation" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.Error" + } + } + } + } + }, "/user/all": { "get": { "security": [ @@ -1568,6 +1770,60 @@ "example": "db" } } + }, + "models.UserInformation": { + "type": "object", + "properties": { + "PeerCount": { + "type": "integer", + "example": 2 + }, + "Peers": { + "type": "array", + "items": { + "$ref": "#/definitions/models.UserInformationPeer" + } + }, + "UserIdentifier": { + "type": "string", + "example": "uid-1234567" + } + } + }, + "models.UserInformationPeer": { + "type": "object", + "properties": { + "DisplayName": { + "description": "DisplayName is a user-defined description of the peer.", + "type": "string", + "example": "My iPhone" + }, + "Identifier": { + "description": "Identifier is the unique identifier of the peer. It equals the public key of the peer.", + "type": "string", + "example": "peer-1234567" + }, + "InterfaceIdentifier": { + "description": "InterfaceIdentifier is the unique identifier of the WireGuard Portal device the peer is connected to.", + "type": "string", + "example": "wg0" + }, + "IpAddresses": { + "description": "IPAddresses is a list of IP addresses in CIDR format assigned to the peer.", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "10.11.12.2/24" + ] + }, + "IsDisabled": { + "description": "IsDisabled is a flag that specifies if the peer is enabled or not. Disabled peers are not able to connect.", + "type": "boolean", + "example": true + } + } } }, "securityDefinitions": { diff --git a/internal/app/api/core/assets/doc/v1_swagger.yaml b/internal/app/api/core/assets/doc/v1_swagger.yaml index 7834c17..c329b8f 100644 --- a/internal/app/api/core/assets/doc/v1_swagger.yaml +++ b/internal/app/api/core/assets/doc/v1_swagger.yaml @@ -475,6 +475,49 @@ definitions: - Identifier - IsAdmin type: object + models.UserInformation: + properties: + PeerCount: + example: 2 + type: integer + Peers: + items: + $ref: '#/definitions/models.UserInformationPeer' + type: array + UserIdentifier: + example: uid-1234567 + type: string + type: object + models.UserInformationPeer: + properties: + DisplayName: + description: DisplayName is a user-defined description of the peer. + example: My iPhone + type: string + Identifier: + description: Identifier is the unique identifier of the peer. It equals the + public key of the peer. + example: peer-1234567 + type: string + InterfaceIdentifier: + description: InterfaceIdentifier is the unique identifier of the WireGuard + Portal device the peer is connected to. + example: wg0 + type: string + IpAddresses: + description: IPAddresses is a list of IP addresses in CIDR format assigned + to the peer. + example: + - 10.11.12.2/24 + items: + type: string + type: array + IsDisabled: + description: IsDisabled is a flag that specifies if the peer is enabled or + not. Disabled peers are not able to connect. + example: true + type: boolean + type: object info: contact: name: WireGuard Portal Project @@ -908,6 +951,142 @@ paths: summary: Create a new peer record. tags: - Peers + /provisioning/data/peer-config: + get: + description: Normal users can only access their own record. Admins can access + all records. + operationId: provisioning_handlePeerConfigGet + parameters: + - description: The peer identifier (public key) that should be queried. + in: query + name: PeerId + required: true + type: string + produces: + - text/plain + - application/json + responses: + "200": + description: The WireGuard configuration file + schema: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/models.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/models.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.Error' + security: + - BasicAuth: [] + summary: Get the peer configuration in wg-quick format. + tags: + - Provisioning + /provisioning/data/peer-qr: + get: + description: Normal users can only access their own record. Admins can access + all records. + operationId: provisioning_handlePeerQrGet + parameters: + - description: The peer identifier (public key) that should be queried. + in: query + name: PeerId + required: true + type: string + produces: + - image/png + - application/json + responses: + "200": + description: The WireGuard configuration QR code + schema: + type: file + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/models.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/models.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.Error' + security: + - BasicAuth: [] + summary: Get the peer configuration as QR code. + tags: + - Provisioning + /provisioning/data/user-info: + get: + description: Normal users can only access their own record. Admins can access + all records. + operationId: provisioning_handleUserInfoGet + parameters: + - description: The user identifier that should be queried. If not set, the authenticated + user is used. + in: query + name: UserId + type: string + - description: The email address that should be queried. If UserId is set, this + is ignored. + in: query + name: Email + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.UserInformation' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/models.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/models.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.Error' + security: + - BasicAuth: [] + summary: Get information about all peer records for a given user. + tags: + - Provisioning /user/all: get: operationId: users_handleAllGet diff --git a/internal/app/api/v0/handlers/endpoint_peers.go b/internal/app/api/v0/handlers/endpoint_peers.go index 80593de..fd22157 100644 --- a/internal/app/api/v0/handlers/endpoint_peers.go +++ b/internal/app/api/v0/handlers/endpoint_peers.go @@ -1,12 +1,13 @@ package handlers import ( + "io" + "net/http" + "github.com/gin-gonic/gin" "github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app/api/v0/model" "github.com/h44z/wg-portal/internal/domain" - "io" - "net/http" ) type peerEndpoint struct { @@ -57,7 +58,8 @@ func (e peerEndpoint) handleAllGet() gin.HandlerFunc { _, peers, err := e.app.GetInterfaceAndPeers(ctx, domain.InterfaceIdentifier(interfaceId)) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + c.JSON(http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } @@ -88,7 +90,8 @@ func (e peerEndpoint) handleSingleGet() gin.HandlerFunc { peer, err := e.app.GetPeer(ctx, domain.PeerIdentifier(peerId)) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + c.JSON(http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } @@ -119,7 +122,8 @@ func (e peerEndpoint) handlePrepareGet() gin.HandlerFunc { peer, err := e.app.PreparePeer(ctx, domain.InterfaceIdentifier(interfaceId)) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + c.JSON(http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } @@ -163,7 +167,8 @@ func (e peerEndpoint) handleCreatePost() gin.HandlerFunc { newPeer, err := e.app.CreatePeer(ctx, model.NewDomainPeer(&p)) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + c.JSON(http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } @@ -200,9 +205,11 @@ func (e peerEndpoint) handleCreateMultiplePost() gin.HandlerFunc { return } - newPeers, err := e.app.CreateMultiplePeers(ctx, domain.InterfaceIdentifier(interfaceId), model.NewDomainPeerCreationRequest(&req)) + newPeers, err := e.app.CreateMultiplePeers(ctx, domain.InterfaceIdentifier(interfaceId), + model.NewDomainPeerCreationRequest(&req)) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + c.JSON(http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } @@ -246,7 +253,8 @@ func (e peerEndpoint) handleUpdatePut() gin.HandlerFunc { updatedPeer, err := e.app.UpdatePeer(ctx, model.NewDomainPeer(&p)) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + c.JSON(http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } @@ -277,7 +285,8 @@ func (e peerEndpoint) handleDelete() gin.HandlerFunc { err := e.app.DeletePeer(ctx, domain.PeerIdentifier(id)) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + c.JSON(http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } @@ -333,9 +342,10 @@ func (e peerEndpoint) handleConfigGet() gin.HandlerFunc { // @ID peers_handleQrCodeGet // @Tags Peer // @Summary Get peer configuration as qr code. +// @Produce png // @Produce json // @Param id path string true "The peer identifier" -// @Success 200 {object} string +// @Success 200 {object} file // @Failure 400 {object} model.Error // @Failure 500 {object} model.Error // @Router /peer/config-qr/{id} [get] @@ -403,7 +413,8 @@ func (e peerEndpoint) handleEmailPost() gin.HandlerFunc { } err = e.app.SendPeerEmail(ctx, req.LinkOnly, peerIds...) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + c.JSON(http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } @@ -434,7 +445,8 @@ func (e peerEndpoint) handleStatsGet() gin.HandlerFunc { stats, err := e.app.GetPeerStats(ctx, domain.InterfaceIdentifier(interfaceId)) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + c.JSON(http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } diff --git a/internal/app/api/v1/backend/provisioning_service.go b/internal/app/api/v1/backend/provisioning_service.go new file mode 100644 index 0000000..a5d55bd --- /dev/null +++ b/internal/app/api/v1/backend/provisioning_service.go @@ -0,0 +1,130 @@ +package backend + +import ( + "context" + "fmt" + "io" + + "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" +) + +type ProvisioningServiceUserManagerRepo interface { + GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) + GetUserByEmail(ctx context.Context, email string) (*domain.User, error) +} + +type ProvisioningServicePeerManagerRepo interface { + GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) + GetUserPeers(context.Context, domain.UserIdentifier) ([]domain.Peer, error) +} + +type ProvisioningServiceConfigFileManagerRepo interface { + GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) + GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) +} + +type ProvisioningService struct { + cfg *config.Config + + users ProvisioningServiceUserManagerRepo + peers ProvisioningServicePeerManagerRepo + configFiles ProvisioningServiceConfigFileManagerRepo +} + +func NewProvisioningService( + cfg *config.Config, + users ProvisioningServiceUserManagerRepo, + peers ProvisioningServicePeerManagerRepo, + configFiles ProvisioningServiceConfigFileManagerRepo, +) *ProvisioningService { + return &ProvisioningService{ + cfg: cfg, + + users: users, + peers: peers, + configFiles: configFiles, + } +} + +func (p ProvisioningService) GetUserAndPeers( + ctx context.Context, + userId domain.UserIdentifier, + email string, +) (*domain.User, []domain.Peer, error) { + // first fetch user + var user *domain.User + switch { + case userId != "": + u, err := p.users.GetUser(ctx, userId) + if err != nil { + return nil, nil, err + } + user = u + case email != "": + u, err := p.users.GetUserByEmail(ctx, email) + if err != nil { + return nil, nil, err + } + user = u + default: + return nil, nil, fmt.Errorf("either UserId or Email must be set: %w", domain.ErrInvalidData) + } + + if err := domain.ValidateUserAccessRights(ctx, user.Identifier); err != nil { + return nil, nil, err + } + + peers, err := p.peers.GetUserPeers(ctx, user.Identifier) + if err != nil { + return nil, nil, err + } + + return user, peers, nil +} + +func (p ProvisioningService) GetPeerConfig(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error) { + peer, err := p.peers.GetPeer(ctx, peerId) + if err != nil { + return nil, err + } + + if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil { + return nil, err + } + + peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier) + if err != nil { + return nil, err + } + + peerCfgData, err := io.ReadAll(peerCfgReader) + if err != nil { + return nil, err + } + + return peerCfgData, nil +} + +func (p ProvisioningService) GetPeerQrPng(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error) { + peer, err := p.peers.GetPeer(ctx, peerId) + if err != nil { + return nil, err + } + + if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil { + return nil, err + } + + peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier) + if err != nil { + return nil, err + } + + peerCfgQrData, err := io.ReadAll(peerCfgQrReader) + if err != nil { + return nil, err + } + + return peerCfgQrData, nil +} diff --git a/internal/app/api/v1/handlers/endpoint_provisioning.go b/internal/app/api/v1/handlers/endpoint_provisioning.go new file mode 100644 index 0000000..e746b10 --- /dev/null +++ b/internal/app/api/v1/handlers/endpoint_provisioning.go @@ -0,0 +1,155 @@ +package handlers + +import ( + "context" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/h44z/wg-portal/internal/app/api/v1/models" + "github.com/h44z/wg-portal/internal/domain" +) + +type ProvisioningEndpointProvisioningService interface { + GetUserAndPeers(ctx context.Context, userId domain.UserIdentifier, email string) ( + *domain.User, + []domain.Peer, + error, + ) + GetPeerConfig(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error) + GetPeerQrPng(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error) +} + +type ProvisioningEndpoint struct { + provisioning ProvisioningEndpointProvisioningService +} + +func NewProvisioningEndpoint(provisioning ProvisioningEndpointProvisioningService) *ProvisioningEndpoint { + return &ProvisioningEndpoint{ + provisioning: provisioning, + } +} + +func (e ProvisioningEndpoint) GetName() string { + return "ProvisioningEndpoint" +} + +func (e ProvisioningEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *authenticationHandler) { + apiGroup := g.Group("/provisioning", authenticator.LoggedIn()) + + apiGroup.GET("/data/user-info", authenticator.LoggedIn(), e.handleUserInfoGet()) + apiGroup.GET("/data/peer-config", authenticator.LoggedIn(), e.handlePeerConfigGet()) + apiGroup.GET("/data/peer-qr", authenticator.LoggedIn(), e.handlePeerQrGet()) +} + +// handleUserInfoGet returns a gorm Handler function. +// +// @ID provisioning_handleUserInfoGet +// @Tags Provisioning +// @Summary Get information about all peer records for a given user. +// @Description Normal users can only access their own record. Admins can access all records. +// @Param UserId query string false "The user identifier that should be queried. If not set, the authenticated user is used." +// @Param Email query string false "The email address that should be queried. If UserId is set, this is ignored." +// @Produce json +// @Success 200 {object} models.UserInformation +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 403 {object} models.Error +// @Failure 404 {object} models.Error +// @Failure 500 {object} models.Error +// @Router /provisioning/data/user-info [get] +// @Security BasicAuth +func (e ProvisioningEndpoint) handleUserInfoGet() gin.HandlerFunc { + return func(c *gin.Context) { + ctx := domain.SetUserInfoFromGin(c) + + id := strings.TrimSpace(c.Query("UserId")) + email := strings.TrimSpace(c.Query("Email")) + + if id == "" && email == "" { + id = string(domain.GetUserInfo(ctx).Id) + } + + user, peers, err := e.provisioning.GetUserAndPeers(ctx, domain.UserIdentifier(id), email) + if err != nil { + c.JSON(ParseServiceError(err)) + return + } + + c.JSON(http.StatusOK, models.NewUserInformation(user, peers)) + } +} + +// handlePeerConfigGet returns a gorm Handler function. +// +// @ID provisioning_handlePeerConfigGet +// @Tags Provisioning +// @Summary Get the peer configuration in wg-quick format. +// @Description Normal users can only access their own record. Admins can access all records. +// @Param PeerId query string true "The peer identifier (public key) that should be queried." +// @Produce plain +// @Produce json +// @Success 200 {string} string "The WireGuard configuration file" +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 403 {object} models.Error +// @Failure 404 {object} models.Error +// @Failure 500 {object} models.Error +// @Router /provisioning/data/peer-config [get] +// @Security BasicAuth +func (e ProvisioningEndpoint) handlePeerConfigGet() gin.HandlerFunc { + return func(c *gin.Context) { + ctx := domain.SetUserInfoFromGin(c) + + id := strings.TrimSpace(c.Query("PeerId")) + if id == "" { + c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing peer id"}) + return + } + + peerConfig, err := e.provisioning.GetPeerConfig(ctx, domain.PeerIdentifier(id)) + if err != nil { + c.JSON(ParseServiceError(err)) + return + } + + c.Data(http.StatusOK, "text/plain", peerConfig) + } +} + +// handlePeerQrGet returns a gorm Handler function. +// +// @ID provisioning_handlePeerQrGet +// @Tags Provisioning +// @Summary Get the peer configuration as QR code. +// @Description Normal users can only access their own record. Admins can access all records. +// @Param PeerId query string true "The peer identifier (public key) that should be queried." +// @Produce png +// @Produce json +// @Success 200 {file} binary "The WireGuard configuration QR code" +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 403 {object} models.Error +// @Failure 404 {object} models.Error +// @Failure 500 {object} models.Error +// @Router /provisioning/data/peer-qr [get] +// @Security BasicAuth +func (e ProvisioningEndpoint) handlePeerQrGet() gin.HandlerFunc { + return func(c *gin.Context) { + ctx := domain.SetUserInfoFromGin(c) + + id := strings.TrimSpace(c.Query("PeerId")) + if id == "" { + c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing peer id"}) + return + } + + peerConfigQrCode, err := e.provisioning.GetPeerQrPng(ctx, domain.PeerIdentifier(id)) + if err != nil { + c.JSON(ParseServiceError(err)) + return + } + + c.Data(http.StatusOK, "image/png", peerConfigQrCode) + } +} diff --git a/internal/app/api/v1/models/models_provisioning.go b/internal/app/api/v1/models/models_provisioning.go new file mode 100644 index 0000000..8a14df3 --- /dev/null +++ b/internal/app/api/v1/models/models_provisioning.go @@ -0,0 +1,58 @@ +package models + +import "github.com/h44z/wg-portal/internal/domain" + +// UserInformation represents the information about a user and its linked peers. +type UserInformation struct { + UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"` + PeerCount int `json:"PeerCount" example:"2"` + Peers []UserInformationPeer `json:"Peers"` +} + +// UserInformationPeer represents the information about a peer. +type UserInformationPeer struct { + // Identifier is the unique identifier of the peer. It equals the public key of the peer. + Identifier string `json:"Identifier" example:"peer-1234567"` + // DisplayName is a user-defined description of the peer. + DisplayName string `json:"DisplayName" example:"My iPhone"` + // IPAddresses is a list of IP addresses in CIDR format assigned to the peer. + IpAddresses []string `json:"IpAddresses" example:"10.11.12.2/24"` + // IsDisabled is a flag that specifies if the peer is enabled or not. Disabled peers are not able to connect. + IsDisabled bool `json:"IsDisabled,omitempty" example:"true"` + + // InterfaceIdentifier is the unique identifier of the WireGuard Portal device the peer is connected to. + InterfaceIdentifier string `json:"InterfaceIdentifier" example:"wg0"` +} + +func NewUserInformation(user *domain.User, peers []domain.Peer) *UserInformation { + if user == nil { + return &UserInformation{} + } + + ui := &UserInformation{ + UserIdentifier: string(user.Identifier), + PeerCount: len(peers), + } + + for _, peer := range peers { + ui.Peers = append(ui.Peers, NewUserInformationPeer(peer)) + } + + if len(ui.Peers) == 0 { + ui.Peers = []UserInformationPeer{} // Ensure that the JSON output is an empty array instead of null. + } + + return ui +} + +func NewUserInformationPeer(peer domain.Peer) UserInformationPeer { + up := UserInformationPeer{ + Identifier: string(peer.Identifier), + DisplayName: peer.DisplayName, + IpAddresses: domain.CidrsToStringSlice(peer.Interface.Addresses), + IsDisabled: peer.IsDisabled(), + InterfaceIdentifier: string(peer.InterfaceIdentifier), + } + + return up +} diff --git a/internal/app/users/repos.go b/internal/app/users/repos.go index 95f06e8..967b429 100644 --- a/internal/app/users/repos.go +++ b/internal/app/users/repos.go @@ -2,11 +2,13 @@ package users import ( "context" + "github.com/h44z/wg-portal/internal/domain" ) type UserDatabaseRepo interface { GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) + GetUserByEmail(ctx context.Context, email string) (*domain.User, error) GetAllUsers(ctx context.Context) ([]domain.User, error) FindUsers(ctx context.Context, search string) ([]domain.User, error) SaveUser(ctx context.Context, id domain.UserIdentifier, updateFunc func(u *domain.User) (*domain.User, error)) error diff --git a/internal/app/users/user_manager.go b/internal/app/users/user_manager.go index 4a1a8da..5c3d5f1 100644 --- a/internal/app/users/user_manager.go +++ b/internal/app/users/user_manager.go @@ -111,6 +111,24 @@ func (m Manager) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain return user, nil } +func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { + + user, err := m.users.GetUserByEmail(ctx, email) + if err != nil { + return nil, fmt.Errorf("unable to load user for email %s: %w", email, err) + } + + if err := domain.ValidateUserAccessRights(ctx, user.Identifier); err != nil { + return nil, err + } + + peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case + + user.LinkedPeerCount = len(peers) + + return user, nil +} + func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) { if err := domain.ValidateAdminAccessRights(ctx); err != nil { return nil, err