provisioning endpoints

This commit is contained in:
Christoph Haas 2025-01-11 18:32:23 +01:00
parent 38838eb4ce
commit fb19b6c4a8
11 changed files with 315 additions and 9 deletions

View File

@ -37,6 +37,7 @@
"users": "Benutzer", "users": "Benutzer",
"lang": "Sprache ändern", "lang": "Sprache ändern",
"profile": "Mein Profil", "profile": "Mein Profil",
"settings": "Einstellungen",
"login": "Anmelden", "login": "Anmelden",
"logout": "Abmelden" "logout": "Abmelden"
}, },
@ -167,6 +168,26 @@
"button-show-peer": "Show Peer", "button-show-peer": "Show Peer",
"button-edit-peer": "Edit 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": { "modals": {
"user-view": { "user-view": {
"headline": "User Account:", "headline": "User Account:",

View File

@ -674,6 +674,7 @@
"/peer/config-qr/{id}": { "/peer/config-qr/{id}": {
"get": { "get": {
"produces": [ "produces": [
"image/png",
"application/json" "application/json"
], ],
"tags": [ "tags": [
@ -694,7 +695,7 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"type": "string" "type": "file"
} }
}, },
"400": { "400": {
@ -1964,6 +1965,9 @@
"model.Settings": { "model.Settings": {
"type": "object", "type": "object",
"properties": { "properties": {
"ApiAdminOnly": {
"type": "boolean"
},
"MailLinkOnly": { "MailLinkOnly": {
"type": "boolean" "type": "boolean"
}, },

View File

@ -357,6 +357,8 @@ definitions:
type: object type: object
model.Settings: model.Settings:
properties: properties:
ApiAdminOnly:
type: boolean
MailLinkOnly: MailLinkOnly:
type: boolean type: boolean
PersistentConfigSupported: PersistentConfigSupported:
@ -943,12 +945,13 @@ paths:
required: true required: true
type: string type: string
produces: produces:
- image/png
- application/json - application/json
responses: responses:
"200": "200":
description: OK description: OK
schema: schema:
type: string type: file
"400": "400":
description: Bad Request description: Bad Request
schema: schema:

View File

@ -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": { "/user/all": {
"get": { "get": {
"security": [ "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": { "models.User": {
"type": "object", "type": "object",
"required": [ "required": [
@ -1775,16 +1870,19 @@
"type": "object", "type": "object",
"properties": { "properties": {
"PeerCount": { "PeerCount": {
"description": "PeerCount is the number of peers linked to the user.",
"type": "integer", "type": "integer",
"example": 2 "example": 2
}, },
"Peers": { "Peers": {
"description": "Peers is a list of peers linked to the user.",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/models.UserInformationPeer" "$ref": "#/definitions/models.UserInformationPeer"
} }
}, },
"UserIdentifier": { "UserIdentifier": {
"description": "UserIdentifier is the unique identifier of the user.",
"type": "string", "type": "string",
"example": "uid-1234567" "example": "uid-1234567"
} }

View File

@ -383,6 +383,32 @@ definitions:
- InterfaceIdentifier - InterfaceIdentifier
- PrivateKey - PrivateKey
type: object 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: models.User:
properties: properties:
ApiEnabled: ApiEnabled:
@ -478,13 +504,16 @@ definitions:
models.UserInformation: models.UserInformation:
properties: properties:
PeerCount: PeerCount:
description: PeerCount is the number of peers linked to the user.
example: 2 example: 2
type: integer type: integer
Peers: Peers:
description: Peers is a list of peers linked to the user.
items: items:
$ref: '#/definitions/models.UserInformationPeer' $ref: '#/definitions/models.UserInformationPeer'
type: array type: array
UserIdentifier: UserIdentifier:
description: UserIdentifier is the unique identifier of the user.
example: uid-1234567 example: uid-1234567
type: string type: string
type: object type: object
@ -1087,6 +1116,50 @@ paths:
summary: Get information about all peer records for a given user. summary: Get information about all peer records for a given user.
tags: tags:
- Provisioning - 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: /user/all:
get: get:
operationId: users_handleAllGet operationId: users_handleAllGet

View File

@ -345,7 +345,7 @@ func (e peerEndpoint) handleConfigGet() gin.HandlerFunc {
// @Produce png // @Produce png
// @Produce json // @Produce json
// @Param id path string true "The peer identifier" // @Param id path string true "The peer identifier"
// @Success 200 {object} file // @Success 200 {file} binary
// @Failure 400 {object} model.Error // @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error // @Failure 500 {object} model.Error
// @Router /peer/config-qr/{id} [get] // @Router /peer/config-qr/{id} [get]

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"github.com/h44z/wg-portal/internal/app/api/v1/models"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
@ -17,6 +18,8 @@ type ProvisioningServiceUserManagerRepo interface {
type ProvisioningServicePeerManagerRepo interface { type ProvisioningServicePeerManagerRepo interface {
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
GetUserPeers(context.Context, domain.UserIdentifier) ([]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 { type ProvisioningServiceConfigFileManagerRepo interface {
@ -128,3 +131,44 @@ func (p ProvisioningService) GetPeerQrPng(ctx context.Context, peerId domain.Pee
return peerCfgQrData, nil 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
}

View File

@ -18,6 +18,7 @@ type ProvisioningEndpointProvisioningService interface {
) )
GetPeerConfig(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error) GetPeerConfig(ctx context.Context, peerId domain.PeerIdentifier) ([]byte, error)
GetPeerQrPng(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 { 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/user-info", authenticator.LoggedIn(), e.handleUserInfoGet())
apiGroup.GET("/data/peer-config", authenticator.LoggedIn(), e.handlePeerConfigGet()) apiGroup.GET("/data/peer-config", authenticator.LoggedIn(), e.handlePeerConfigGet())
apiGroup.GET("/data/peer-qr", authenticator.LoggedIn(), e.handlePeerQrGet()) apiGroup.GET("/data/peer-qr", authenticator.LoggedIn(), e.handlePeerQrGet())
apiGroup.POST("/new-peer", authenticator.LoggedIn(), e.handleNewPeerPost())
} }
// handleUserInfoGet returns a gorm Handler function. // handleUserInfoGet returns a gorm Handler function.
@ -153,3 +156,40 @@ func (e ProvisioningEndpoint) handlePeerQrGet() gin.HandlerFunc {
c.Data(http.StatusOK, "image/png", peerConfigQrCode) 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))
}
}

View File

@ -4,9 +4,12 @@ import "github.com/h44z/wg-portal/internal/domain"
// UserInformation represents the information about a user and its linked peers. // UserInformation represents the information about a user and its linked peers.
type UserInformation struct { type UserInformation struct {
UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"` // UserIdentifier is the unique identifier of the user.
PeerCount int `json:"PeerCount" example:"2"` UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"`
Peers []UserInformationPeer `json:"Peers"` // 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. // UserInformationPeer represents the information about a peer.
@ -56,3 +59,17 @@ func NewUserInformationPeer(peer domain.Peer) UserInformationPeer {
return up 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"`
}

View File

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -34,9 +33,9 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
} }
peer.UserIdentifier = userId 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.Notes = fmt.Sprintf("Default peer created for user %s", userId)
peer.AutomaticallyCreated = true peer.AutomaticallyCreated = true
peer.GenerateDisplayName("Default")
newPeers = append(newPeers, *peer) newPeers = append(newPeers, *peer)
} }
@ -108,7 +107,6 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier)
ExtraAllowedIPsStr: "", ExtraAllowedIPsStr: "",
PresharedKey: pk, PresharedKey: pk,
PersistentKeepalive: domain.NewConfigOption(iface.PeerDefPersistentKeepalive, true), PersistentKeepalive: domain.NewConfigOption(iface.PeerDefPersistentKeepalive, true),
DisplayName: fmt.Sprintf("Peer %s", internal.TruncateString(string(peerId), 8)),
Identifier: peerId, Identifier: peerId,
UserIdentifier: currentUser.Id, UserIdentifier: currentUser.Id,
InterfaceIdentifier: iface.Identifier, InterfaceIdentifier: iface.Identifier,
@ -132,6 +130,7 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier)
PostDown: domain.NewConfigOption(iface.PeerDefPostDown, true), PostDown: domain.NewConfigOption(iface.PeerDefPostDown, true),
}, },
} }
freshPeer.GenerateDisplayName("")
return freshPeer, nil return freshPeer, nil
} }

View File

@ -120,6 +120,13 @@ func (p *Peer) ApplyInterfaceDefaults(in *Interface) {
p.Interface.PostDown.TrySetValue(in.PeerDefPostDown) 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 { type PeerInterfaceConfig struct {
KeyPair // private/public Key of the peer KeyPair // private/public Key of the peer