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",
"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:",

View File

@ -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"
},

View File

@ -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:

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": {
"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"
}

View File

@ -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

View File

@ -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]

View File

@ -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
}

View File

@ -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))
}
}

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.
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"`
}

View File

@ -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
}

View File

@ -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