mirror of https://github.com/h44z/wg-portal.git
				
				
				
			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) | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | } | ||||||
|  | @ -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 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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…
	
		Reference in New Issue