diff --git a/frontend/src/lang/translations/de.json b/frontend/src/lang/translations/de.json index a498111..9f0cab3 100644 --- a/frontend/src/lang/translations/de.json +++ b/frontend/src/lang/translations/de.json @@ -37,6 +37,7 @@ "users": "Benutzer", "lang": "Sprache ändern", "profile": "Mein Profil", + "settings": "Einstellungen", "login": "Anmelden", "logout": "Abmelden" }, @@ -167,6 +168,26 @@ "button-show-peer": "Show Peer", "button-edit-peer": "Edit Peer" }, + "settings": { + "headline": "Einstellungen", + "abstract": "Hier finden Sie persönliche Einstellungen für WireGuard Portal.", + "api": { + "headline": "API Einstellungen", + "abstract": "Hier können Sie die RESTful API verwalten.", + "active-description": "Die API ist derzeit für Ihr Benutzerkonto aktiv. Alle API-Anfragen werden mit Basic Auth authentifiziert. Verwenden Sie zur Authentifizierung die folgenden Anmeldeinformationen.", + "inactive-description": "Die API ist derzeit inaktiv. Klicken Sie auf die Schaltfläche unten, um sie zu aktivieren.", + "user-label": "API Benutzername:", + "user-placeholder": "API Benutzer", + "token-label": "API Passwort:", + "token-placeholder": "API Token", + "token-created-label": "API-Zugriff gewährt seit: ", + "button-disable-title": "Deaktivieren Sie die API. Dadurch wird der aktuelle Token ungültig.", + "button-disable-text": "API deaktivieren", + "button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.", + "button-enable-text": "API aktivieren", + "api-link": "API Dokumentation" + } + }, "modals": { "user-view": { "headline": "User Account:", diff --git a/internal/app/api/core/assets/doc/v0_swagger.json b/internal/app/api/core/assets/doc/v0_swagger.json index e6c1b8f..b37f2cf 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.json +++ b/internal/app/api/core/assets/doc/v0_swagger.json @@ -674,6 +674,7 @@ "/peer/config-qr/{id}": { "get": { "produces": [ + "image/png", "application/json" ], "tags": [ @@ -694,7 +695,7 @@ "200": { "description": "OK", "schema": { - "type": "string" + "type": "file" } }, "400": { @@ -1964,6 +1965,9 @@ "model.Settings": { "type": "object", "properties": { + "ApiAdminOnly": { + "type": "boolean" + }, "MailLinkOnly": { "type": "boolean" }, diff --git a/internal/app/api/core/assets/doc/v0_swagger.yaml b/internal/app/api/core/assets/doc/v0_swagger.yaml index b44b8b3..ee870dc 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.yaml +++ b/internal/app/api/core/assets/doc/v0_swagger.yaml @@ -357,6 +357,8 @@ definitions: type: object model.Settings: properties: + ApiAdminOnly: + type: boolean MailLinkOnly: type: boolean PersistentConfigSupported: @@ -943,12 +945,13 @@ paths: required: true type: string produces: + - image/png - application/json responses: "200": description: OK schema: - type: string + type: file "400": description: Bad Request schema: diff --git a/internal/app/api/core/assets/doc/v1_swagger.json b/internal/app/api/core/assets/doc/v1_swagger.json index 28cd5c0..d23abeb 100644 --- a/internal/app/api/core/assets/doc/v1_swagger.json +++ b/internal/app/api/core/assets/doc/v1_swagger.json @@ -867,6 +867,73 @@ } } }, + "/provisioning/new-peer": { + "post": { + "security": [ + { + "BasicAuth": [] + } + ], + "description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.", + "produces": [ + "application/json" + ], + "tags": [ + "Provisioning" + ], + "summary": "Create a new peer for the given interface and user.", + "operationId": "provisioning_handleNewPeerPost", + "parameters": [ + { + "description": "Provisioning request model.", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ProvisioningRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Peer" + } + }, + "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": [ @@ -1661,6 +1728,34 @@ } } }, + "models.ProvisioningRequest": { + "type": "object", + "required": [ + "InterfaceIdentifier" + ], + "properties": { + "InterfaceIdentifier": { + "description": "InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.", + "type": "string", + "example": "wg0" + }, + "PresharedKey": { + "description": "PresharedKey is the optional pre-shared key of the peer. If no pre-shared key is set, a new key is generated.", + "type": "string", + "example": "yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=" + }, + "PublicKey": { + "description": "PublicKey is the optional public key of the peer. If no public key is set, a new key pair is generated.", + "type": "string", + "example": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=" + }, + "UserIdentifier": { + "description": "UserIdentifier is the identifier of the user the peer should be linked to.\nIf no user identifier is set, the authenticated user is used.", + "type": "string", + "example": "uid-1234567" + } + } + }, "models.User": { "type": "object", "required": [ @@ -1775,16 +1870,19 @@ "type": "object", "properties": { "PeerCount": { + "description": "PeerCount is the number of peers linked to the user.", "type": "integer", "example": 2 }, "Peers": { + "description": "Peers is a list of peers linked to the user.", "type": "array", "items": { "$ref": "#/definitions/models.UserInformationPeer" } }, "UserIdentifier": { + "description": "UserIdentifier is the unique identifier of the user.", "type": "string", "example": "uid-1234567" } diff --git a/internal/app/api/core/assets/doc/v1_swagger.yaml b/internal/app/api/core/assets/doc/v1_swagger.yaml index c329b8f..00c914f 100644 --- a/internal/app/api/core/assets/doc/v1_swagger.yaml +++ b/internal/app/api/core/assets/doc/v1_swagger.yaml @@ -383,6 +383,32 @@ definitions: - InterfaceIdentifier - PrivateKey type: object + models.ProvisioningRequest: + properties: + InterfaceIdentifier: + description: InterfaceIdentifier is the identifier of the WireGuard interface + the peer should be linked to. + example: wg0 + type: string + PresharedKey: + description: PresharedKey is the optional pre-shared key of the peer. If no + pre-shared key is set, a new key is generated. + example: yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk= + type: string + PublicKey: + description: PublicKey is the optional public key of the peer. If no public + key is set, a new key pair is generated. + example: xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg= + type: string + UserIdentifier: + description: |- + UserIdentifier is the identifier of the user the peer should be linked to. + If no user identifier is set, the authenticated user is used. + example: uid-1234567 + type: string + required: + - InterfaceIdentifier + type: object models.User: properties: ApiEnabled: @@ -478,13 +504,16 @@ definitions: models.UserInformation: properties: PeerCount: + description: PeerCount is the number of peers linked to the user. example: 2 type: integer Peers: + description: Peers is a list of peers linked to the user. items: $ref: '#/definitions/models.UserInformationPeer' type: array UserIdentifier: + description: UserIdentifier is the unique identifier of the user. example: uid-1234567 type: string type: object @@ -1087,6 +1116,50 @@ paths: summary: Get information about all peer records for a given user. tags: - Provisioning + /provisioning/new-peer: + post: + description: Normal users can only create new peers if self provisioning is + allowed. Admins can always add new peers. + operationId: provisioning_handleNewPeerPost + parameters: + - description: Provisioning request model. + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.ProvisioningRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Peer' + "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: Create a new peer for the given interface and 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 fd22157..60645ab 100644 --- a/internal/app/api/v0/handlers/endpoint_peers.go +++ b/internal/app/api/v0/handlers/endpoint_peers.go @@ -345,7 +345,7 @@ func (e peerEndpoint) handleConfigGet() gin.HandlerFunc { // @Produce png // @Produce json // @Param id path string true "The peer identifier" -// @Success 200 {object} file +// @Success 200 {file} binary // @Failure 400 {object} model.Error // @Failure 500 {object} model.Error // @Router /peer/config-qr/{id} [get] diff --git a/internal/app/api/v1/backend/provisioning_service.go b/internal/app/api/v1/backend/provisioning_service.go index a5d55bd..4e14ecb 100644 --- a/internal/app/api/v1/backend/provisioning_service.go +++ b/internal/app/api/v1/backend/provisioning_service.go @@ -5,6 +5,7 @@ import ( "fmt" "io" + "github.com/h44z/wg-portal/internal/app/api/v1/models" "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" ) @@ -17,6 +18,8 @@ type ProvisioningServiceUserManagerRepo interface { type ProvisioningServicePeerManagerRepo interface { GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) GetUserPeers(context.Context, domain.UserIdentifier) ([]domain.Peer, error) + PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Peer, error) + CreatePeer(ctx context.Context, p *domain.Peer) (*domain.Peer, error) } type ProvisioningServiceConfigFileManagerRepo interface { @@ -128,3 +131,44 @@ func (p ProvisioningService) GetPeerQrPng(ctx context.Context, peerId domain.Pee return peerCfgQrData, nil } + +func (p ProvisioningService) NewPeer(ctx context.Context, req models.ProvisioningRequest) (*domain.Peer, error) { + if req.UserIdentifier == "" { + req.UserIdentifier = string(domain.GetUserInfo(ctx).Id) // use authenticated user id if not set + } + + // check permissions + if err := domain.ValidateUserAccessRights(ctx, domain.UserIdentifier(req.UserIdentifier)); err != nil { + return nil, err + } + if !p.cfg.Core.SelfProvisioningAllowed { + // only admins can create new peers if self-provisioning is disabled + if err := domain.ValidateAdminAccessRights(ctx); err != nil { + return nil, err + } + } + + // prepare new peer + peer, err := p.peers.PreparePeer(ctx, domain.InterfaceIdentifier(req.InterfaceIdentifier)) + if err != nil { + return nil, fmt.Errorf("failed to prepare new peer: %w", err) + } + peer.UserIdentifier = domain.UserIdentifier(req.UserIdentifier) // overwrite context user id with the one from the request + if req.PublicKey != "" { + peer.Identifier = domain.PeerIdentifier(req.PublicKey) + peer.Interface.PublicKey = req.PublicKey + peer.Interface.PrivateKey = "" // clear private key if public key is set, WireGuard Portal does not know the private key in that case + } + if req.PresharedKey != "" { + peer.PresharedKey = domain.PreSharedKey(req.PresharedKey) + } + peer.GenerateDisplayName("API") + + // save new peer + peer, err = p.peers.CreatePeer(ctx, peer) + if err != nil { + return nil, fmt.Errorf("failed to create new peer: %w", err) + } + + return peer, nil +} diff --git a/internal/app/api/v1/handlers/endpoint_provisioning.go b/internal/app/api/v1/handlers/endpoint_provisioning.go index e746b10..f31aed6 100644 --- a/internal/app/api/v1/handlers/endpoint_provisioning.go +++ b/internal/app/api/v1/handlers/endpoint_provisioning.go @@ -18,6 +18,7 @@ type ProvisioningEndpointProvisioningService interface { ) GetPeerConfig(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error) GetPeerQrPng(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error) + NewPeer(ctx context.Context, req models.ProvisioningRequest) (*domain.Peer, error) } type ProvisioningEndpoint struct { @@ -40,6 +41,8 @@ func (e ProvisioningEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator * 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()) + + apiGroup.POST("/new-peer", authenticator.LoggedIn(), e.handleNewPeerPost()) } // handleUserInfoGet returns a gorm Handler function. @@ -153,3 +156,40 @@ func (e ProvisioningEndpoint) handlePeerQrGet() gin.HandlerFunc { c.Data(http.StatusOK, "image/png", peerConfigQrCode) } } + +// handleNewPeerPost returns a gorm Handler function. +// +// @ID provisioning_handleNewPeerPost +// @Tags Provisioning +// @Summary Create a new peer for the given interface and user. +// @Description Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers. +// @Param request body models.ProvisioningRequest true "Provisioning request model." +// @Produce json +// @Success 200 {object} models.Peer +// @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/new-peer [post] +// @Security BasicAuth +func (e ProvisioningEndpoint) handleNewPeerPost() gin.HandlerFunc { + return func(c *gin.Context) { + ctx := domain.SetUserInfoFromGin(c) + + var req models.ProvisioningRequest + err := c.BindJSON(&req) + if err != nil { + c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + peer, err := e.provisioning.NewPeer(ctx, req) + if err != nil { + c.JSON(ParseServiceError(err)) + return + } + + c.JSON(http.StatusOK, models.NewPeer(peer)) + } +} diff --git a/internal/app/api/v1/models/models_provisioning.go b/internal/app/api/v1/models/models_provisioning.go index 8a14df3..88bd0eb 100644 --- a/internal/app/api/v1/models/models_provisioning.go +++ b/internal/app/api/v1/models/models_provisioning.go @@ -4,9 +4,12 @@ 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"` + // UserIdentifier is the unique identifier of the user. + UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"` + // PeerCount is the number of peers linked to the user. + PeerCount int `json:"PeerCount" example:"2"` + // Peers is a list of peers linked to the user. + Peers []UserInformationPeer `json:"Peers"` } // UserInformationPeer represents the information about a peer. @@ -56,3 +59,17 @@ func NewUserInformationPeer(peer domain.Peer) UserInformationPeer { return up } + +// ProvisioningRequest represents a request to provision a new peer. +type ProvisioningRequest struct { + // InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to. + InterfaceIdentifier string `json:"InterfaceIdentifier" example:"wg0" binding:"required"` + // UserIdentifier is the identifier of the user the peer should be linked to. + // If no user identifier is set, the authenticated user is used. + UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"` + + // PublicKey is the optional public key of the peer. If no public key is set, a new key pair is generated. + PublicKey string `json:"PublicKey" example:"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=" binding:"omitempty,len=44"` + // PresharedKey is the optional pre-shared key of the peer. If no pre-shared key is set, a new key is generated. + PresharedKey string `json:"PresharedKey" example:"yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=" binding:"omitempty,len=44"` +} diff --git a/internal/app/wireguard/wireguard_peers.go b/internal/app/wireguard/wireguard_peers.go index a7cd2d8..0f1039e 100644 --- a/internal/app/wireguard/wireguard_peers.go +++ b/internal/app/wireguard/wireguard_peers.go @@ -6,7 +6,6 @@ import ( "fmt" "time" - "github.com/h44z/wg-portal/internal" "github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/domain" "github.com/sirupsen/logrus" @@ -34,9 +33,9 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti } peer.UserIdentifier = userId - peer.DisplayName = fmt.Sprintf("Default Peer %s", internal.TruncateString(string(peer.Identifier), 8)) peer.Notes = fmt.Sprintf("Default peer created for user %s", userId) peer.AutomaticallyCreated = true + peer.GenerateDisplayName("Default") newPeers = append(newPeers, *peer) } @@ -108,7 +107,6 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) ExtraAllowedIPsStr: "", PresharedKey: pk, PersistentKeepalive: domain.NewConfigOption(iface.PeerDefPersistentKeepalive, true), - DisplayName: fmt.Sprintf("Peer %s", internal.TruncateString(string(peerId), 8)), Identifier: peerId, UserIdentifier: currentUser.Id, InterfaceIdentifier: iface.Identifier, @@ -132,6 +130,7 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) PostDown: domain.NewConfigOption(iface.PeerDefPostDown, true), }, } + freshPeer.GenerateDisplayName("") return freshPeer, nil } diff --git a/internal/domain/peer.go b/internal/domain/peer.go index 9be8f17..f496114 100644 --- a/internal/domain/peer.go +++ b/internal/domain/peer.go @@ -120,6 +120,13 @@ func (p *Peer) ApplyInterfaceDefaults(in *Interface) { p.Interface.PostDown.TrySetValue(in.PeerDefPostDown) } +func (p *Peer) GenerateDisplayName(prefix string) { + if prefix != "" { + prefix = fmt.Sprintf("%s ", strings.TrimSpace(prefix)) // add a space after the prefix + } + p.DisplayName = fmt.Sprintf("%sPeer %s", prefix, internal.TruncateString(string(p.Identifier), 8)) +} + type PeerInterfaceConfig struct { KeyPair // private/public Key of the peer