mirror of
https://github.com/h44z/wg-portal.git
synced 2025-06-27 16:57:01 +00:00
add simple webhook feature for peer, interface and user events (#398)
This commit is contained in:
parent
e75a32e4d0
commit
9354a1d9d3
@ -44,6 +44,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL or Post
|
|||||||
* Handles route and DNS settings like wg-quick does
|
* Handles route and DNS settings like wg-quick does
|
||||||
* Exposes Prometheus metrics for monitoring and alerting
|
* Exposes Prometheus metrics for monitoring and alerting
|
||||||
* REST API for management and client deployment
|
* REST API for management and client deployment
|
||||||
|
* Webhook for custom actions on peer, interface or user updates
|
||||||
|
|
||||||
<!-- Text to this line # is included in docs/documentation/overview.md -->
|
<!-- Text to this line # is included in docs/documentation/overview.md -->
|
||||||

|

|
||||||
@ -61,7 +62,6 @@ For the complete documentation visit [wgportal.org](https://wgportal.org).
|
|||||||
## Application stack
|
## Application stack
|
||||||
|
|
||||||
* [wgctrl-go](https://github.com/WireGuard/wgctrl-go) and [netlink](https://github.com/vishvananda/netlink) for interface handling
|
* [wgctrl-go](https://github.com/WireGuard/wgctrl-go) and [netlink](https://github.com/vishvananda/netlink) for interface handling
|
||||||
* [Gin](https://github.com/gin-gonic/gin), HTTP web framework written in Go
|
|
||||||
* [Bootstrap](https://getbootstrap.com/), for the HTML templates
|
* [Bootstrap](https://getbootstrap.com/), for the HTML templates
|
||||||
* [Vue.js](https://vuejs.org/), for the frontend
|
* [Vue.js](https://vuejs.org/), for the frontend
|
||||||
|
|
||||||
|
@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/h44z/wg-portal/internal/app/mail"
|
"github.com/h44z/wg-portal/internal/app/mail"
|
||||||
"github.com/h44z/wg-portal/internal/app/route"
|
"github.com/h44z/wg-portal/internal/app/route"
|
||||||
"github.com/h44z/wg-portal/internal/app/users"
|
"github.com/h44z/wg-portal/internal/app/users"
|
||||||
|
"github.com/h44z/wg-portal/internal/app/webhooks"
|
||||||
"github.com/h44z/wg-portal/internal/app/wireguard"
|
"github.com/h44z/wg-portal/internal/app/wireguard"
|
||||||
"github.com/h44z/wg-portal/internal/config"
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
)
|
)
|
||||||
@ -102,6 +103,10 @@ func main() {
|
|||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
routeManager.StartBackgroundJobs(ctx)
|
routeManager.StartBackgroundJobs(ctx)
|
||||||
|
|
||||||
|
webhookManager, err := webhooks.NewManager(cfg, eventBus)
|
||||||
|
internal.AssertNoError(err)
|
||||||
|
webhookManager.StartBackgroundJobs(ctx)
|
||||||
|
|
||||||
err = app.Initialize(cfg, wireGuardManager, userManager)
|
err = app.Initialize(cfg, wireGuardManager, userManager)
|
||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
|
|
||||||
|
@ -13,6 +13,11 @@ web:
|
|||||||
external_url: http://localhost:8888
|
external_url: http://localhost:8888
|
||||||
request_logging: true
|
request_logging: true
|
||||||
|
|
||||||
|
webhook:
|
||||||
|
url: ""
|
||||||
|
authentication: ""
|
||||||
|
timeout: 10s
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
ldap:
|
ldap:
|
||||||
- id: ldap1
|
- id: ldap1
|
||||||
|
@ -81,6 +81,11 @@ web:
|
|||||||
request_logging: false
|
request_logging: false
|
||||||
cert_file: ""
|
cert_file: ""
|
||||||
key_File: ""
|
key_File: ""
|
||||||
|
|
||||||
|
webhook:
|
||||||
|
url: ""
|
||||||
|
authentication: ""
|
||||||
|
timeout: 10s
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@ -92,8 +97,9 @@ Below you will find sections like
|
|||||||
[`database`](#database),
|
[`database`](#database),
|
||||||
[`statistics`](#statistics),
|
[`statistics`](#statistics),
|
||||||
[`mail`](#mail),
|
[`mail`](#mail),
|
||||||
[`auth`](#auth) and
|
[`auth`](#auth),
|
||||||
[`web`](#web).
|
[`web`](#web) and
|
||||||
|
[`webhook`](#webhook).
|
||||||
Each section describes the individual configuration keys, their default values, and a brief explanation of their purpose.
|
Each section describes the individual configuration keys, their default values, and a brief explanation of their purpose.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -556,6 +562,10 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
|||||||
|
|
||||||
## Web
|
## Web
|
||||||
|
|
||||||
|
The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection.
|
||||||
|
It is important to specify a valid `external_url` for the web server, especially if you are using a reverse proxy.
|
||||||
|
Without a valid `external_url`, the login process may fail due to CSRF protection.
|
||||||
|
|
||||||
### `listening_address`
|
### `listening_address`
|
||||||
- **Default:** `:8888`
|
- **Default:** `:8888`
|
||||||
- **Description:** The listening port of the web server.
|
- **Description:** The listening port of the web server.
|
||||||
@ -596,3 +606,33 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
|||||||
### `key_file`
|
### `key_file`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
- **Description:** (Optional) Path to the TLS certificate key file.
|
- **Description:** (Optional) Path to the TLS certificate key file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhook
|
||||||
|
|
||||||
|
The webhook section allows you to configure a webhook that is called on certain events in WireGuard Portal.
|
||||||
|
A JSON object is sent in a POST request to the webhook URL with the following structure:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "peer_created",
|
||||||
|
"entity": "peer",
|
||||||
|
"identifier": "the-peer-identifier",
|
||||||
|
"payload": {
|
||||||
|
// The payload of the event, e.g. peer data.
|
||||||
|
// Check the API documentation for the exact structure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `url`
|
||||||
|
- **Default:** *(empty)*
|
||||||
|
- **Description:** The POST endpoint to which the webhook is sent. The URL must be reachable from the WireGuard Portal server. If the URL is empty, the webhook is disabled.
|
||||||
|
|
||||||
|
### `authentication`
|
||||||
|
- **Default:** *(empty)*
|
||||||
|
- **Description:** The Authorization header for the webhook endpoint. The value is send as-is in the header. For example: `Bearer <token>`.
|
||||||
|
|
||||||
|
### `timeout`
|
||||||
|
- **Default:** `10s`
|
||||||
|
- **Description:** The timeout for the webhook request. If the request takes longer than this, it is aborted.
|
@ -56,3 +56,16 @@ func (r *FilesystemRepo) WriteFile(path string, contents io.Reader) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteFile deletes the file at the given path.
|
||||||
|
// The path is relative to the base path of the repository.
|
||||||
|
// If the file does not exist, it is ignored.
|
||||||
|
func (r *FilesystemRepo) DeleteFile(path string) error {
|
||||||
|
filePath := filepath.Join(r.basePath, path)
|
||||||
|
|
||||||
|
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to delete file %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -37,6 +37,9 @@ type WireguardDatabaseRepo interface {
|
|||||||
type FileSystemRepo interface {
|
type FileSystemRepo interface {
|
||||||
// WriteFile writes the contents to the file at the given path.
|
// WriteFile writes the contents to the file at the given path.
|
||||||
WriteFile(path string, contents io.Reader) error
|
WriteFile(path string, contents io.Reader) error
|
||||||
|
|
||||||
|
// DeleteFile deletes the file at the given path.
|
||||||
|
DeleteFile(path string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type TemplateRenderer interface {
|
type TemplateRenderer interface {
|
||||||
@ -109,22 +112,37 @@ func (m Manager) createStorageDirectory() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) connectToMessageBus() {
|
func (m Manager) connectToMessageBus() {
|
||||||
_ = m.bus.Subscribe(app.TopicInterfaceUpdated, m.handleInterfaceUpdatedEvent)
|
_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceSavedEvent)
|
||||||
|
_ = m.bus.Subscribe(app.TopicInterfaceUpdated, m.handleInterfaceSavedEvent)
|
||||||
|
_ = m.bus.Subscribe(app.TopicInterfaceDeleted, m.handleInterfaceDeleteEvent)
|
||||||
_ = m.bus.Subscribe(app.TopicPeerInterfaceUpdated, m.handlePeerInterfaceUpdatedEvent)
|
_ = m.bus.Subscribe(app.TopicPeerInterfaceUpdated, m.handlePeerInterfaceUpdatedEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) handleInterfaceUpdatedEvent(iface *domain.Interface) {
|
func (m Manager) handleInterfaceSavedEvent(iface domain.Interface) {
|
||||||
if !iface.SaveConfig {
|
if !iface.SaveConfig {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("handling interface updated event", "interface", iface.Identifier)
|
slog.Debug("handling interface save event", "interface", iface.Identifier)
|
||||||
|
|
||||||
err := m.PersistInterfaceConfig(context.Background(), iface.Identifier)
|
err := m.PersistInterfaceConfig(context.Background(), iface.Identifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to automatically persist interface config",
|
slog.Error("failed to automatically persist interface config",
|
||||||
"interface", iface.Identifier,
|
"interface", iface.Identifier, "error", err)
|
||||||
"error", err)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) {
|
||||||
|
if !iface.SaveConfig {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("handling interface delete event", "interface", iface.Identifier)
|
||||||
|
|
||||||
|
err := m.UnpersistInterfaceConfig(context.Background(), iface.GetConfigFileName())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to remove persisted interface config",
|
||||||
|
"interface", iface.Identifier, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,6 +269,15 @@ func (m Manager) PersistInterfaceConfig(ctx context.Context, id domain.Interface
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnpersistInterfaceConfig removes the configuration file for the given interface from the file system.
|
||||||
|
func (m Manager) UnpersistInterfaceConfig(_ context.Context, filename string) error {
|
||||||
|
if err := m.fsRepo.DeleteFile(filename); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove interface config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type nopCloser struct {
|
type nopCloser struct {
|
||||||
io.Writer
|
io.Writer
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,50 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
|
// region misc-events
|
||||||
|
|
||||||
|
const TopicAuthLogin = "auth:login"
|
||||||
|
const TopicRouteUpdate = "route:update"
|
||||||
|
const TopicRouteRemove = "route:remove"
|
||||||
|
|
||||||
|
// endregion misc-events
|
||||||
|
|
||||||
|
// region user-events
|
||||||
|
|
||||||
const TopicUserCreated = "user:created"
|
const TopicUserCreated = "user:created"
|
||||||
|
const TopicUserDeleted = "user:deleted"
|
||||||
|
const TopicUserUpdated = "user:updated"
|
||||||
const TopicUserApiEnabled = "user:api:enabled"
|
const TopicUserApiEnabled = "user:api:enabled"
|
||||||
const TopicUserApiDisabled = "user:api:disabled"
|
const TopicUserApiDisabled = "user:api:disabled"
|
||||||
const TopicUserRegistered = "user:registered"
|
const TopicUserRegistered = "user:registered"
|
||||||
const TopicUserDisabled = "user:disabled"
|
const TopicUserDisabled = "user:disabled"
|
||||||
const TopicUserEnabled = "user:enabled"
|
const TopicUserEnabled = "user:enabled"
|
||||||
const TopicUserDeleted = "user:deleted"
|
|
||||||
const TopicAuthLogin = "auth:login"
|
// endregion user-events
|
||||||
const TopicRouteUpdate = "route:update"
|
|
||||||
const TopicRouteRemove = "route:remove"
|
// region interface-events
|
||||||
|
|
||||||
|
const TopicInterfaceCreated = "interface:created"
|
||||||
const TopicInterfaceUpdated = "interface:updated"
|
const TopicInterfaceUpdated = "interface:updated"
|
||||||
|
const TopicInterfaceDeleted = "interface:deleted"
|
||||||
|
|
||||||
|
// endregion interface-events
|
||||||
|
|
||||||
|
// region peer-events
|
||||||
|
|
||||||
|
const TopicPeerCreated = "peer:created"
|
||||||
|
const TopicPeerDeleted = "peer:deleted"
|
||||||
|
const TopicPeerUpdated = "peer:updated"
|
||||||
const TopicPeerInterfaceUpdated = "peer:interface:updated"
|
const TopicPeerInterfaceUpdated = "peer:interface:updated"
|
||||||
const TopicPeerIdentifierUpdated = "peer:identifier:updated"
|
const TopicPeerIdentifierUpdated = "peer:identifier:updated"
|
||||||
|
|
||||||
|
// endregion peer-events
|
||||||
|
|
||||||
|
// region audit-events
|
||||||
|
|
||||||
const TopicAuditLoginSuccess = "audit:login:success"
|
const TopicAuditLoginSuccess = "audit:login:success"
|
||||||
const TopicAuditLoginFailed = "audit:login:failed"
|
const TopicAuditLoginFailed = "audit:login:failed"
|
||||||
|
|
||||||
const TopicAuditInterfaceChanged = "audit:interface:changed"
|
const TopicAuditInterfaceChanged = "audit:interface:changed"
|
||||||
const TopicAuditPeerChanged = "audit:peer:changed"
|
const TopicAuditPeerChanged = "audit:peer:changed"
|
||||||
|
|
||||||
|
// endregion audit-events
|
||||||
|
@ -77,47 +77,12 @@ func (m Manager) RegisterUser(ctx context.Context, user *domain.User) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := m.NewUser(ctx, user)
|
createdUser, err := m.CreateUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.bus.Publish(app.TopicUserRegistered, user)
|
m.bus.Publish(app.TopicUserRegistered, createdUser)
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUser creates a new user.
|
|
||||||
func (m Manager) NewUser(ctx context.Context, user *domain.User) error {
|
|
||||||
if user.Identifier == "" {
|
|
||||||
return errors.New("missing user identifier")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
|
|
||||||
u.Identifier = user.Identifier
|
|
||||||
u.Email = user.Email
|
|
||||||
u.Source = user.Source
|
|
||||||
u.ProviderName = user.ProviderName
|
|
||||||
u.IsAdmin = user.IsAdmin
|
|
||||||
u.Firstname = user.Firstname
|
|
||||||
u.Lastname = user.Lastname
|
|
||||||
u.Phone = user.Phone
|
|
||||||
u.Department = user.Department
|
|
||||||
u.Notes = user.Notes
|
|
||||||
u.ApiToken = user.ApiToken
|
|
||||||
u.ApiTokenCreated = user.ApiTokenCreated
|
|
||||||
|
|
||||||
return u, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.bus.Publish(app.TopicUserCreated, user)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -229,6 +194,8 @@ func (m Manager) UpdateUser(ctx context.Context, user *domain.User) (*domain.Use
|
|||||||
return nil, fmt.Errorf("update failure: %w", err)
|
return nil, fmt.Errorf("update failure: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicUserUpdated, *user)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case !existingUser.IsDisabled() && user.IsDisabled():
|
case !existingUser.IsDisabled() && user.IsDisabled():
|
||||||
m.bus.Publish(app.TopicUserDisabled, *user)
|
m.bus.Publish(app.TopicUserDisabled, *user)
|
||||||
@ -241,6 +208,10 @@ func (m Manager) UpdateUser(ctx context.Context, user *domain.User) (*domain.Use
|
|||||||
|
|
||||||
// CreateUser creates a new user.
|
// CreateUser creates a new user.
|
||||||
func (m Manager) CreateUser(ctx context.Context, user *domain.User) (*domain.User, error) {
|
func (m Manager) CreateUser(ctx context.Context, user *domain.User) (*domain.User, error) {
|
||||||
|
if user.Identifier == "" {
|
||||||
|
return nil, errors.New("missing user identifier")
|
||||||
|
}
|
||||||
|
|
||||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -270,6 +241,8 @@ func (m Manager) CreateUser(ctx context.Context, user *domain.User) (*domain.Use
|
|||||||
return nil, fmt.Errorf("creation failure: %w", err)
|
return nil, fmt.Errorf("creation failure: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicUserCreated, *user)
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,6 +294,7 @@ func (m Manager) ActivateApi(ctx context.Context, id domain.UserIdentifier) (*do
|
|||||||
return nil, fmt.Errorf("update failure: %w", err)
|
return nil, fmt.Errorf("update failure: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicUserUpdated, user)
|
||||||
m.bus.Publish(app.TopicUserApiEnabled, user)
|
m.bus.Publish(app.TopicUserApiEnabled, user)
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
@ -348,6 +322,7 @@ func (m Manager) DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*
|
|||||||
return nil, fmt.Errorf("update failure: %w", err)
|
return nil, fmt.Errorf("update failure: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicUserUpdated, user)
|
||||||
m.bus.Publish(app.TopicUserApiDisabled, user)
|
m.bus.Publish(app.TopicUserApiDisabled, user)
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
@ -555,7 +530,7 @@ func (m Manager) updateLdapUsers(
|
|||||||
// create new user
|
// create new user
|
||||||
slog.Debug("creating new user from provider", "user", user.Identifier, "provider", provider.ProviderName)
|
slog.Debug("creating new user from provider", "user", user.Identifier, "provider", provider.ProviderName)
|
||||||
|
|
||||||
err := m.NewUser(tctx, user)
|
_, err := m.CreateUser(tctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
return fmt.Errorf("create error for user id %s: %w", user.Identifier, err)
|
return fmt.Errorf("create error for user id %s: %w", user.Identifier, err)
|
||||||
|
185
internal/app/webhooks/manager.go
Normal file
185
internal/app/webhooks/manager.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/h44z/wg-portal/internal/app"
|
||||||
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// region dependencies
|
||||||
|
|
||||||
|
type EventBus interface {
|
||||||
|
// Publish sends a message to the message bus.
|
||||||
|
Publish(topic string, args ...any)
|
||||||
|
// Subscribe subscribes to a topic
|
||||||
|
Subscribe(topic string, fn interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion dependencies
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
cfg *config.Config
|
||||||
|
bus EventBus
|
||||||
|
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new webhook manager instance.
|
||||||
|
func NewManager(cfg *config.Config, bus EventBus) (*Manager, error) {
|
||||||
|
m := &Manager{
|
||||||
|
cfg: cfg,
|
||||||
|
bus: bus,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: cfg.Webhook.Timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.connectToMessageBus()
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartBackgroundJobs starts background jobs for the webhook manager.
|
||||||
|
// This method is non-blocking and returns immediately.
|
||||||
|
func (m Manager) StartBackgroundJobs(_ context.Context) {
|
||||||
|
// this is a no-op for now
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) connectToMessageBus() {
|
||||||
|
if m.cfg.Webhook.Url == "" {
|
||||||
|
slog.Info("[WEBHOOK] no webhook configured, skipping event-bus subscription")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = m.bus.Subscribe(app.TopicUserCreated, m.handleUserCreateEvent)
|
||||||
|
_ = m.bus.Subscribe(app.TopicUserUpdated, m.handleUserUpdateEvent)
|
||||||
|
_ = m.bus.Subscribe(app.TopicUserDeleted, m.handleUserDeleteEvent)
|
||||||
|
|
||||||
|
_ = m.bus.Subscribe(app.TopicPeerCreated, m.handlePeerCreateEvent)
|
||||||
|
_ = m.bus.Subscribe(app.TopicPeerUpdated, m.handlePeerUpdateEvent)
|
||||||
|
_ = m.bus.Subscribe(app.TopicPeerDeleted, m.handlePeerDeleteEvent)
|
||||||
|
|
||||||
|
_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreateEvent)
|
||||||
|
_ = m.bus.Subscribe(app.TopicInterfaceUpdated, m.handleInterfaceUpdateEvent)
|
||||||
|
_ = m.bus.Subscribe(app.TopicInterfaceDeleted, m.handleInterfaceDeleteEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) sendWebhook(ctx context.Context, data io.Reader) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.cfg.Webhook.Url, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if m.cfg.Webhook.Authentication != "" {
|
||||||
|
req.Header.Set("Authorization", m.cfg.Webhook.Authentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
err := Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("[WEBHOOK] failed to close response body", "error", err)
|
||||||
|
}
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusAccepted {
|
||||||
|
return fmt.Errorf("webhook request failed with status: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handleUserCreateEvent(user domain.User) {
|
||||||
|
m.handleGenericEvent(WebhookEventCreate, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handleUserUpdateEvent(user domain.User) {
|
||||||
|
m.handleGenericEvent(WebhookEventUpdate, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handleUserDeleteEvent(user domain.User) {
|
||||||
|
m.handleGenericEvent(WebhookEventDelete, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handlePeerCreateEvent(peer domain.Peer) {
|
||||||
|
m.handleGenericEvent(WebhookEventCreate, peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handlePeerUpdateEvent(peer domain.Peer) {
|
||||||
|
m.handleGenericEvent(WebhookEventUpdate, peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handlePeerDeleteEvent(peer domain.Peer) {
|
||||||
|
m.handleGenericEvent(WebhookEventDelete, peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handleInterfaceCreateEvent(iface domain.Interface) {
|
||||||
|
m.handleGenericEvent(WebhookEventCreate, iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handleInterfaceUpdateEvent(iface domain.Interface) {
|
||||||
|
m.handleGenericEvent(WebhookEventUpdate, iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) {
|
||||||
|
m.handleGenericEvent(WebhookEventDelete, iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handleGenericEvent(action WebhookEvent, payload any) {
|
||||||
|
eventData, err := m.createWebhookData(action, payload)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("[WEBHOOK] failed to create webhook data", "error", err, "action", action,
|
||||||
|
"payload", fmt.Sprintf("%T", payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eventJson, err := eventData.Serialize()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("[WEBHOOK] failed to serialize event data", "error", err, "action", action,
|
||||||
|
"payload", fmt.Sprintf("%T", payload), "identifier", eventData.Identifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = m.sendWebhook(context.Background(), eventJson)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("[WEBHOOK] failed to execute webhook", "error", err, "action", action,
|
||||||
|
"payload", fmt.Sprintf("%T", payload), "identifier", eventData.Identifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("[WEBHOOK] executed webhook", "action", action, "payload", fmt.Sprintf("%T", payload),
|
||||||
|
"identifier", eventData.Identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) createWebhookData(action WebhookEvent, payload any) (*WebhookData, error) {
|
||||||
|
d := &WebhookData{
|
||||||
|
Event: action,
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := payload.(type) {
|
||||||
|
case domain.User:
|
||||||
|
d.Entity = WebhookEntityUser
|
||||||
|
d.Identifier = string(v.Identifier)
|
||||||
|
case domain.Peer:
|
||||||
|
d.Entity = WebhookEntityPeer
|
||||||
|
d.Identifier = string(v.Identifier)
|
||||||
|
case domain.Interface:
|
||||||
|
d.Entity = WebhookEntityInterface
|
||||||
|
d.Identifier = string(v.Identifier)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported payload type: %T", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return d, nil
|
||||||
|
}
|
48
internal/app/webhooks/model.go
Normal file
48
internal/app/webhooks/model.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WebhookData is the data structure for the webhook payload.
|
||||||
|
type WebhookData struct {
|
||||||
|
// Event is the event type (e.g. create, update, delete)
|
||||||
|
Event WebhookEvent `json:"event" example:"create"`
|
||||||
|
|
||||||
|
// Entity is the entity type (e.g. user, peer, interface)
|
||||||
|
Entity WebhookEntity `json:"entity" example:"user"`
|
||||||
|
|
||||||
|
// Identifier is the identifier of the entity
|
||||||
|
Identifier string `json:"identifier" example:"user-123"`
|
||||||
|
|
||||||
|
// Payload is the payload of the event
|
||||||
|
Payload any `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize serializes the WebhookData to JSON and returns it as an io.Reader.
|
||||||
|
func (d *WebhookData) Serialize() (io.Reader, error) {
|
||||||
|
data, err := json.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.NewReader(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookEntity = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
WebhookEntityUser WebhookEntity = "user"
|
||||||
|
WebhookEntityPeer WebhookEntity = "peer"
|
||||||
|
WebhookEntityInterface WebhookEntity = "interface"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebhookEvent = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
WebhookEventCreate WebhookEvent = "create"
|
||||||
|
WebhookEventUpdate WebhookEvent = "update"
|
||||||
|
WebhookEventDelete WebhookEvent = "delete"
|
||||||
|
)
|
@ -410,6 +410,8 @@ func (m Manager) CreateInterface(ctx context.Context, in *domain.Interface) (*do
|
|||||||
return nil, fmt.Errorf("creation failure: %w", err)
|
return nil, fmt.Errorf("creation failure: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicInterfaceCreated, *in)
|
||||||
|
|
||||||
return in, nil
|
return in, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -433,6 +435,8 @@ func (m Manager) UpdateInterface(ctx context.Context, in *domain.Interface) (*do
|
|||||||
return nil, nil, fmt.Errorf("update failure: %w", err)
|
return nil, nil, fmt.Errorf("update failure: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicInterfaceUpdated, *in)
|
||||||
|
|
||||||
return in, existingPeers, nil
|
return in, existingPeers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -490,6 +494,8 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
|
|||||||
return fmt.Errorf("post-delete hooks failed: %w", err)
|
return fmt.Errorf("post-delete hooks failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicInterfaceDeleted, *existingInterface)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -549,7 +555,6 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
|
|||||||
return nil, fmt.Errorf("post-save hooks failed: %w", err)
|
return nil, fmt.Errorf("post-save hooks failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.bus.Publish(app.TopicInterfaceUpdated, iface)
|
|
||||||
m.bus.Publish(app.TopicAuditInterfaceChanged, domain.AuditEventWrapper[audit.InterfaceEvent]{
|
m.bus.Publish(app.TopicAuditInterfaceChanged, domain.AuditEventWrapper[audit.InterfaceEvent]{
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
Event: audit.InterfaceEvent{
|
Event: audit.InterfaceEvent{
|
||||||
|
@ -204,6 +204,8 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
|
|||||||
return nil, fmt.Errorf("creation failure: %w", err)
|
return nil, fmt.Errorf("creation failure: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicPeerCreated, *peer)
|
||||||
|
|
||||||
return peer, nil
|
return peer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,6 +248,8 @@ func (m Manager) CreateMultiplePeers(
|
|||||||
createdPeers := make([]domain.Peer, len(newPeers))
|
createdPeers := make([]domain.Peer, len(newPeers))
|
||||||
for i := range newPeers {
|
for i := range newPeers {
|
||||||
createdPeers[i] = *newPeers[i]
|
createdPeers[i] = *newPeers[i]
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicPeerCreated, *newPeers[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
return createdPeers, nil
|
return createdPeers, nil
|
||||||
@ -315,6 +319,8 @@ func (m Manager) UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicPeerUpdated, *peer)
|
||||||
|
|
||||||
return peer, nil
|
return peer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -343,6 +349,7 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
|
|||||||
return fmt.Errorf("failed to delete peer %s: %w", id, err)
|
return fmt.Errorf("failed to delete peer %s: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicPeerDeleted, *peer)
|
||||||
// Update routes after peers have changed
|
// Update routes after peers have changed
|
||||||
m.bus.Publish(app.TopicRouteUpdate, "peers updated")
|
m.bus.Publish(app.TopicRouteUpdate, "peers updated")
|
||||||
// Update interface after peers have changed
|
// Update interface after peers have changed
|
||||||
@ -428,6 +435,7 @@ func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// publish event
|
// publish event
|
||||||
|
|
||||||
m.bus.Publish(app.TopicAuditPeerChanged, domain.AuditEventWrapper[audit.PeerEvent]{
|
m.bus.Publish(app.TopicAuditPeerChanged, domain.AuditEventWrapper[audit.PeerEvent]{
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
Event: audit.PeerEvent{
|
Event: audit.PeerEvent{
|
||||||
|
@ -62,6 +62,8 @@ type Config struct {
|
|||||||
Database DatabaseConfig `yaml:"database"`
|
Database DatabaseConfig `yaml:"database"`
|
||||||
|
|
||||||
Web WebConfig `yaml:"web"`
|
Web WebConfig `yaml:"web"`
|
||||||
|
|
||||||
|
Webhook WebhookConfig `yaml:"webhook"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogStartupValues logs the startup values of the configuration in debug level
|
// LogStartupValues logs the startup values of the configuration in debug level
|
||||||
@ -158,6 +160,10 @@ func defaultConfig() *Config {
|
|||||||
LinkOnly: false,
|
LinkOnly: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.Webhook.Url = "" // no webhook by default
|
||||||
|
cfg.Webhook.Authentication = ""
|
||||||
|
cfg.Webhook.Timeout = 10 * time.Second
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
14
internal/config/webhook.go
Normal file
14
internal/config/webhook.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// WebhookConfig contains the configuration for webhooks.
|
||||||
|
type WebhookConfig struct {
|
||||||
|
// Url is the URL to send the webhook to. If empty, no webhook will be sent.
|
||||||
|
Url string `yaml:"url"`
|
||||||
|
// Authentication is the authorization header for the webhook request.
|
||||||
|
// It can either be a Bearer token or a Basic auth string.
|
||||||
|
Authentication string `yaml:"authentication"`
|
||||||
|
// Timeout is the timeout for the webhook request.
|
||||||
|
Timeout time.Duration `yaml:"timeout"`
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user