From 9354a1d9d3d9b122477ad728ebb5e51910d03de6 Mon Sep 17 00:00:00 2001 From: Christoph Date: Sat, 19 Apr 2025 21:29:26 +0200 Subject: [PATCH] add simple webhook feature for peer, interface and user events (#398) --- README.md | 2 +- cmd/wg-portal/main.go | 5 + config.yml.sample | 5 + docs/documentation/configuration/overview.md | 44 ++++- internal/adapters/filesystem.go | 13 ++ internal/app/configfile/manager.go | 37 +++- internal/app/eventbus.go | 37 +++- internal/app/users/user_manager.go | 51 ++--- internal/app/webhooks/manager.go | 185 ++++++++++++++++++ internal/app/webhooks/model.go | 48 +++++ .../app/wireguard/wireguard_interfaces.go | 7 +- internal/app/wireguard/wireguard_peers.go | 8 + internal/config/config.go | 6 + internal/config/webhook.go | 14 ++ 14 files changed, 411 insertions(+), 51 deletions(-) create mode 100644 internal/app/webhooks/manager.go create mode 100644 internal/app/webhooks/model.go create mode 100644 internal/config/webhook.go diff --git a/README.md b/README.md index df73afa..a42ae02 100644 --- a/README.md +++ b/README.md @@ -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 * Exposes Prometheus metrics for monitoring and alerting * REST API for management and client deployment +* Webhook for custom actions on peer, interface or user updates ![Screenshot](docs/assets/images/screenshot.png) @@ -61,7 +62,6 @@ For the complete documentation visit [wgportal.org](https://wgportal.org). ## Application stack * [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 * [Vue.js](https://vuejs.org/), for the frontend diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index 896244c..9a24566 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -24,6 +24,7 @@ import ( "github.com/h44z/wg-portal/internal/app/mail" "github.com/h44z/wg-portal/internal/app/route" "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/config" ) @@ -102,6 +103,10 @@ func main() { internal.AssertNoError(err) routeManager.StartBackgroundJobs(ctx) + webhookManager, err := webhooks.NewManager(cfg, eventBus) + internal.AssertNoError(err) + webhookManager.StartBackgroundJobs(ctx) + err = app.Initialize(cfg, wireGuardManager, userManager) internal.AssertNoError(err) diff --git a/config.yml.sample b/config.yml.sample index 2fe4c69..039d034 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -13,6 +13,11 @@ web: external_url: http://localhost:8888 request_logging: true +webhook: + url: "" + authentication: "" + timeout: 10s + auth: ldap: - id: ldap1 diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index a7d6964..a155696 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -81,6 +81,11 @@ web: request_logging: false cert_file: "" key_File: "" + +webhook: + url: "" + authentication: "" + timeout: 10s ``` @@ -92,8 +97,9 @@ Below you will find sections like [`database`](#database), [`statistics`](#statistics), [`mail`](#mail), -[`auth`](#auth) and -[`web`](#web). +[`auth`](#auth), +[`web`](#web) and +[`webhook`](#webhook). 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 +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` - **Default:** `:8888` - **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` - **Default:** *(empty)* - **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 `. + +### `timeout` +- **Default:** `10s` +- **Description:** The timeout for the webhook request. If the request takes longer than this, it is aborted. \ No newline at end of file diff --git a/internal/adapters/filesystem.go b/internal/adapters/filesystem.go index 34221fd..bda4b24 100644 --- a/internal/adapters/filesystem.go +++ b/internal/adapters/filesystem.go @@ -56,3 +56,16 @@ func (r *FilesystemRepo) WriteFile(path string, contents io.Reader) error { 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 +} diff --git a/internal/app/configfile/manager.go b/internal/app/configfile/manager.go index 6e859b4..83c07f4 100644 --- a/internal/app/configfile/manager.go +++ b/internal/app/configfile/manager.go @@ -37,6 +37,9 @@ type WireguardDatabaseRepo interface { type FileSystemRepo interface { // WriteFile writes the contents to the file at the given path. WriteFile(path string, contents io.Reader) error + + // DeleteFile deletes the file at the given path. + DeleteFile(path string) error } type TemplateRenderer interface { @@ -109,22 +112,37 @@ func (m Manager) createStorageDirectory() error { } 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) } -func (m Manager) handleInterfaceUpdatedEvent(iface *domain.Interface) { +func (m Manager) handleInterfaceSavedEvent(iface domain.Interface) { if !iface.SaveConfig { 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) if err != nil { slog.Error("failed to automatically persist interface config", - "interface", iface.Identifier, - "error", err) + "interface", iface.Identifier, "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 } +// 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 { io.Writer } diff --git a/internal/app/eventbus.go b/internal/app/eventbus.go index e67bade..56a3220 100644 --- a/internal/app/eventbus.go +++ b/internal/app/eventbus.go @@ -1,21 +1,50 @@ 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 TopicUserDeleted = "user:deleted" +const TopicUserUpdated = "user:updated" const TopicUserApiEnabled = "user:api:enabled" const TopicUserApiDisabled = "user:api:disabled" const TopicUserRegistered = "user:registered" const TopicUserDisabled = "user:disabled" const TopicUserEnabled = "user:enabled" -const TopicUserDeleted = "user:deleted" -const TopicAuthLogin = "auth:login" -const TopicRouteUpdate = "route:update" -const TopicRouteRemove = "route:remove" + +// endregion user-events + +// region interface-events + +const TopicInterfaceCreated = "interface:created" 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 TopicPeerIdentifierUpdated = "peer:identifier:updated" +// endregion peer-events + +// region audit-events + const TopicAuditLoginSuccess = "audit:login:success" const TopicAuditLoginFailed = "audit:login:failed" const TopicAuditInterfaceChanged = "audit:interface:changed" const TopicAuditPeerChanged = "audit:peer:changed" + +// endregion audit-events diff --git a/internal/app/users/user_manager.go b/internal/app/users/user_manager.go index 1f2b5c3..af3cd21 100644 --- a/internal/app/users/user_manager.go +++ b/internal/app/users/user_manager.go @@ -77,47 +77,12 @@ func (m Manager) RegisterUser(ctx context.Context, user *domain.User) error { return err } - err := m.NewUser(ctx, user) + createdUser, err := m.CreateUser(ctx, user) if err != nil { return err } - m.bus.Publish(app.TopicUserRegistered, user) - - 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) + m.bus.Publish(app.TopicUserRegistered, createdUser) 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) } + m.bus.Publish(app.TopicUserUpdated, *user) + switch { case !existingUser.IsDisabled() && user.IsDisabled(): 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. 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 { 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) } + m.bus.Publish(app.TopicUserCreated, *user) + 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) } + m.bus.Publish(app.TopicUserUpdated, user) m.bus.Publish(app.TopicUserApiEnabled, user) 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) } + m.bus.Publish(app.TopicUserUpdated, user) m.bus.Publish(app.TopicUserApiDisabled, user) return user, nil @@ -555,7 +530,7 @@ func (m Manager) updateLdapUsers( // create new user 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 { cancel() return fmt.Errorf("create error for user id %s: %w", user.Identifier, err) diff --git a/internal/app/webhooks/manager.go b/internal/app/webhooks/manager.go new file mode 100644 index 0000000..9a81bc0 --- /dev/null +++ b/internal/app/webhooks/manager.go @@ -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 +} diff --git a/internal/app/webhooks/model.go b/internal/app/webhooks/model.go new file mode 100644 index 0000000..3f0fe33 --- /dev/null +++ b/internal/app/webhooks/model.go @@ -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" +) diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go index 9b8b48e..f232eec 100644 --- a/internal/app/wireguard/wireguard_interfaces.go +++ b/internal/app/wireguard/wireguard_interfaces.go @@ -410,6 +410,8 @@ func (m Manager) CreateInterface(ctx context.Context, in *domain.Interface) (*do return nil, fmt.Errorf("creation failure: %w", err) } + m.bus.Publish(app.TopicInterfaceCreated, *in) + 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) } + m.bus.Publish(app.TopicInterfaceUpdated, *in) + 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) } + m.bus.Publish(app.TopicInterfaceDeleted, *existingInterface) + 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) } - m.bus.Publish(app.TopicInterfaceUpdated, iface) m.bus.Publish(app.TopicAuditInterfaceChanged, domain.AuditEventWrapper[audit.InterfaceEvent]{ Ctx: ctx, Event: audit.InterfaceEvent{ diff --git a/internal/app/wireguard/wireguard_peers.go b/internal/app/wireguard/wireguard_peers.go index 1fd5810..e52f372 100644 --- a/internal/app/wireguard/wireguard_peers.go +++ b/internal/app/wireguard/wireguard_peers.go @@ -204,6 +204,8 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee return nil, fmt.Errorf("creation failure: %w", err) } + m.bus.Publish(app.TopicPeerCreated, *peer) + return peer, nil } @@ -246,6 +248,8 @@ func (m Manager) CreateMultiplePeers( createdPeers := make([]domain.Peer, len(newPeers)) for i := range newPeers { createdPeers[i] = *newPeers[i] + + m.bus.Publish(app.TopicPeerCreated, *newPeers[i]) } 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 } @@ -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) } + m.bus.Publish(app.TopicPeerDeleted, *peer) // Update routes after peers have changed m.bus.Publish(app.TopicRouteUpdate, "peers updated") // Update interface after peers have changed @@ -428,6 +435,7 @@ func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error { } // publish event + m.bus.Publish(app.TopicAuditPeerChanged, domain.AuditEventWrapper[audit.PeerEvent]{ Ctx: ctx, Event: audit.PeerEvent{ diff --git a/internal/config/config.go b/internal/config/config.go index 0bda0c8..f4694c1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,6 +62,8 @@ type Config struct { Database DatabaseConfig `yaml:"database"` Web WebConfig `yaml:"web"` + + Webhook WebhookConfig `yaml:"webhook"` } // LogStartupValues logs the startup values of the configuration in debug level @@ -158,6 +160,10 @@ func defaultConfig() *Config { LinkOnly: false, } + cfg.Webhook.Url = "" // no webhook by default + cfg.Webhook.Authentication = "" + cfg.Webhook.Timeout = 10 * time.Second + return cfg } diff --git a/internal/config/webhook.go b/internal/config/webhook.go new file mode 100644 index 0000000..f15de70 --- /dev/null +++ b/internal/config/webhook.go @@ -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"` +}