From f11fc0b0e142521dac9e54cd8e98604239e87327 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 11 Apr 2026 08:40:41 +0700 Subject: [PATCH 01/10] notification: add notification models and error sentinels --- model/errors.go | 33 ++++--- model/notification.go | 209 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 model/notification.go diff --git a/model/errors.go b/model/errors.go index 693a8e3f..35d0acc8 100644 --- a/model/errors.go +++ b/model/errors.go @@ -27,21 +27,24 @@ import ( ) var ( - ErrAuthUserNotFound = errors.New("auth user not found") - ErrCheckPlanNotFound = errors.New("check plan not found") - ErrCheckEvaluationNotFound = errors.New("check evaluation not found") - ErrCheckerNotFound = errors.New("checker not found") - ErrDomainDoesNotExist = errors.New("domain name doesn't exist") - ErrDomainNotFound = errors.New("domain not found") - ErrDomainLogNotFound = errors.New("domain log not found") - ErrExecutionNotFound = errors.New("execution not found") - ErrProviderNotFound = errors.New("provider not found") - ErrSessionNotFound = errors.New("session not found") - ErrSnapshotNotFound = errors.New("snapshot not found") - ErrUserNotFound = errors.New("user not found") - ErrUserAlreadyExist = errors.New("user already exists") - ErrZoneNotFound = errors.New("zone not found") - ErrNotFound = errors.New("not found") + ErrAuthUserNotFound = errors.New("auth user not found") + ErrCheckPlanNotFound = errors.New("check plan not found") + ErrCheckEvaluationNotFound = errors.New("check evaluation not found") + ErrCheckerNotFound = errors.New("checker not found") + ErrDomainDoesNotExist = errors.New("domain name doesn't exist") + ErrDomainNotFound = errors.New("domain not found") + ErrDomainLogNotFound = errors.New("domain log not found") + ErrExecutionNotFound = errors.New("execution not found") + ErrNotificationChannelNotFound = errors.New("notification channel not found") + ErrNotificationPreferenceNotFound = errors.New("notification preference not found") + ErrNotificationStateNotFound = errors.New("notification state not found") + ErrProviderNotFound = errors.New("provider not found") + ErrSessionNotFound = errors.New("session not found") + ErrSnapshotNotFound = errors.New("snapshot not found") + ErrUserNotFound = errors.New("user not found") + ErrUserAlreadyExist = errors.New("user already exists") + ErrZoneNotFound = errors.New("zone not found") + ErrNotFound = errors.New("not found") ) const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later." diff --git a/model/notification.go b/model/notification.go new file mode 100644 index 00000000..ea0cd9e4 --- /dev/null +++ b/model/notification.go @@ -0,0 +1,209 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package happydns + +import ( + "encoding/json" + "time" +) + +// NotificationChannelType identifies the transport used to deliver a notification. +// Values are owned by sender implementations; the model does not enumerate them. +type NotificationChannelType string + +// NotificationChannel represents a single configured notification destination. +// A user may have any number of channels, including multiple of the same Type +// (e.g. two emails to different addresses, or two webhooks to different URLs). +// Config is opaque to the model: it is decoded by the sender registered for Type. +type NotificationChannel struct { + // Id is the channel's unique identifier. + Id Identifier `json:"id" swaggertype:"string" readonly:"true"` + + // UserId is the owner of the channel. + UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"` + + // Type is the transport type. Must match a registered sender. + Type NotificationChannelType `json:"type" binding:"required"` + + // Name is a human-readable label for the channel. + Name string `json:"name"` + + // Enabled controls whether notifications are sent through this channel. + Enabled bool `json:"enabled"` + + // Config is the channel-type-specific configuration as raw JSON. + // Its shape is defined by the sender for Type. + Config json.RawMessage `json:"config" swaggertype:"object"` +} + +// NotificationPreference controls what notifications a user receives for a given scope. +// Scope resolution: ServiceId set > DomainId set > global (both nil). +type NotificationPreference struct { + // Id is the preference's unique identifier. + Id Identifier `json:"id" swaggertype:"string" readonly:"true"` + + // UserId is the owner of the preference. + UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"` + + // DomainId, if set, scopes this preference to a specific domain. + DomainId *Identifier `json:"domainId,omitempty" swaggertype:"string"` + + // ServiceId, if set, scopes this preference to a specific service. + ServiceId *Identifier `json:"serviceId,omitempty" swaggertype:"string"` + + // ChannelIds restricts which channels to use. Empty means all enabled channels. + ChannelIds []Identifier `json:"channelIds,omitempty" swaggertype:"array,string"` + + // MinStatus is the minimum severity that triggers a notification. + MinStatus Status `json:"minStatus"` + + // NotifyRecovery controls whether recovery (back to OK) notifications are sent. + NotifyRecovery bool `json:"notifyRecovery"` + + // QuietStart is the start hour (0-23, UTC) of a quiet window. + QuietStart *int `json:"quietStart,omitempty"` + + // QuietEnd is the end hour (0-23, UTC) of a quiet window. + QuietEnd *int `json:"quietEnd,omitempty"` + + // Enabled is the master switch for this preference scope. + Enabled bool `json:"enabled"` +} + +// NotificationState tracks the last notified status for a (checker, target, user) tuple. +// Used for deduplication: only state transitions trigger notifications. +type NotificationState struct { + // CheckerID identifies the checker. + CheckerID string `json:"checkerId"` + + // Target is the checked scope. + Target CheckTarget `json:"target"` + + // UserId is the user who owns the target. + UserId Identifier `json:"userId" swaggertype:"string"` + + // LastStatus is the status from the last notification. + LastStatus Status `json:"lastStatus"` + + // LastNotifiedAt is when the last notification was sent. + LastNotifiedAt time.Time `json:"lastNotifiedAt" format:"date-time"` + + // Acknowledged indicates the user has acknowledged the current issue. + Acknowledged bool `json:"acknowledged"` + + // AcknowledgedAt is when the issue was acknowledged. + AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty" format:"date-time"` + + // AcknowledgedBy describes who acknowledged (user email or "api"). + AcknowledgedBy string `json:"acknowledgedBy,omitempty"` + + // Annotation is a user-provided note on the acknowledgement. + Annotation string `json:"annotation,omitempty"` +} + +// NotificationRecord logs a sent notification for audit purposes. +type NotificationRecord struct { + // Id is the record's unique identifier. + Id Identifier `json:"id" swaggertype:"string" readonly:"true"` + + // UserId is the recipient user. + UserId Identifier `json:"userId" swaggertype:"string"` + + // ChannelType is the transport used. + ChannelType NotificationChannelType `json:"channelType"` + + // ChannelId is the channel through which the notification was sent. + ChannelId Identifier `json:"channelId" swaggertype:"string"` + + // CheckerID is the checker that triggered the notification. + CheckerID string `json:"checkerId"` + + // Target is the checked scope. + Target CheckTarget `json:"target"` + + // OldStatus is the previous status before the transition. + OldStatus Status `json:"oldStatus"` + + // NewStatus is the new status that triggered the notification. + NewStatus Status `json:"newStatus"` + + // SentAt is when the notification was dispatched. + SentAt time.Time `json:"sentAt" format:"date-time"` + + // Success indicates whether the send succeeded. + Success bool `json:"success"` + + // Error holds the error message if the send failed. + Error string `json:"error,omitempty"` +} + +// AcknowledgeRequest is the JSON body for acknowledging a checker issue. +type AcknowledgeRequest struct { + Annotation string `json:"annotation,omitempty"` +} + +// ClearAcknowledgement resets the acknowledgement fields in place. Used both +// when a user explicitly clears an ack and when a transition (recovery or +// escalation) invalidates the existing one. +func (s *NotificationState) ClearAcknowledgement() { + s.Acknowledged = false + s.AcknowledgedAt = nil + s.AcknowledgedBy = "" + s.Annotation = "" +} + +// DefaultNotificationPreference returns the implicit preference applied when +// a user has no matching preference configured for a target. It opts the user +// in to notifications at Warn and above on all of their enabled channels, so +// a freshly-onboarded user receives alerts without having to author a rule. +// +// The returned value has a zero Id and UserId; it is not persisted. Callers +// that need user-attribution should set UserId before use. +func DefaultNotificationPreference() *NotificationPreference { + return &NotificationPreference{ + MinStatus: StatusWarn, + NotifyRecovery: false, + Enabled: true, + } +} + +// MatchesTarget reports the specificity of this preference for the given +// target: 2 for an exact service match, 1 for a domain-scoped match, 0 for a +// global preference, or -1 if this preference does not apply to the target. +// +// CheckTarget carries Domain/Service ids as plain strings while preferences +// hold *Identifier; this method is the single place that bridges the two. +func (p *NotificationPreference) MatchesTarget(target CheckTarget) int { + if p.ServiceId != nil { + if p.ServiceId.String() == target.ServiceId { + return 2 + } + return -1 + } + if p.DomainId != nil { + if p.DomainId.String() == target.DomainId { + return 1 + } + return -1 + } + return 0 +} From 4e10fa10772f58023598805200694c7aba8f1a1a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 11 Apr 2026 08:42:31 +0700 Subject: [PATCH 02/10] notification: add storage interfaces and KV implementations --- .../app/instrumented_storage_generated.go | 85 ++++++++++ internal/storage/interface.go | 5 + .../storage/kvtpl/notification_channel.go | 148 ++++++++++++++++++ .../storage/kvtpl/notification_preference.go | 134 ++++++++++++++++ internal/storage/kvtpl/notification_record.go | 97 ++++++++++++ internal/storage/kvtpl/notification_state.go | 82 ++++++++++ internal/storage/kvtpl/updates-from-10.go | 28 ++++ internal/storage/kvtpl/updates.go | 1 + internal/usecase/notification/storage.go | 71 +++++++++ tools/gen_instrumented_storage.go | 4 + 10 files changed, 655 insertions(+) create mode 100644 internal/storage/kvtpl/notification_channel.go create mode 100644 internal/storage/kvtpl/notification_preference.go create mode 100644 internal/storage/kvtpl/notification_record.go create mode 100644 internal/storage/kvtpl/notification_state.go create mode 100644 internal/storage/kvtpl/updates-from-10.go create mode 100644 internal/usecase/notification/storage.go diff --git a/internal/app/instrumented_storage_generated.go b/internal/app/instrumented_storage_generated.go index a4b0e8cd..8a093ba1 100644 --- a/internal/app/instrumented_storage_generated.go +++ b/internal/app/instrumented_storage_generated.go @@ -137,6 +137,11 @@ func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error return s.inner.CreateAuthUser(user) } +func (s *instrumentedStorage) CreateChannel(ch *happydns.NotificationChannel) (err error) { + defer observe("create", "notification_channel")(&err) + return s.inner.CreateChannel(ch) +} + func (s *instrumentedStorage) CreateCheckPlan(plan *happydns.CheckPlan) (err error) { defer observe("create", "check_plan")(&err) return s.inner.CreateCheckPlan(plan) @@ -167,11 +172,21 @@ func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error return s.inner.CreateOrUpdateUser(user) } +func (s *instrumentedStorage) CreatePreference(pref *happydns.NotificationPreference) (err error) { + defer observe("create", "notification_preference")(&err) + return s.inner.CreatePreference(pref) +} + func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) { defer observe("create", "provider")(&err) return s.inner.CreateProvider(prvd) } +func (s *instrumentedStorage) CreateRecord(rec *happydns.NotificationRecord) (err error) { + defer observe("create", "notification_record")(&err) + return s.inner.CreateRecord(rec) +} + func (s *instrumentedStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) (err error) { defer observe("create", "observation_snapshot")(&err) return s.inner.CreateSnapshot(snap) @@ -187,6 +202,11 @@ func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error return s.inner.DeleteAuthUser(user) } +func (s *instrumentedStorage) DeleteChannel(channelId happydns.Identifier) (err error) { + defer observe("delete", "notification_channel")(&err) + return s.inner.DeleteChannel(channelId) +} + func (s *instrumentedStorage) DeleteCheckPlan(planID happydns.Identifier) (err error) { defer observe("delete", "check_plan")(&err) return s.inner.DeleteCheckPlan(planID) @@ -237,11 +257,21 @@ func (s *instrumentedStorage) DeleteExecutionsByChecker(checkerID string, target return s.inner.DeleteExecutionsByChecker(checkerID, target) } +func (s *instrumentedStorage) DeletePreference(prefId happydns.Identifier) (err error) { + defer observe("delete", "notification_preference")(&err) + return s.inner.DeletePreference(prefId) +} + func (s *instrumentedStorage) DeleteProvider(prvdid happydns.Identifier) (err error) { defer observe("delete", "provider")(&err) return s.inner.DeleteProvider(prvdid) } +func (s *instrumentedStorage) DeleteRecordsOlderThan(before time.Time) (err error) { + defer observe("delete", "notification_record")(&err) + return s.inner.DeleteRecordsOlderThan(before) +} + func (s *instrumentedStorage) DeleteSession(sessionid string) (err error) { defer observe("delete", "session")(&err) return s.inner.DeleteSession(sessionid) @@ -252,6 +282,11 @@ func (s *instrumentedStorage) DeleteSnapshot(snapID happydns.Identifier) (err er return s.inner.DeleteSnapshot(snapID) } +func (s *instrumentedStorage) DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (err error) { + defer observe("delete", "notification_state")(&err) + return s.inner.DeleteState(checkerID, target, userId) +} + func (s *instrumentedStorage) DeleteUser(userid happydns.Identifier) (err error) { defer observe("delete", "user")(&err) return s.inner.DeleteUser(userid) @@ -282,6 +317,11 @@ func (s *instrumentedStorage) GetCachedObservation(target happydns.CheckTarget, return s.inner.GetCachedObservation(target, key) } +func (s *instrumentedStorage) GetChannel(channelId happydns.Identifier) (ret *happydns.NotificationChannel, err error) { + defer observe("get", "notification_channel")(&err) + return s.inner.GetChannel(channelId) +} + func (s *instrumentedStorage) GetCheckPlan(planID happydns.Identifier) (ret *happydns.CheckPlan, err error) { defer observe("get", "check_plan")(&err) return s.inner.GetCheckPlan(planID) @@ -322,6 +362,11 @@ func (s *instrumentedStorage) GetLatestEvaluation(planID happydns.Identifier) (r return s.inner.GetLatestEvaluation(planID) } +func (s *instrumentedStorage) GetPreference(prefId happydns.Identifier) (ret *happydns.NotificationPreference, err error) { + defer observe("get", "notification_preference")(&err) + return s.inner.GetPreference(prefId) +} + func (s *instrumentedStorage) GetProvider(prvdid happydns.Identifier) (ret *happydns.ProviderMessage, err error) { defer observe("get", "provider")(&err) return s.inner.GetProvider(prvdid) @@ -337,6 +382,11 @@ func (s *instrumentedStorage) GetSnapshot(snapID happydns.Identifier) (ret *happ return s.inner.GetSnapshot(snapID) } +func (s *instrumentedStorage) GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (ret *happydns.NotificationState, err error) { + defer observe("get", "notification_state")(&err) + return s.inner.GetState(checkerID, target, userId) +} + func (s *instrumentedStorage) GetUser(userid happydns.Identifier) (ret *happydns.User, err error) { defer observe("get", "user")(&err) return s.inner.GetUser(userid) @@ -447,6 +497,11 @@ func (s *instrumentedStorage) ListAuthUserSessions(user *happydns.UserAuth) (ret return s.inner.ListAuthUserSessions(user) } +func (s *instrumentedStorage) ListChannelsByUser(userId happydns.Identifier) (ret []*happydns.NotificationChannel, err error) { + defer observe("list", "notification_channel")(&err) + return s.inner.ListChannelsByUser(userId) +} + func (s *instrumentedStorage) ListCheckPlansByChecker(checkerID string) (ret []*happydns.CheckPlan, err error) { defer observe("list", "check_plan")(&err) return s.inner.ListCheckPlansByChecker(checkerID) @@ -522,11 +577,26 @@ func (s *instrumentedStorage) ListExecutionsByUser(userId happydns.Identifier, l return s.inner.ListExecutionsByUser(userId, limit, filter) } +func (s *instrumentedStorage) ListPreferencesByUser(userId happydns.Identifier) (ret []*happydns.NotificationPreference, err error) { + defer observe("list", "notification_preference")(&err) + return s.inner.ListPreferencesByUser(userId) +} + func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.ProviderMessages, err error) { defer observe("list", "provider")(&err) return s.inner.ListProviders(user) } +func (s *instrumentedStorage) ListRecordsByUser(userId happydns.Identifier, limit int) (ret []*happydns.NotificationRecord, err error) { + defer observe("list", "notification_record")(&err) + return s.inner.ListRecordsByUser(userId, limit) +} + +func (s *instrumentedStorage) ListStatesByUser(userId happydns.Identifier) (ret []*happydns.NotificationState, err error) { + defer observe("list", "notification_state")(&err) + return s.inner.ListStatesByUser(userId) +} + func (s *instrumentedStorage) ListUserSessions(userid happydns.Identifier) (ret []*happydns.Session, err error) { defer observe("list", "session")(&err) return s.inner.ListUserSessions(userid) @@ -544,6 +614,11 @@ func (s *instrumentedStorage) PutDiscoveryObservationRef(ref *happydns.Discovery return s.inner.PutDiscoveryObservationRef(ref) } +func (s *instrumentedStorage) PutState(state *happydns.NotificationState) (err error) { + defer observe("put", "notification_state")(&err) + return s.inner.PutState(state) +} + func (s *instrumentedStorage) ReplaceDiscoveryEntries(producerID string, target happydns.CheckTarget, entries []happydns.DiscoveryEntry) (err error) { defer observe("update", "discovery_entry")(&err) return s.inner.ReplaceDiscoveryEntries(producerID, target, entries) @@ -601,6 +676,11 @@ func (s *instrumentedStorage) UpdateAuthUser(user *happydns.UserAuth) (err error return s.inner.UpdateAuthUser(user) } +func (s *instrumentedStorage) UpdateChannel(ch *happydns.NotificationChannel) (err error) { + defer observe("update", "notification_channel")(&err) + return s.inner.UpdateChannel(ch) +} + func (s *instrumentedStorage) UpdateCheckPlan(plan *happydns.CheckPlan) (err error) { defer observe("update", "check_plan")(&err) return s.inner.UpdateCheckPlan(plan) @@ -626,6 +706,11 @@ func (s *instrumentedStorage) UpdateExecution(exec *happydns.Execution) (err err return s.inner.UpdateExecution(exec) } +func (s *instrumentedStorage) UpdatePreference(pref *happydns.NotificationPreference) (err error) { + defer observe("update", "notification_preference")(&err) + return s.inner.UpdatePreference(pref) +} + func (s *instrumentedStorage) UpdateProvider(prvd *happydns.Provider) (err error) { defer observe("update", "provider")(&err) return s.inner.UpdateProvider(prvd) diff --git a/internal/storage/interface.go b/internal/storage/interface.go index 8ccd1f8a..ea0479bd 100644 --- a/internal/storage/interface.go +++ b/internal/storage/interface.go @@ -27,6 +27,7 @@ import ( "git.happydns.org/happyDomain/internal/usecase/domain" "git.happydns.org/happyDomain/internal/usecase/domain_log" "git.happydns.org/happyDomain/internal/usecase/insight" + "git.happydns.org/happyDomain/internal/usecase/notification" "git.happydns.org/happyDomain/internal/usecase/provider" "git.happydns.org/happyDomain/internal/usecase/session" "git.happydns.org/happyDomain/internal/usecase/user" @@ -53,6 +54,10 @@ type Storage interface { domain.DomainStorage domainlog.DomainLogStorage insight.InsightStorage + notification.NotificationChannelStorage + notification.NotificationPreferenceStorage + notification.NotificationStateStorage + notification.NotificationRecordStorage provider.ProviderStorage session.SessionStorage user.UserStorage diff --git a/internal/storage/kvtpl/notification_channel.go b/internal/storage/kvtpl/notification_channel.go new file mode 100644 index 00000000..abc3d415 --- /dev/null +++ b/internal/storage/kvtpl/notification_channel.go @@ -0,0 +1,148 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package database + +import ( + "errors" + "fmt" + "log" + "strings" + + "git.happydns.org/happyDomain/model" +) + +// Channel storage layout: +// notifch| -> full NotificationChannel (source of truth) +// notifch-user|| -> "" (presence-only secondary index) +// +// Storing only the channel id in the index avoids the previous double-write +// (two copies of the same blob to keep in sync) while still letting +// ListChannelsByUser scan a per-user prefix without loading every channel. + +const ( + notifchPrimaryPrefix = "notifch|" + notifchUserPrefix = "notifch-user|" +) + +func notifchPrimaryKey(id happydns.Identifier) string { + return notifchPrimaryPrefix + id.String() +} + +func notifchUserKey(userId, channelId happydns.Identifier) string { + return fmt.Sprintf("%s%s|%s", notifchUserPrefix, userId.String(), channelId.String()) +} + +// channelIdFromUserIndexKey extracts the channel id suffix from a +// "notifch-user||" key. +func channelIdFromUserIndexKey(key string) (string, bool) { + rest, ok := strings.CutPrefix(key, notifchUserPrefix) + if !ok { + return "", false + } + _, channelId, ok := strings.Cut(rest, "|") + if !ok || channelId == "" { + return "", false + } + return channelId, true +} + +func (s *KVStorage) ListChannelsByUser(userId happydns.Identifier) ([]*happydns.NotificationChannel, error) { + prefix := notifchUserPrefix + userId.String() + "|" + iter := s.db.Search(prefix) + defer iter.Release() + + var channels []*happydns.NotificationChannel + for iter.Next() { + idStr, ok := channelIdFromUserIndexKey(iter.Key()) + if !ok { + continue + } + id, err := happydns.NewIdentifierFromString(idStr) + if err != nil { + log.Printf("storage: malformed channel index key %q: %v", iter.Key(), err) + continue + } + ch, err := s.GetChannel(id) + if err != nil { + // Index drift: the primary record is gone but the index entry + // remains. Log and skip rather than fail the whole list. + log.Printf("storage: channel index points to missing channel %s: %v", idStr, err) + continue + } + channels = append(channels, ch) + } + return channels, nil +} + +func (s *KVStorage) GetChannel(channelId happydns.Identifier) (*happydns.NotificationChannel, error) { + ch := &happydns.NotificationChannel{} + err := s.db.Get(notifchPrimaryKey(channelId), ch) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrNotificationChannelNotFound + } + return ch, err +} + +func (s *KVStorage) CreateChannel(ch *happydns.NotificationChannel) error { + key, id, err := s.db.FindIdentifierKey(notifchPrimaryPrefix) + if err != nil { + return err + } + ch.Id = id + + if err := s.db.Put(key, ch); err != nil { + return err + } + if err := s.db.Put(notifchUserKey(ch.UserId, ch.Id), ""); err != nil { + // Best-effort rollback so a failed index write does not leave a primary + // record orphaned and invisible from per-user listing. + if delErr := s.db.Delete(key); delErr != nil { + log.Printf("storage: orphan channel %s after index write failed (rollback also failed: %v)", ch.Id.String(), delErr) + } + return err + } + return nil +} + +func (s *KVStorage) UpdateChannel(ch *happydns.NotificationChannel) error { + // The user index is keyed by (userId, channelId) and carries no payload, + // so an Update only needs to write the primary record. + return s.db.Put(notifchPrimaryKey(ch.Id), ch) +} + +func (s *KVStorage) DeleteChannel(channelId happydns.Identifier) error { + ch, err := s.GetChannel(channelId) + if err != nil { + return err + } + + // Delete the index first so a partial failure leaves the channel hidden + // from listings rather than visible-but-broken. + if err := s.db.Delete(notifchUserKey(ch.UserId, channelId)); err != nil { + return err + } + if err := s.db.Delete(notifchPrimaryKey(channelId)); err != nil { + log.Printf("storage: channel %s index removed but primary delete failed: %v", channelId.String(), err) + return err + } + return nil +} diff --git a/internal/storage/kvtpl/notification_preference.go b/internal/storage/kvtpl/notification_preference.go new file mode 100644 index 00000000..2e067e1a --- /dev/null +++ b/internal/storage/kvtpl/notification_preference.go @@ -0,0 +1,134 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package database + +import ( + "errors" + "fmt" + "log" + "strings" + + "git.happydns.org/happyDomain/model" +) + +// Preference storage layout mirrors notification_channel.go: the primary +// "notifpref|" key holds the record; "notifpref-user||" is a +// presence-only secondary index for cheap per-user listing. + +const ( + notifprefPrimaryPrefix = "notifpref|" + notifprefUserPrefix = "notifpref-user|" +) + +func notifprefPrimaryKey(id happydns.Identifier) string { + return notifprefPrimaryPrefix + id.String() +} + +func notifprefUserKey(userId, prefId happydns.Identifier) string { + return fmt.Sprintf("%s%s|%s", notifprefUserPrefix, userId.String(), prefId.String()) +} + +func prefIdFromUserIndexKey(key string) (string, bool) { + rest, ok := strings.CutPrefix(key, notifprefUserPrefix) + if !ok { + return "", false + } + _, prefId, ok := strings.Cut(rest, "|") + if !ok || prefId == "" { + return "", false + } + return prefId, true +} + +func (s *KVStorage) ListPreferencesByUser(userId happydns.Identifier) ([]*happydns.NotificationPreference, error) { + prefix := notifprefUserPrefix + userId.String() + "|" + iter := s.db.Search(prefix) + defer iter.Release() + + var prefs []*happydns.NotificationPreference + for iter.Next() { + idStr, ok := prefIdFromUserIndexKey(iter.Key()) + if !ok { + continue + } + id, err := happydns.NewIdentifierFromString(idStr) + if err != nil { + log.Printf("storage: malformed preference index key %q: %v", iter.Key(), err) + continue + } + pref, err := s.GetPreference(id) + if err != nil { + log.Printf("storage: preference index points to missing preference %s: %v", idStr, err) + continue + } + prefs = append(prefs, pref) + } + return prefs, nil +} + +func (s *KVStorage) GetPreference(prefId happydns.Identifier) (*happydns.NotificationPreference, error) { + pref := &happydns.NotificationPreference{} + err := s.db.Get(notifprefPrimaryKey(prefId), pref) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrNotificationPreferenceNotFound + } + return pref, err +} + +func (s *KVStorage) CreatePreference(pref *happydns.NotificationPreference) error { + key, id, err := s.db.FindIdentifierKey(notifprefPrimaryPrefix) + if err != nil { + return err + } + pref.Id = id + + if err := s.db.Put(key, pref); err != nil { + return err + } + if err := s.db.Put(notifprefUserKey(pref.UserId, pref.Id), ""); err != nil { + if delErr := s.db.Delete(key); delErr != nil { + log.Printf("storage: orphan preference %s after index write failed (rollback also failed: %v)", pref.Id.String(), delErr) + } + return err + } + return nil +} + +func (s *KVStorage) UpdatePreference(pref *happydns.NotificationPreference) error { + return s.db.Put(notifprefPrimaryKey(pref.Id), pref) +} + +func (s *KVStorage) DeletePreference(prefId happydns.Identifier) error { + pref, err := s.GetPreference(prefId) + if err != nil { + return err + } + + if err := s.db.Delete(notifprefUserKey(pref.UserId, prefId)); err != nil { + return err + } + if err := s.db.Delete(notifprefPrimaryKey(prefId)); err != nil { + log.Printf("storage: preference %s index removed but primary delete failed: %v", prefId.String(), err) + return err + } + return nil +} diff --git a/internal/storage/kvtpl/notification_record.go b/internal/storage/kvtpl/notification_record.go new file mode 100644 index 00000000..1744aba9 --- /dev/null +++ b/internal/storage/kvtpl/notification_record.go @@ -0,0 +1,97 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package database + +import ( + "errors" + "fmt" + "log" + "sort" + "time" + + "git.happydns.org/happyDomain/model" +) + +func (s *KVStorage) CreateRecord(rec *happydns.NotificationRecord) error { + key, id, err := s.db.FindIdentifierKey("notifrec|") + if err != nil { + return err + } + rec.Id = id + + if err := s.db.Put(key, rec); err != nil { + return err + } + + indexKey := fmt.Sprintf("notifrec-user|%s|%s", rec.UserId.String(), rec.Id.String()) + return s.db.Put(indexKey, rec) +} + +func (s *KVStorage) ListRecordsByUser(userId happydns.Identifier, limit int) ([]*happydns.NotificationRecord, error) { + prefix := fmt.Sprintf("notifrec-user|%s|", userId.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var records []*happydns.NotificationRecord + for iter.Next() { + var rec happydns.NotificationRecord + if err := s.db.DecodeData(iter.Value(), &rec); err != nil { + // Corrupt entry: log and skip rather than fail the whole listing. + log.Printf("storage: malformed notification record at %q: %v", iter.Key(), err) + continue + } + records = append(records, &rec) + } + + sort.Slice(records, func(i, j int) bool { + return records[i].SentAt.After(records[j].SentAt) + }) + + if limit > 0 && len(records) > limit { + records = records[:limit] + } + return records, nil +} + +func (s *KVStorage) DeleteRecordsOlderThan(before time.Time) error { + iter := s.db.Search("notifrec|") + defer iter.Release() + + var errs []error + for iter.Next() { + var rec happydns.NotificationRecord + if err := s.db.DecodeData(iter.Value(), &rec); err != nil { + log.Printf("storage: malformed notification record at %q: %v", iter.Key(), err) + continue + } + if rec.SentAt.Before(before) { + if err := s.db.Delete(iter.Key()); err != nil { + errs = append(errs, fmt.Errorf("delete %s: %w", iter.Key(), err)) + } + userIndexKey := fmt.Sprintf("notifrec-user|%s|%s", rec.UserId.String(), rec.Id.String()) + if err := s.db.Delete(userIndexKey); err != nil { + errs = append(errs, fmt.Errorf("delete %s: %w", userIndexKey, err)) + } + } + } + return errors.Join(errs...) +} diff --git a/internal/storage/kvtpl/notification_state.go b/internal/storage/kvtpl/notification_state.go new file mode 100644 index 00000000..4d5d2198 --- /dev/null +++ b/internal/storage/kvtpl/notification_state.go @@ -0,0 +1,82 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package database + +import ( + "errors" + "fmt" + "log" + + "git.happydns.org/happyDomain/model" +) + +// notifStateKey builds the storage key for a notification state record. +// +// The key is constructed from explicit fields rather than CheckTarget.String() +// so that adding new fields to CheckTarget (in the external SDK) cannot +// silently change the key shape and orphan previously-stored records, and so +// that the dispatcher and the acknowledgement handler always agree on the +// exact set of fields that participate in identity. +func notifStateKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string { + return fmt.Sprintf( + "notifstate|%s|%s|%s/%s/%s", + userId.String(), + checkerID, + target.UserId, + target.DomainId, + target.ServiceId, + ) +} + +func (s *KVStorage) GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (*happydns.NotificationState, error) { + state := &happydns.NotificationState{} + err := s.db.Get(notifStateKey(checkerID, target, userId), state) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrNotificationStateNotFound + } + return state, err +} + +func (s *KVStorage) PutState(state *happydns.NotificationState) error { + return s.db.Put(notifStateKey(state.CheckerID, state.Target, state.UserId), state) +} + +func (s *KVStorage) DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) error { + return s.db.Delete(notifStateKey(checkerID, target, userId)) +} + +func (s *KVStorage) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) { + prefix := fmt.Sprintf("notifstate|%s|", userId.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var states []*happydns.NotificationState + for iter.Next() { + var state happydns.NotificationState + if err := s.db.DecodeData(iter.Value(), &state); err != nil { + log.Printf("storage: malformed notification state at %q: %v", iter.Key(), err) + continue + } + states = append(states, &state) + } + return states, nil +} diff --git a/internal/storage/kvtpl/updates-from-10.go b/internal/storage/kvtpl/updates-from-10.go new file mode 100644 index 00000000..81e97f4d --- /dev/null +++ b/internal/storage/kvtpl/updates-from-10.go @@ -0,0 +1,28 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package database + +// migrateFrom10 adds the notification system tables. +// KV storage uses prefix-based keys, so no structural migration is needed. +func migrateFrom10(s *KVStorage) error { + return nil +} diff --git a/internal/storage/kvtpl/updates.go b/internal/storage/kvtpl/updates.go index add2bad4..677a445a 100644 --- a/internal/storage/kvtpl/updates.go +++ b/internal/storage/kvtpl/updates.go @@ -39,6 +39,7 @@ var migrations []KVMigrationFunc = []KVMigrationFunc{ migrateFrom7, migrateFrom8, migrateFrom9, + migrateFrom10, } type Version struct { diff --git a/internal/usecase/notification/storage.go b/internal/usecase/notification/storage.go new file mode 100644 index 00000000..8859bb02 --- /dev/null +++ b/internal/usecase/notification/storage.go @@ -0,0 +1,71 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "time" + + "git.happydns.org/happyDomain/model" +) + +// NotificationChannelStorage provides persistence for notification channels. +type NotificationChannelStorage interface { + ListChannelsByUser(userId happydns.Identifier) ([]*happydns.NotificationChannel, error) + GetChannel(channelId happydns.Identifier) (*happydns.NotificationChannel, error) + CreateChannel(ch *happydns.NotificationChannel) error + UpdateChannel(ch *happydns.NotificationChannel) error + DeleteChannel(channelId happydns.Identifier) error +} + +// NotificationPreferenceStorage provides persistence for notification preferences. +type NotificationPreferenceStorage interface { + ListPreferencesByUser(userId happydns.Identifier) ([]*happydns.NotificationPreference, error) + GetPreference(prefId happydns.Identifier) (*happydns.NotificationPreference, error) + CreatePreference(pref *happydns.NotificationPreference) error + UpdatePreference(pref *happydns.NotificationPreference) error + DeletePreference(prefId happydns.Identifier) error +} + +// NotificationStateStorage provides persistence for notification state tracking. +type NotificationStateStorage interface { + GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (*happydns.NotificationState, error) + PutState(state *happydns.NotificationState) error + DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) error + ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) +} + +// NotificationRecordStorage provides persistence for notification audit records. +type NotificationRecordStorage interface { + CreateRecord(rec *happydns.NotificationRecord) error + ListRecordsByUser(userId happydns.Identifier, limit int) ([]*happydns.NotificationRecord, error) + DeleteRecordsOlderThan(before time.Time) error +} + +// UserGetter is a narrow interface for loading users in the notification context. +type UserGetter interface { + GetUser(id happydns.Identifier) (*happydns.User, error) +} + +// DomainGetter is a narrow interface for loading domains in the notification context. +type DomainGetter interface { + GetDomain(id happydns.Identifier) (*happydns.Domain, error) +} diff --git a/tools/gen_instrumented_storage.go b/tools/gen_instrumented_storage.go index 24dd8e1b..49e67cd2 100644 --- a/tools/gen_instrumented_storage.go +++ b/tools/gen_instrumented_storage.go @@ -53,6 +53,10 @@ var entityMap = map[string]string{ "DomainStorage": "domain", "DomainLogStorage": "domain_log", "InsightStorage": "insight", + "NotificationChannelStorage": "notification_channel", + "NotificationPreferenceStorage": "notification_preference", + "NotificationStateStorage": "notification_state", + "NotificationRecordStorage": "notification_record", "ProviderStorage": "provider", "SessionStorage": "session", "UserStorage": "user", From d9afd353fd52e015f2115fae21e912db31374ef1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 29 Apr 2026 20:56:56 +0700 Subject: [PATCH 03/10] notification: add ChannelSender interface and email sender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define the ChannelSender interface and NotificationPayload/Recipient types passed to senders. Senders receive only what they need to render or transmit a notification — no user object, no server config — so adding a new transport cannot accidentally leak privileged data. EmailSender reuses the existing Mailer to send Markdown-formatted alerts. --- internal/notification/email_sender.go | 126 +++++++++++ internal/notification/sender.go | 291 ++++++++++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 internal/notification/email_sender.go create mode 100644 internal/notification/sender.go diff --git a/internal/notification/email_sender.go b/internal/notification/email_sender.go new file mode 100644 index 00000000..c5374e64 --- /dev/null +++ b/internal/notification/email_sender.go @@ -0,0 +1,126 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "context" + "errors" + "fmt" + "net/mail" + "strings" + + "git.happydns.org/happyDomain/model" +) + +const ChannelTypeEmail happydns.NotificationChannelType = "email" + +// EmailConfig is the per-channel configuration for the email sender. +type EmailConfig struct { + // Address overrides the user's account email. Empty means use account email. + Address string `json:"address,omitempty"` +} + +func (c EmailConfig) Validate() error { + if c.Address == "" { + return nil + } + if _, err := mail.ParseAddress(c.Address); err != nil { + return fmt.Errorf("invalid email address: %w", err) + } + return nil +} + +// EmailSender sends notifications via email using the existing Mailer. +// The base URL is captured at construction so it does not need to be threaded +// through every payload — it is server identity, not per-notification data. +type EmailSender struct { + mailer happydns.Mailer + baseURL string +} + +// NewEmailSender creates a new EmailSender. baseURL, if non-empty, is rendered +// as a "View in happyDomain" link in the email body. +func NewEmailSender(mailer happydns.Mailer, baseURL string) *EmailSender { + return &EmailSender{mailer: mailer, baseURL: baseURL} +} + +func (s *EmailSender) Type() happydns.NotificationChannelType { return ChannelTypeEmail } + +func (s *EmailSender) Send(_ context.Context, c EmailConfig, payload *NotificationPayload) error { + addr := c.Address + if addr == "" { + addr = payload.Recipient.Email + } + if addr == "" { + return errors.New("no email address available") + } + + to := &mail.Address{Address: addr} + + // Subject is an RFC 5322 header: stripping CR/LF prevents header injection + // if a domain name field ever contains them. + safeDomain := stripCRLF(payload.DomainName) + subject := fmt.Sprintf("[happyDomain] %s: %s", safeDomain, payload.NewStatus) + + // Fields populated from third-party sources (checker output produced from + // WHOIS, DNS, or remote HTTP responses) are wrapped in code spans so the + // Markdown renderer treats them as literal text. Without this, an attacker + // controlling a remote bytes path could plant a clickable link in a + // DKIM-signed mail. payload.Annotation stays unwrapped — it is authored by + // the recipient themselves, no privilege boundary to cross. + var body strings.Builder + fmt.Fprintf(&body, "## Status Change: %s -> %s\n\n", payload.OldStatus, payload.NewStatus) + fmt.Fprintf(&body, "**Domain:** %s\n\n", mdLiteral(payload.DomainName)) + fmt.Fprintf(&body, "**Checker:** %s\n\n", mdLiteral(payload.CheckerID)) + + if len(payload.States) > 0 { + body.WriteString("### Rule Results\n\n") + for _, state := range payload.States { + fmt.Fprintf(&body, "- %s (%s): %s\n", mdLiteral(state.Code), state.Status, mdLiteral(state.Message)) + } + body.WriteString("\n") + } + + if payload.Annotation != "" { + fmt.Fprintf(&body, "**Note:** %s\n\n", payload.Annotation) + } + + if s.baseURL != "" { + fmt.Fprintf(&body, "[View in happyDomain](%s)\n", s.baseURL) + } + + return s.mailer.SendMail(to, subject, body.String()) +} + +// stripCRLF removes CR and LF bytes; intended for header field values where a +// newline would terminate the header and let an attacker append further headers. +func stripCRLF(s string) string { + return strings.NewReplacer("\r", "", "\n", "").Replace(s) +} + +// mdLiteral renders s as a Markdown code span so the renderer cannot interpret +// link syntax, headings, or other formatting in user-supplied content. +// Backticks inside s are replaced with apostrophes (good enough for display +// without resorting to multi-backtick fence accounting). +func mdLiteral(s string) string { + return "`" + strings.ReplaceAll(s, "`", "'") + "`" +} diff --git a/internal/notification/sender.go b/internal/notification/sender.go new file mode 100644 index 00000000..4d08f6c0 --- /dev/null +++ b/internal/notification/sender.go @@ -0,0 +1,291 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "git.happydns.org/happyDomain/model" +) + +// Recipient is the minimum identity passed through to a sender. It carries +// only what at least one transport actually needs (currently the email +// address) so other transports cannot accidentally leak the full user record +// — webhooks must never receive the user object as a side effect of being +// configured. +type Recipient struct { + // Email is the recipient's email address. May be empty for transports + // that don't need it (webhook, UnifiedPush). + Email string +} + +// NotificationPayload holds the data passed to channel senders. Senders +// receive only what they need to render or transmit a notification — no user +// object, no server config — so adding a new transport cannot accidentally +// leak privileged data. +type NotificationPayload struct { + Recipient Recipient + CheckerID string + Target happydns.CheckTarget + DomainName string + OldStatus happydns.Status + NewStatus happydns.Status + States []happydns.CheckState + Annotation string +} + +// ChannelConfig is the typed configuration for a single channel. Each sender +// implementation defines its own concrete type that satisfies this interface. +type ChannelConfig interface { + // Validate returns a non-nil error if the configuration is invalid. + Validate() error +} + +// ChannelSender delivers notifications through one transport (email, webhook, ...). +// A sender owns its configuration shape: callers pass raw JSON, the sender +// decodes it. This keeps the model package free of transport details and makes +// adding a new channel a one-file change. +// +// Most implementations should not implement this directly — use TypedSender[C] +// and Adapt to get the unmarshal/validate/type-assert/SendTest boilerplate +// for free. +type ChannelSender interface { + // Type is the discriminator that links a NotificationChannel to this sender. + Type() happydns.NotificationChannelType + + // DecodeConfig parses and validates raw JSON config for this channel type. + DecodeConfig(raw json.RawMessage) (ChannelConfig, error) + + // Send delivers a real notification. + Send(ctx context.Context, cfg ChannelConfig, payload *NotificationPayload) error + + // SendTest delivers a test notification through the sender. + SendTest(ctx context.Context, cfg ChannelConfig, user *happydns.User) error + + // RedactConfig returns a version of raw safe to echo back to API clients — + // secrets stripped and replaced with presence booleans. Senders without + // secrets return raw unchanged. + RedactConfig(raw json.RawMessage) (json.RawMessage, error) + + // MergeForUpdate produces the config to persist on PUT. Senders with + // secrets use it to preserve the stored secret when the client submits an + // empty value (the client never sees the secret on read, so the wire + // protocol cannot carry "no change" otherwise). Senders without secrets + // return incoming unchanged. + MergeForUpdate(existing, incoming json.RawMessage) (json.RawMessage, error) +} + +// ConfigRedactor is an optional capability for transports whose config has +// secret fields. Implement on a TypedSender[C] to opt in; the adapter wires +// it into ChannelSender.RedactConfig automatically. +type ConfigRedactor[C ChannelConfig] interface { + RedactConfig(cfg C) C +} + +// ConfigMerger is an optional capability for transports that need to fold an +// existing stored config into an incoming update (e.g. preserve a secret the +// client never receives back). +type ConfigMerger[C ChannelConfig] interface { + MergeForUpdate(existing, incoming C) C +} + +// TypedSender is the strongly-typed contract a transport implements. The +// concrete config type C is checked at compile time, so implementations are +// freed from JSON unmarshaling, validation, runtime type-asserting and from +// re-implementing SendTest. Wrap with Adapt to expose as a ChannelSender. +type TypedSender[C ChannelConfig] interface { + Type() happydns.NotificationChannelType + Send(ctx context.Context, cfg C, payload *NotificationPayload) error +} + +// Adapt promotes a TypedSender[C] to a ChannelSender, providing JSON decode, +// config validation, type-asserted dispatch, and SendTest in one place. +func Adapt[C ChannelConfig](s TypedSender[C]) ChannelSender { + return &typedAdapter[C]{inner: s} +} + +type typedAdapter[C ChannelConfig] struct { + inner TypedSender[C] +} + +func (a *typedAdapter[C]) Type() happydns.NotificationChannelType { return a.inner.Type() } + +func (a *typedAdapter[C]) DecodeConfig(raw json.RawMessage) (ChannelConfig, error) { + var c C + if len(raw) > 0 { + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("decoding %s config: %w", a.inner.Type(), err) + } + } + if err := c.Validate(); err != nil { + return nil, err + } + return c, nil +} + +func (a *typedAdapter[C]) Send(ctx context.Context, cfg ChannelConfig, payload *NotificationPayload) error { + typed, ok := cfg.(C) + if !ok { + return fmt.Errorf("%s sender: unexpected config type %T", a.inner.Type(), cfg) + } + return a.inner.Send(ctx, typed, payload) +} + +func (a *typedAdapter[C]) SendTest(ctx context.Context, cfg ChannelConfig, user *happydns.User) error { + return a.Send(ctx, cfg, testPayload(Recipient{Email: user.Email})) +} + +func (a *typedAdapter[C]) RedactConfig(raw json.RawMessage) (json.RawMessage, error) { + redactor, ok := a.inner.(ConfigRedactor[C]) + if !ok { + return raw, nil + } + var c C + if len(raw) > 0 { + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("decoding %s config: %w", a.inner.Type(), err) + } + } + c = redactor.RedactConfig(c) + return json.Marshal(c) +} + +func (a *typedAdapter[C]) MergeForUpdate(existing, incoming json.RawMessage) (json.RawMessage, error) { + merger, ok := a.inner.(ConfigMerger[C]) + if !ok { + return incoming, nil + } + var ec, ic C + if len(existing) > 0 { + if err := json.Unmarshal(existing, &ec); err != nil { + return nil, fmt.Errorf("decoding existing %s config: %w", a.inner.Type(), err) + } + } + if len(incoming) > 0 { + if err := json.Unmarshal(incoming, &ic); err != nil { + return nil, fmt.Errorf("decoding %s config: %w", a.inner.Type(), err) + } + } + return json.Marshal(merger.MergeForUpdate(ec, ic)) +} + +// Registry maps channel types to their senders. Senders self-register at +// startup so adding a new transport requires no changes here. +type Registry struct { + senders map[happydns.NotificationChannelType]ChannelSender +} + +// NewRegistry returns an empty registry. +func NewRegistry() *Registry { + return &Registry{senders: make(map[happydns.NotificationChannelType]ChannelSender)} +} + +// Register adds a sender. Panics on duplicate type — duplicate registration is +// a programming error, not a runtime condition. +func (r *Registry) Register(s ChannelSender) { + t := s.Type() + if _, exists := r.senders[t]; exists { + panic(fmt.Sprintf("notification: sender already registered for type %q", t)) + } + r.senders[t] = s +} + +// Get returns the sender for the given type. +func (r *Registry) Get(t happydns.NotificationChannelType) (ChannelSender, bool) { + s, ok := r.senders[t] + return s, ok +} + +// Types returns all registered channel types. +func (r *Registry) Types() []happydns.NotificationChannelType { + out := make([]happydns.NotificationChannelType, 0, len(r.senders)) + for t := range r.senders { + out = append(out, t) + } + return out +} + +// DecodeChannelConfig is a convenience: look up the sender for the channel, +// decode and validate its config. Returns ErrUnknownChannelType if the type +// has no registered sender. +func (r *Registry) DecodeChannelConfig(ch *happydns.NotificationChannel) (ChannelConfig, error) { + s, ok := r.Get(ch.Type) + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnknownChannelType, ch.Type) + } + return s.DecodeConfig(ch.Config) +} + +// RedactChannel returns a shallow copy of ch with its Config replaced by the +// sender's redacted form. Channels of unknown types are returned unchanged so +// administrators can still observe legacy data; secret-carrying senders that +// implement ConfigRedactor get their secrets stripped here. +func (r *Registry) RedactChannel(ch *happydns.NotificationChannel) (*happydns.NotificationChannel, error) { + if ch == nil { + return nil, nil + } + s, ok := r.Get(ch.Type) + if !ok { + copy := *ch + return ©, nil + } + redacted, err := s.RedactConfig(ch.Config) + if err != nil { + return nil, err + } + copy := *ch + copy.Config = redacted + return ©, nil +} + +// RedactChannels returns a fresh slice with each channel redacted. Errors +// short-circuit; callers can choose whether to log and degrade or fail. +func (r *Registry) RedactChannels(chs []*happydns.NotificationChannel) ([]*happydns.NotificationChannel, error) { + out := make([]*happydns.NotificationChannel, 0, len(chs)) + for _, ch := range chs { + red, err := r.RedactChannel(ch) + if err != nil { + return nil, err + } + out = append(out, red) + } + return out, nil +} + +// MergeChannelForUpdate folds an existing stored channel's config into an +// incoming update, letting senders preserve fields the client cannot resend +// (typically secrets). Returns the merged raw config; callers should then +// validate it via DecodeConfig before persisting. +func (r *Registry) MergeChannelForUpdate(existing, incoming *happydns.NotificationChannel) (json.RawMessage, error) { + s, ok := r.Get(incoming.Type) + if !ok { + return incoming.Config, nil + } + return s.MergeForUpdate(existing.Config, incoming.Config) +} + +// ErrUnknownChannelType is returned when a channel references a type that has +// no registered sender. +var ErrUnknownChannelType = errors.New("unknown channel type") From c181befa913024dd93a342783d1560c55f54dcda Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 29 Apr 2026 20:57:03 +0700 Subject: [PATCH 04/10] notification: add webhook channel sender WebhookSender posts a JSON payload to a user-configured URL with an optional HMAC-SHA256 signature header. Outbound requests go through a SafeHTTP transport that blocks loopback, link-local, and private address ranges to prevent SSRF against internal services. --- internal/notification/httpjson.go | 103 +++++++++++++++ internal/notification/safe_http.go | 145 +++++++++++++++++++++ internal/notification/webhook_sender.go | 159 ++++++++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 internal/notification/httpjson.go create mode 100644 internal/notification/safe_http.go create mode 100644 internal/notification/webhook_sender.go diff --git a/internal/notification/httpjson.go b/internal/notification/httpjson.go new file mode 100644 index 00000000..380c1316 --- /dev/null +++ b/internal/notification/httpjson.go @@ -0,0 +1,103 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "git.happydns.org/happyDomain/model" +) + +// httpJSONPayload is the wire format used by both webhook and UnifiedPush. +type httpJSONPayload struct { + Event string `json:"event"` + Checker string `json:"checker"` + Domain string `json:"domain"` + Target happydns.CheckTarget `json:"target"` + OldStatus happydns.Status `json:"oldStatus"` + NewStatus happydns.Status `json:"newStatus"` + States []happydns.CheckState `json:"states,omitempty"` + Timestamp time.Time `json:"timestamp"` + DashboardURL string `json:"dashboardUrl,omitempty"` +} + +func buildHTTPPayload(p *NotificationPayload, dashboardURL string) httpJSONPayload { + return httpJSONPayload{ + Event: "status_change", + Checker: p.CheckerID, + Domain: p.DomainName, + Target: p.Target, + OldStatus: p.OldStatus, + NewStatus: p.NewStatus, + States: p.States, + Timestamp: time.Now(), + DashboardURL: dashboardURL, + } +} + +// postJSON marshals body, POSTs it, runs decorate (e.g. for HMAC/headers), +// and returns an error on a non-2xx response or transport failure. +func postJSON(ctx context.Context, client *http.Client, url string, body any, decorate func(*http.Request, []byte)) error { + raw, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshaling payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if decorate != nil { + decorate(req, raw) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBodyBytes)) + + if resp.StatusCode >= 300 { + return fmt.Errorf("endpoint returned status %d", resp.StatusCode) + } + return nil +} + +// testPayload builds a synthetic payload used by SendTest implementations. +func testPayload(rcpt Recipient) *NotificationPayload { + return &NotificationPayload{ + Recipient: rcpt, + CheckerID: "test", + DomainName: "example.com", + OldStatus: happydns.StatusOK, + NewStatus: happydns.StatusWarn, + Annotation: "This is a test notification from happyDomain.", + } +} diff --git a/internal/notification/safe_http.go b/internal/notification/safe_http.go new file mode 100644 index 00000000..ee7401a7 --- /dev/null +++ b/internal/notification/safe_http.go @@ -0,0 +1,145 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "syscall" + "time" +) + +// maxResponseBodyBytes caps the bytes read from a remote endpoint's response. +// We do not need the body for routing, just to drain it for keep-alive. +const maxResponseBodyBytes = 64 * 1024 + +// errBlockedAddress is returned when a webhook target resolves to a +// disallowed address range (private, loopback, link-local, multicast, etc.). +var errBlockedAddress = errors.New("address resolves to a blocked range") + +// validateOutboundURL parses rawURL and rejects targets that should never +// be reached from a server-side webhook: non-http(s) schemes, missing host, +// or hosts that are bare IP literals in private/loopback/link-local ranges. +// DNS-based hosts are re-checked at dial time by safeDialContext. +func validateOutboundURL(rawURL string) (*url.URL, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("unsupported scheme %q (only http and https are allowed)", u.Scheme) + } + host := u.Hostname() + if host == "" { + return nil, errors.New("URL has no host") + } + if ip := net.ParseIP(host); ip != nil { + if !isPublicIP(ip) { + return nil, errBlockedAddress + } + } + return u, nil +} + +// isPublicIP reports whether ip is a globally routable unicast address. +// Anything else (loopback, link-local, multicast, private RFC1918/ULA, +// IPv4 broadcast, the unspecified address, the IPv6 link-local fe80::/10, +// or the IPv4-mapped 169.254.169.254 metadata address) is rejected. +func isPublicIP(ip net.IP) bool { + if ip == nil { + return false + } + if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsPrivate() || ip.IsInterfaceLocalMulticast() { + return false + } + // Block IPv4 broadcast 255.255.255.255 explicitly (not covered above). + if v4 := ip.To4(); v4 != nil && v4[0] == 255 && v4[1] == 255 && v4[2] == 255 && v4[3] == 255 { + return false + } + return true +} + +// safeDialContext wraps net.Dialer.DialContext and re-checks the resolved IP +// before allowing the connection. This catches DNS rebinding attacks and +// hostnames that resolve to internal addresses. +func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + dialer := &net.Dialer{Timeout: 10 * time.Second} + resolver := net.DefaultResolver + ips, err := resolver.LookupIP(ctx, "ip", host) + if err != nil { + return nil, err + } + for _, ip := range ips { + if !isPublicIP(ip) { + return nil, fmt.Errorf("dial %s: %w", address, errBlockedAddress) + } + } + // Pin the dial to a validated IP rather than letting the dialer re-resolve. + var lastErr error + for _, ip := range ips { + conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + if err == nil { + return conn, nil + } + lastErr = err + if errors.Is(err, syscall.ECONNREFUSED) { + continue + } + } + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("dial %s: no usable address", address) +} + +// newSafeHTTPClient returns an *http.Client that refuses to talk to private +// or loopback addresses and re-validates each redirect hop. +func newSafeHTTPClient(timeout time.Duration) *http.Client { + transport := &http.Transport{ + DialContext: safeDialContext, + ResponseHeaderTimeout: timeout, + IdleConnTimeout: 30 * time.Second, + } + return &http.Client{ + Timeout: timeout, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("too many redirects") + } + if _, err := validateOutboundURL(req.URL.String()); err != nil { + return err + } + return nil + }, + } +} diff --git a/internal/notification/webhook_sender.go b/internal/notification/webhook_sender.go new file mode 100644 index 00000000..92265f62 --- /dev/null +++ b/internal/notification/webhook_sender.go @@ -0,0 +1,159 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "git.happydns.org/happyDomain/model" +) + +// disallowedWebhookHeaders are header names that must be controlled by the +// HTTP client (transport/length) or that would let a user override our +// outbound identity. Allowing them invites smuggling, host-routing tricks, +// and content-length mismatches. +var disallowedWebhookHeaders = map[string]struct{}{ + "host": {}, + "content-length": {}, + "content-encoding": {}, + "transfer-encoding": {}, + "connection": {}, + "upgrade": {}, + "te": {}, + "trailer": {}, +} + +// validateHeader rejects a custom webhook header if its name is reserved or +// if either name or value contains CR/LF (header injection). +func validateHeader(k, v string) error { + if k == "" { + return errors.New("empty header name") + } + if strings.ContainsAny(k, "\r\n") || strings.ContainsAny(v, "\r\n") { + return fmt.Errorf("header %q contains CR/LF", k) + } + if _, blocked := disallowedWebhookHeaders[strings.ToLower(k)]; blocked { + return fmt.Errorf("header %q is not allowed", k) + } + return nil +} + +const ChannelTypeWebhook happydns.NotificationChannelType = "webhook" + +// WebhookConfig is the per-channel configuration for the webhook sender. +type WebhookConfig struct { + // URL is the HTTP endpoint to POST to. + URL string `json:"url"` + + // Headers are extra headers to send with each request. + Headers map[string]string `json:"headers,omitempty"` + + // Secret, if set, is used to compute an HMAC-SHA256 signature header. + Secret string `json:"secret,omitempty"` + + // HasSecret is set on the response side only by RedactConfig. It is never + // stored or accepted on input — the API serializer overwrites it. + HasSecret bool `json:"hasSecret,omitempty"` +} + +func (c WebhookConfig) Validate() error { + if c.URL == "" { + return errors.New("webhook URL is required") + } + if _, err := validateOutboundURL(c.URL); err != nil { + return fmt.Errorf("webhook URL: %w", err) + } + for k, v := range c.Headers { + if err := validateHeader(k, v); err != nil { + return fmt.Errorf("webhook header: %w", err) + } + } + return nil +} + +// WebhookSender sends notifications via HTTP POST to a configured URL. The +// dashboard URL is captured here so it does not need to be threaded through +// every payload — it is server identity, not per-notification data. +type WebhookSender struct { + client *http.Client + dashboardURL string +} + +// NewWebhookSender creates a new WebhookSender. dashboardURL is published to +// recipients in the JSON payload's "dashboardUrl" field; pass "" to omit it. +func NewWebhookSender(dashboardURL string) *WebhookSender { + return &WebhookSender{ + client: newSafeHTTPClient(10 * time.Second), + dashboardURL: dashboardURL, + } +} + +func (s *WebhookSender) Type() happydns.NotificationChannelType { return ChannelTypeWebhook } + +// RedactConfig clears the HMAC secret before the config is echoed back to the +// API client and surfaces a presence boolean instead. The transport never +// needs to send the secret outbound; clients never need to read it back. +func (s *WebhookSender) RedactConfig(cfg WebhookConfig) WebhookConfig { + cfg.HasSecret = cfg.Secret != "" + cfg.Secret = "" + return cfg +} + +// MergeForUpdate preserves a previously stored secret when the client submits +// an empty one. Since RedactConfig never returns the secret, the only way the +// client could carry it back to us is by re-typing it; absence means "no +// change", not "clear it". +func (s *WebhookSender) MergeForUpdate(existing, incoming WebhookConfig) WebhookConfig { + if incoming.Secret == "" { + incoming.Secret = existing.Secret + } + incoming.HasSecret = false + return incoming +} + +func (s *WebhookSender) Send(ctx context.Context, c WebhookConfig, payload *NotificationPayload) error { + return postJSON(ctx, s.client, c.URL, buildHTTPPayload(payload, s.dashboardURL), func(req *http.Request, body []byte) { + req.Header.Set("User-Agent", "happyDomain-Notification/1.0") + for k, v := range c.Headers { + // Defense in depth: Validate() should already have rejected + // bad headers, but skip them here too in case a stored + // channel pre-dates the validation. + if err := validateHeader(k, v); err != nil { + continue + } + req.Header.Set(k, v) + } + if c.Secret != "" { + mac := hmac.New(sha256.New, []byte(c.Secret)) + mac.Write(body) + req.Header.Set("X-Happydomain-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil))) + } + }) +} From a2fa5811507df6eb82d98a36f4ba8a770d744f2a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 29 Apr 2026 20:57:10 +0700 Subject: [PATCH 05/10] notification: add UnifiedPush channel sender UnifiedPushSender posts notification payloads to a user-supplied UnifiedPush distributor endpoint following the UnifiedPush protocol. --- internal/notification/unifiedpush_sender.go | 72 +++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 internal/notification/unifiedpush_sender.go diff --git a/internal/notification/unifiedpush_sender.go b/internal/notification/unifiedpush_sender.go new file mode 100644 index 00000000..f41b6050 --- /dev/null +++ b/internal/notification/unifiedpush_sender.go @@ -0,0 +1,72 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "git.happydns.org/happyDomain/model" +) + +const ChannelTypeUnifiedPush happydns.NotificationChannelType = "unifiedpush" + +// UnifiedPushConfig is the per-channel configuration for the UnifiedPush sender. +type UnifiedPushConfig struct { + // Endpoint is the push server endpoint URL provided by the distributor. + Endpoint string `json:"endpoint"` +} + +func (c UnifiedPushConfig) Validate() error { + if c.Endpoint == "" { + return errors.New("UnifiedPush endpoint is required") + } + if _, err := validateOutboundURL(c.Endpoint); err != nil { + return fmt.Errorf("UnifiedPush endpoint: %w", err) + } + return nil +} + +// UnifiedPushSender sends notifications via the UnifiedPush protocol. The +// dashboard URL is captured here rather than threaded through the payload. +type UnifiedPushSender struct { + client *http.Client + dashboardURL string +} + +// NewUnifiedPushSender creates a new UnifiedPushSender. dashboardURL is +// published to recipients in the JSON payload's "dashboardUrl" field. +func NewUnifiedPushSender(dashboardURL string) *UnifiedPushSender { + return &UnifiedPushSender{ + client: newSafeHTTPClient(10 * time.Second), + dashboardURL: dashboardURL, + } +} + +func (s *UnifiedPushSender) Type() happydns.NotificationChannelType { return ChannelTypeUnifiedPush } + +func (s *UnifiedPushSender) Send(ctx context.Context, c UnifiedPushConfig, payload *NotificationPayload) error { + return postJSON(ctx, s.client, c.Endpoint, buildHTTPPayload(payload, s.dashboardURL), nil) +} From a73fd9319470ba83db591fecfcaaf25557c14c84 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 11 Apr 2026 08:47:42 +0700 Subject: [PATCH 06/10] notification: add dispatcher with state tracking and acknowledgement The Dispatcher is the core notification logic: it receives execution callbacks, detects status transitions via persisted NotificationState, resolves user preferences by specificity (service > domain > global), respects quiet hours, and dispatches through configured channels. Acknowledgement support allows users to suppress repeat notifications until the next state change. --- internal/usecase/notification/acknowledge.go | 98 +++++++ internal/usecase/notification/dispatcher.go | 240 ++++++++++++++++++ internal/usecase/notification/policy.go | 130 ++++++++++ internal/usecase/notification/policy_test.go | 204 +++++++++++++++ internal/usecase/notification/pool.go | 196 ++++++++++++++ internal/usecase/notification/resolver.go | 97 +++++++ .../usecase/notification/resolver_test.go | 98 +++++++ internal/usecase/notification/state_lock.go | 95 +++++++ internal/usecase/notification/tester.go | 57 +++++ 9 files changed, 1215 insertions(+) create mode 100644 internal/usecase/notification/acknowledge.go create mode 100644 internal/usecase/notification/dispatcher.go create mode 100644 internal/usecase/notification/policy.go create mode 100644 internal/usecase/notification/policy_test.go create mode 100644 internal/usecase/notification/pool.go create mode 100644 internal/usecase/notification/resolver.go create mode 100644 internal/usecase/notification/resolver_test.go create mode 100644 internal/usecase/notification/state_lock.go create mode 100644 internal/usecase/notification/tester.go diff --git a/internal/usecase/notification/acknowledge.go b/internal/usecase/notification/acknowledge.go new file mode 100644 index 00000000..b9cdf936 --- /dev/null +++ b/internal/usecase/notification/acknowledge.go @@ -0,0 +1,98 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "errors" + "time" + + "git.happydns.org/happyDomain/model" +) + +// AckService manages user acknowledgements of incidents. It depends only on +// the state store: it neither sends notifications nor consults preferences. +type AckService struct { + stateStore NotificationStateStorage + locker *StateLocker + + // nowFn is the clock used for AcknowledgedAt. Overridable by same-package + // tests; defaults to time.Now. + nowFn func() time.Time +} + +// NewAckService builds an AckService bound to the state store. locker is +// shared with the Dispatcher so the two cannot race on the same state record. +func NewAckService(stateStore NotificationStateStorage, locker *StateLocker) *AckService { + return &AckService{stateStore: stateStore, locker: locker, nowFn: time.Now} +} + +// AcknowledgeIssue marks an issue as acknowledged, suppressing repeat +// notifications until the next state change. +func (a *AckService) AcknowledgeIssue(userId happydns.Identifier, checkerID string, target happydns.CheckTarget, acknowledgedBy string, annotation string) error { + unlock := a.locker.Lock(checkerID, target, userId) + defer unlock() + + state, err := a.stateStore.GetState(checkerID, target, userId) + if errors.Is(err, happydns.ErrNotificationStateNotFound) { + // Create a new state if one doesn't exist yet. + state = &happydns.NotificationState{ + CheckerID: checkerID, + Target: target, + UserId: userId, + LastStatus: happydns.StatusUnknown, + } + } else if err != nil { + return err + } + + now := a.nowFn() + state.Acknowledged = true + state.AcknowledgedAt = &now + state.AcknowledgedBy = acknowledgedBy + state.Annotation = annotation + + return a.stateStore.PutState(state) +} + +// ClearAcknowledgement removes the acknowledgement from an issue. +func (a *AckService) ClearAcknowledgement(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) error { + unlock := a.locker.Lock(checkerID, target, userId) + defer unlock() + + state, err := a.stateStore.GetState(checkerID, target, userId) + if err != nil { + return err + } + + state.ClearAcknowledgement() + return a.stateStore.PutState(state) +} + +// GetState returns the current notification state for a checker/target/user. +func (a *AckService) GetState(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) (*happydns.NotificationState, error) { + return a.stateStore.GetState(checkerID, target, userId) +} + +// ListStatesByUser returns all notification states for a user. +func (a *AckService) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) { + return a.stateStore.ListStatesByUser(userId) +} diff --git a/internal/usecase/notification/dispatcher.go b/internal/usecase/notification/dispatcher.go new file mode 100644 index 00000000..52668bf7 --- /dev/null +++ b/internal/usecase/notification/dispatcher.go @@ -0,0 +1,240 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "errors" + "log" + "time" + + notifPkg "git.happydns.org/happyDomain/internal/notification" + "git.happydns.org/happyDomain/model" +) + +// Dispatcher orchestrates the response to a checker execution: load state, +// consult policy, persist the new state, and hand off any send to the Pool. +// +// It owns no I/O of its own — all storage, sending, resolving, and +// acknowledgement live in dedicated collaborators. Its single job is to glue +// them together at the seam between the checker and the notification system. +type Dispatcher struct { + stateStore NotificationStateStorage + userStore UserGetter + domainStore DomainGetter + + resolver *Resolver + pool *Pool + tester *Tester + ack *AckService + locker *StateLocker + + // nowFn is the clock used for state timestamps. Overridable by same-package + // tests to drive time-dependent policy decisions deterministically. + nowFn func() time.Time +} + +// NewDispatcher builds a Dispatcher from its collaborators. The caller is +// responsible for the lifecycle of the Pool (Start/Stop); the Dispatcher does +// not own it. +func NewDispatcher( + stateStore NotificationStateStorage, + userStore UserGetter, + domainStore DomainGetter, + resolver *Resolver, + pool *Pool, + tester *Tester, + ack *AckService, + locker *StateLocker, +) *Dispatcher { + return &Dispatcher{ + stateStore: stateStore, + userStore: userStore, + domainStore: domainStore, + resolver: resolver, + pool: pool, + tester: tester, + ack: ack, + locker: locker, + nowFn: time.Now, + } +} + +// Start delegates to the Pool. Kept on Dispatcher so existing app wiring, +// which thinks of "the dispatcher" as a lifecycle unit, doesn't change. +func (d *Dispatcher) Start() { d.pool.Start() } + +// Stop delegates to the Pool. +func (d *Dispatcher) Stop() { d.pool.Stop() } + +// OnExecutionComplete is the callback invoked after a checker execution +// finishes. It walks the policy decision tree and either skips, advances +// state silently, or enqueues sends through the pool. +func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydns.CheckEvaluation) { + if exec == nil || exec.Status != happydns.ExecutionDone { + return + } + + userId := happydns.TargetIdentifier(exec.Target.UserId) + if userId == nil { + return + } + + user, err := d.userStore.GetUser(*userId) + if err != nil { + log.Printf("notification: failed to load user %s: %v", userId, err) + return + } + + newStatus := exec.Result.Status + + // Serialise the load-modify-store sequence with the AckService so a + // concurrent acknowledgement cannot be silently overwritten, and so two + // rapid executions for the same target cannot both observe the old state + // and fire duplicate notifications. + unlock := d.locker.Lock(exec.CheckerID, exec.Target, *userId) + defer unlock() + + state, err := d.loadOrInitState(exec, *userId) + if err != nil { + return + } + + oldStatus := state.LastStatus + pref := d.resolver.ResolvePreference(user, exec.Target) + + dec := decide(state, pref, newStatus, d.nowFn()) + + // Recovery and escalation invalidate any prior acknowledgement: the + // incident is either over or has gotten worse, so the user should be + // re-paged on the next non-suppressed alert. + if dec.ClearAck { + state.ClearAcknowledgement() + } + + switch dec.Action { + case actionSkip: + return + case actionAdvance: + d.advanceState(state, newStatus) + return + } + + payload := d.buildPayload(user, exec, eval, oldStatus, newStatus) + + // Mark as notified before enqueuing so a rapid re-run of the checker + // observing the same transition will see oldStatus == newStatus and skip, + // even if the worker pool has not drained yet. + d.markNotified(state, newStatus) + + for _, ch := range d.resolver.ResolveChannels(user, pref) { + d.pool.Enqueue(ch, payload, user) + } +} + +func (d *Dispatcher) loadOrInitState(exec *happydns.Execution, userId happydns.Identifier) (*happydns.NotificationState, error) { + state, err := d.stateStore.GetState(exec.CheckerID, exec.Target, userId) + if errors.Is(err, happydns.ErrNotificationStateNotFound) { + return &happydns.NotificationState{ + CheckerID: exec.CheckerID, + Target: exec.Target, + UserId: userId, + LastStatus: happydns.StatusUnknown, + }, nil + } + if err != nil { + log.Printf("notification: failed to load state for %q/%q: %v", exec.CheckerID, exec.Target.String(), err) + return nil, err + } + return state, nil +} + +func (d *Dispatcher) buildPayload(user *happydns.User, exec *happydns.Execution, eval *happydns.CheckEvaluation, oldStatus, newStatus happydns.Status) *notifPkg.NotificationPayload { + var domainName string + if did := happydns.TargetIdentifier(exec.Target.DomainId); did != nil { + if domain, err := d.domainStore.GetDomain(*did); err == nil { + domainName = domain.DomainName + } + } + if domainName == "" { + domainName = "(unknown domain)" + } + + var states []happydns.CheckState + if eval != nil { + states = eval.States + } + + return ¬ifPkg.NotificationPayload{ + Recipient: notifPkg.Recipient{Email: user.Email}, + CheckerID: exec.CheckerID, + Target: exec.Target, + DomainName: domainName, + OldStatus: oldStatus, + NewStatus: newStatus, + States: states, + } +} + +// advanceState persists the new observed status without claiming a +// notification was sent. Used when policy suppresses the alert. +func (d *Dispatcher) advanceState(state *happydns.NotificationState, newStatus happydns.Status) { + state.LastStatus = newStatus + if err := d.stateStore.PutState(state); err != nil { + log.Printf("notification: failed to update state: %v", err) + } +} + +// markNotified persists the new status and stamps LastNotifiedAt. Called only +// after the dispatcher actually attempts to deliver a notification. +func (d *Dispatcher) markNotified(state *happydns.NotificationState, newStatus happydns.Status) { + state.LastStatus = newStatus + state.LastNotifiedAt = d.nowFn() + if err := d.stateStore.PutState(state); err != nil { + log.Printf("notification: failed to update state: %v", err) + } +} + +// SendTestNotification delegates to the Tester. Kept for backwards-compat with +// the existing controller wiring; new callers should depend on *Tester directly. +func (d *Dispatcher) SendTestNotification(ch *happydns.NotificationChannel, user *happydns.User) error { + return d.tester.Send(ch, user) +} + +// AcknowledgeIssue delegates to the AckService. +func (d *Dispatcher) AcknowledgeIssue(userId happydns.Identifier, checkerID string, target happydns.CheckTarget, acknowledgedBy string, annotation string) error { + return d.ack.AcknowledgeIssue(userId, checkerID, target, acknowledgedBy, annotation) +} + +// ClearAcknowledgement delegates to the AckService. +func (d *Dispatcher) ClearAcknowledgement(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) error { + return d.ack.ClearAcknowledgement(userId, checkerID, target) +} + +// GetState delegates to the AckService. +func (d *Dispatcher) GetState(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) (*happydns.NotificationState, error) { + return d.ack.GetState(userId, checkerID, target) +} + +// ListStatesByUser delegates to the AckService. +func (d *Dispatcher) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) { + return d.ack.ListStatesByUser(userId) +} diff --git a/internal/usecase/notification/policy.go b/internal/usecase/notification/policy.go new file mode 100644 index 00000000..9da83040 --- /dev/null +++ b/internal/usecase/notification/policy.go @@ -0,0 +1,130 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "time" + + "git.happydns.org/happyDomain/model" +) + +// decisionAction is the outcome of evaluating notification policy for a +// single status transition. +type decisionAction int + +const ( + // actionSkip: no observable transition; do not touch state, do not notify. + actionSkip decisionAction = iota + // actionAdvance: status changed but policy says do not notify; persist + // the new LastStatus only. + actionAdvance + // actionNotify: send the notification and stamp LastNotifiedAt. + actionNotify +) + +// decision is a pure description of what to do with a status transition, +// independent of how we persist or dispatch it. The Reason is intended for +// logging and tests; callers must not branch on its exact text. +type decision struct { + Action decisionAction + Reason string + IsRecovery bool + IsEscalation bool + // ClearAck is true when the transition resolves or worsens the incident + // enough that any prior acknowledgement should be discarded. + ClearAck bool +} + +// decide is the pure policy predicate: given a transition and the user's +// preference for the target, return whether to skip, advance, or notify. +// +// A nil pref is treated as "no preference configured for this scope" and +// suppresses notifications (but still advances state on a real transition). +// +// `now` is injected so tests can pin the clock for quiet-hour checks. +func decide(state *happydns.NotificationState, pref *happydns.NotificationPreference, newStatus happydns.Status, now time.Time) decision { + oldStatus := state.LastStatus + + if oldStatus == newStatus { + return decision{Action: actionSkip, Reason: "no transition"} + } + + isRecovery := newStatus < happydns.StatusWarn && oldStatus >= happydns.StatusWarn + isEscalation := newStatus > oldStatus && newStatus >= happydns.StatusWarn + clearAck := isRecovery || isEscalation + + d := decision{IsRecovery: isRecovery, IsEscalation: isEscalation, ClearAck: clearAck} + + if pref == nil { + d.Action = actionAdvance + d.Reason = "no preference configured" + return d + } + if !pref.Enabled { + d.Action = actionAdvance + d.Reason = "preference disabled" + return d + } + if !isRecovery && newStatus < pref.MinStatus { + d.Action = actionAdvance + d.Reason = "below MinStatus threshold" + return d + } + if isRecovery && !pref.NotifyRecovery { + d.Action = actionAdvance + d.Reason = "recovery suppressed by preference" + return d + } + // An active acknowledgement that hasn't just been cleared (above) means + // the user already knows: suppress repeat alerts. Recoveries always notify + // (subject to NotifyRecovery), so we only check on non-recovery here. + if state.Acknowledged && !clearAck && !isRecovery { + d.Action = actionAdvance + d.Reason = "acknowledged" + return d + } + if isQuietHour(pref, now) { + d.Action = actionAdvance + d.Reason = "quiet hours" + return d + } + + d.Action = actionNotify + d.Reason = "notify" + return d +} + +// isQuietHour reports whether `now` (in UTC) falls within the preference's +// configured quiet window. Returns false when no window is configured. +func isQuietHour(pref *happydns.NotificationPreference, now time.Time) bool { + if pref.QuietStart == nil || pref.QuietEnd == nil { + return false + } + hour := now.UTC().Hour() + start := *pref.QuietStart + end := *pref.QuietEnd + if start <= end { + return hour >= start && hour < end + } + // Wraps midnight, e.g. 22:00 - 06:00. + return hour >= start || hour < end +} diff --git a/internal/usecase/notification/policy_test.go b/internal/usecase/notification/policy_test.go new file mode 100644 index 00000000..ebd86357 --- /dev/null +++ b/internal/usecase/notification/policy_test.go @@ -0,0 +1,204 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "testing" + "time" + + "git.happydns.org/happyDomain/model" +) + +func ptr[T any](v T) *T { return &v } + +func TestDecide(t *testing.T) { + noon := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) + night := time.Date(2026, 4, 29, 3, 0, 0, 0, time.UTC) + + enabled := func() *happydns.NotificationPreference { + return &happydns.NotificationPreference{ + Enabled: true, + MinStatus: happydns.StatusWarn, + NotifyRecovery: true, + } + } + + tests := []struct { + name string + state happydns.NotificationState + pref *happydns.NotificationPreference + newStatus happydns.Status + now time.Time + wantAction decisionAction + wantClear bool + }{ + { + name: "no transition", + state: happydns.NotificationState{LastStatus: happydns.StatusOK}, + pref: enabled(), + newStatus: happydns.StatusOK, + now: noon, + wantAction: actionSkip, + }, + { + name: "nil preference advances state without notifying", + state: happydns.NotificationState{LastStatus: happydns.StatusOK}, + pref: nil, + newStatus: happydns.StatusCrit, + now: noon, + wantAction: actionAdvance, + wantClear: true, + }, + { + name: "preference disabled advances", + state: happydns.NotificationState{LastStatus: happydns.StatusOK}, + pref: &happydns.NotificationPreference{ + Enabled: false, + MinStatus: happydns.StatusWarn, + }, + newStatus: happydns.StatusCrit, + now: noon, + wantAction: actionAdvance, + wantClear: true, + }, + { + name: "below MinStatus advances", + state: happydns.NotificationState{LastStatus: happydns.StatusUnknown}, + pref: enabled(), + newStatus: happydns.StatusOK, + now: noon, + wantAction: actionAdvance, + }, + { + name: "recovery suppressed when NotifyRecovery is false", + state: happydns.NotificationState{LastStatus: happydns.StatusCrit}, + pref: &happydns.NotificationPreference{ + Enabled: true, + MinStatus: happydns.StatusWarn, + NotifyRecovery: false, + }, + newStatus: happydns.StatusOK, + now: noon, + wantAction: actionAdvance, + wantClear: true, + }, + { + name: "recovery notifies when NotifyRecovery is true", + state: happydns.NotificationState{LastStatus: happydns.StatusCrit}, + pref: enabled(), + newStatus: happydns.StatusOK, + now: noon, + wantAction: actionNotify, + wantClear: true, + }, + { + name: "escalation notifies and clears ack", + state: happydns.NotificationState{LastStatus: happydns.StatusWarn, Acknowledged: true}, + pref: enabled(), + newStatus: happydns.StatusCrit, + now: noon, + wantAction: actionNotify, + wantClear: true, + }, + { + name: "acknowledged non-recovery is suppressed", + state: happydns.NotificationState{LastStatus: happydns.StatusCrit, Acknowledged: true}, + pref: enabled(), + newStatus: happydns.StatusWarn, + now: noon, + wantAction: actionAdvance, + wantClear: false, + }, + { + name: "quiet hours suppress alert", + state: happydns.NotificationState{LastStatus: happydns.StatusOK}, + pref: &happydns.NotificationPreference{ + Enabled: true, + MinStatus: happydns.StatusWarn, + NotifyRecovery: true, + QuietStart: ptr(22), + QuietEnd: ptr(6), + }, + newStatus: happydns.StatusCrit, + now: night, + wantAction: actionAdvance, + wantClear: true, + }, + { + name: "outside quiet hours notifies", + state: happydns.NotificationState{LastStatus: happydns.StatusOK}, + pref: &happydns.NotificationPreference{ + Enabled: true, + MinStatus: happydns.StatusWarn, + NotifyRecovery: true, + QuietStart: ptr(22), + QuietEnd: ptr(6), + }, + newStatus: happydns.StatusCrit, + now: noon, + wantAction: actionNotify, + wantClear: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := decide(&tc.state, tc.pref, tc.newStatus, tc.now) + if got.Action != tc.wantAction { + t.Errorf("Action: got %v (%s), want %v", got.Action, got.Reason, tc.wantAction) + } + if got.ClearAck != tc.wantClear { + t.Errorf("ClearAck: got %v, want %v", got.ClearAck, tc.wantClear) + } + }) + } +} + +func TestIsQuietHour(t *testing.T) { + at := func(h int) time.Time { + return time.Date(2026, 4, 29, h, 30, 0, 0, time.UTC) + } + tests := []struct { + name string + start *int + end *int + hour int + expect bool + }{ + {"no window", nil, nil, 3, false}, + {"inside non-wrap (9-17)", ptr(9), ptr(17), 12, true}, + {"outside non-wrap (9-17)", ptr(9), ptr(17), 18, false}, + {"end-exclusive (9-17 at 17)", ptr(9), ptr(17), 17, false}, + {"wrap inside before midnight (22-6)", ptr(22), ptr(6), 23, true}, + {"wrap inside after midnight (22-6)", ptr(22), ptr(6), 3, true}, + {"wrap outside (22-6)", ptr(22), ptr(6), 12, false}, + {"wrap end-exclusive (22-6 at 6)", ptr(22), ptr(6), 6, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pref := &happydns.NotificationPreference{QuietStart: tc.start, QuietEnd: tc.end} + if got := isQuietHour(pref, at(tc.hour)); got != tc.expect { + t.Errorf("got %v, want %v", got, tc.expect) + } + }) + } +} diff --git a/internal/usecase/notification/pool.go b/internal/usecase/notification/pool.go new file mode 100644 index 00000000..168188b7 --- /dev/null +++ b/internal/usecase/notification/pool.go @@ -0,0 +1,196 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "context" + "fmt" + "log" + "sync" + "sync/atomic" + "time" + + notifPkg "git.happydns.org/happyDomain/internal/notification" + "git.happydns.org/happyDomain/model" +) + +const ( + // dispatchWorkers is the number of goroutines draining the send queue. + dispatchWorkers = 4 + // dispatchQueueSize bounds the number of in-flight dispatch jobs. On + // overflow, jobs are not silently dropped: an audit record is written so + // the user can see in their history that an alert was lost to back-pressure. + dispatchQueueSize = 256 + // sendTimeout caps a single Send call. Long enough for a polite remote, + // short enough that a wedged endpoint cannot starve workers. + sendTimeout = 15 * time.Second + // maxRecordErrorLen bounds the persisted error string so a verbose remote + // (or a misbehaving sender wrapping a giant body into the error) cannot + // bloat the audit log indefinitely. + maxRecordErrorLen = 512 +) + +// truncateError clips an error message to maxRecordErrorLen bytes for storage. +// The truncation marker is appended so operators reading history see that the +// message was cut rather than mistaking the prefix for the full error. +func truncateError(s string) string { + const marker = "…[truncated]" + if len(s) <= maxRecordErrorLen { + return s + } + return s[:maxRecordErrorLen-len(marker)] + marker +} + +// dispatchJob is the unit of work consumed by the worker pool. +type dispatchJob struct { + channel *happydns.NotificationChannel + payload *notifPkg.NotificationPayload + user *happydns.User +} + +// Pool is the asynchronous send fan-out. It owns the bounded job queue and a +// fixed worker pool that drives senders and persists audit records. The Pool +// has no policy of its own; deciding *whether* to enqueue is the caller's job. +type Pool struct { + registry *notifPkg.Registry + recordStore NotificationRecordStorage + + jobs chan dispatchJob + wg sync.WaitGroup + stopped atomic.Bool + stopOnce sync.Once + + // nowFn is the clock used to stamp audit records. Overridable by + // same-package tests; defaults to time.Now. + nowFn func() time.Time +} + +// NewPool builds a Pool. Workers are not started yet — call Start. +func NewPool(registry *notifPkg.Registry, recordStore NotificationRecordStorage) *Pool { + return &Pool{ + registry: registry, + recordStore: recordStore, + jobs: make(chan dispatchJob, dispatchQueueSize), + nowFn: time.Now, + } +} + +// Start spins up the workers. Must be called before Enqueue. +func (p *Pool) Start() { + for range dispatchWorkers { + p.wg.Add(1) + go p.worker() + } +} + +// Stop closes the queue and waits for in-flight sends to finish. Safe to call +// multiple times. After Stop, Enqueue is a no-op (returns false): callers that +// race a shutdown are not punished with a panic on send-to-closed-channel. +func (p *Pool) Stop() { + p.stopOnce.Do(func() { + p.stopped.Store(true) + close(p.jobs) + }) + p.wg.Wait() +} + +func (p *Pool) worker() { + defer p.wg.Done() + for job := range p.jobs { + p.sendAndRecord(job.channel, job.payload, job.user) + } +} + +// Enqueue submits a send for asynchronous delivery. Returns false if the pool +// is stopped or the queue is saturated; in the saturation case the failure is +// also persisted as an audit record so it surfaces in the user's history. +func (p *Pool) Enqueue(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) bool { + if p.stopped.Load() { + return false + } + job := dispatchJob{channel: ch, payload: payload, user: user} + select { + case p.jobs <- job: + return true + default: + // Saturated: don't wedge the checker, but don't drop silently either — + // a missed alert is exactly the failure mode this audit log exists for. + log.Printf("notification: dispatch queue full, recording back-pressure failure for channel %s (%q)", ch.Id, ch.Type) + p.recordSaturation(ch, payload, user) + return false + } +} + +func (p *Pool) recordSaturation(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) { + rec := newRecord(ch, payload, user, p.nowFn()) + rec.Success = false + rec.Error = "dispatch queue saturated" + if err := p.recordStore.CreateRecord(rec); err != nil { + log.Printf("notification: failed to log saturation record: %v", err) + } +} + +// sendAndRecord runs the sender and writes an audit record for the result. +func (p *Pool) sendAndRecord(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) { + rec := newRecord(ch, payload, user, p.nowFn()) + + if err := p.runSend(ch, payload); err != nil { + log.Printf("notification: failed to send via %q channel %s: %v", ch.Type, ch.Id, err) + rec.Success = false + rec.Error = truncateError(err.Error()) + } else { + rec.Success = true + } + + if err := p.recordStore.CreateRecord(rec); err != nil { + log.Printf("notification: failed to log record: %v", err) + } +} + +func (p *Pool) runSend(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload) error { + sender, ok := p.registry.Get(ch.Type) + if !ok { + return fmt.Errorf("no sender for channel type %q", ch.Type) + } + cfg, err := sender.DecodeConfig(ch.Config) + if err != nil { + return fmt.Errorf("invalid config for channel %s: %w", ch.Id, err) + } + ctx, cancel := context.WithTimeout(context.Background(), sendTimeout) + defer cancel() + return sender.Send(ctx, cfg, payload) +} + +// newRecord stamps a NotificationRecord from a job. Success/Error are filled +// in by the caller after the actual send attempt. +func newRecord(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User, sentAt time.Time) *happydns.NotificationRecord { + return &happydns.NotificationRecord{ + UserId: user.Id, + ChannelType: ch.Type, + ChannelId: ch.Id, + CheckerID: payload.CheckerID, + Target: payload.Target, + OldStatus: payload.OldStatus, + NewStatus: payload.NewStatus, + SentAt: sentAt, + } +} diff --git a/internal/usecase/notification/resolver.go b/internal/usecase/notification/resolver.go new file mode 100644 index 00000000..9e802ff4 --- /dev/null +++ b/internal/usecase/notification/resolver.go @@ -0,0 +1,97 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "log" + + "git.happydns.org/happyDomain/model" +) + +// Resolver picks the active preference for a target and the channels that +// should carry an alert for that preference. It is read-only and depends on +// no transient state, so it is cheap to share between goroutines. +type Resolver struct { + channelStore NotificationChannelStorage + prefStore NotificationPreferenceStorage +} + +// NewResolver builds a Resolver bound to the channel and preference stores. +func NewResolver(channelStore NotificationChannelStorage, prefStore NotificationPreferenceStorage) *Resolver { + return &Resolver{channelStore: channelStore, prefStore: prefStore} +} + +// ResolvePreference returns the most specific preference for the target. +// Specificity is service > domain > global. When the user has no matching +// preference, a synthesized default (see happydns.DefaultNotificationPreference) +// is returned so opt-in defaults flow through the policy unchanged. +func (r *Resolver) ResolvePreference(user *happydns.User, target happydns.CheckTarget) *happydns.NotificationPreference { + prefs, err := r.prefStore.ListPreferencesByUser(user.Id) + if err != nil { + log.Printf("notification: failed to load preferences for user %s: %v", user.Id, err) + return happydns.DefaultNotificationPreference() + } + + var best *happydns.NotificationPreference + bestSpecificity := -1 + for _, p := range prefs { + s := p.MatchesTarget(target) + if s > bestSpecificity { + best = p + bestSpecificity = s + } + } + if best == nil { + return happydns.DefaultNotificationPreference() + } + return best +} + +// ResolveChannels returns the user's enabled channels, narrowed to the +// preference's allow-list when one is configured. +func (r *Resolver) ResolveChannels(user *happydns.User, pref *happydns.NotificationPreference) []*happydns.NotificationChannel { + allChannels, err := r.channelStore.ListChannelsByUser(user.Id) + if err != nil { + log.Printf("notification: failed to load channels for user %s: %v", user.Id, err) + return nil + } + + var allowed map[string]bool + if len(pref.ChannelIds) > 0 { + allowed = make(map[string]bool, len(pref.ChannelIds)) + for _, id := range pref.ChannelIds { + allowed[id.String()] = true + } + } + + var result []*happydns.NotificationChannel + for _, ch := range allChannels { + if !ch.Enabled { + continue + } + if allowed != nil && !allowed[ch.Id.String()] { + continue + } + result = append(result, ch) + } + return result +} diff --git a/internal/usecase/notification/resolver_test.go b/internal/usecase/notification/resolver_test.go new file mode 100644 index 00000000..f86e7afb --- /dev/null +++ b/internal/usecase/notification/resolver_test.go @@ -0,0 +1,98 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "errors" + "testing" + + "git.happydns.org/happyDomain/model" +) + +type fakePrefStore struct { + prefs []*happydns.NotificationPreference + err error +} + +func (f *fakePrefStore) ListPreferencesByUser(_ happydns.Identifier) ([]*happydns.NotificationPreference, error) { + return f.prefs, f.err +} +func (f *fakePrefStore) GetPreference(_ happydns.Identifier) (*happydns.NotificationPreference, error) { + return nil, nil +} +func (f *fakePrefStore) CreatePreference(_ *happydns.NotificationPreference) error { return nil } +func (f *fakePrefStore) UpdatePreference(_ *happydns.NotificationPreference) error { return nil } +func (f *fakePrefStore) DeletePreference(_ happydns.Identifier) error { return nil } + +func TestResolvePreferenceFallsBackToDefault(t *testing.T) { + user := &happydns.User{Id: happydns.Identifier{1}} + target := happydns.CheckTarget{UserId: user.Id.String(), DomainId: "dom-1"} + + t.Run("no preferences returns opt-in default", func(t *testing.T) { + r := NewResolver(nil, &fakePrefStore{}) + got := r.ResolvePreference(user, target) + if got == nil { + t.Fatal("expected default preference, got nil") + } + if !got.Enabled || got.MinStatus != happydns.StatusWarn { + t.Errorf("default not opt-in: %+v", got) + } + }) + + t.Run("store error returns default", func(t *testing.T) { + r := NewResolver(nil, &fakePrefStore{err: errors.New("boom")}) + if got := r.ResolvePreference(user, target); got == nil || !got.Enabled { + t.Errorf("expected enabled default on store error, got %+v", got) + } + }) + + t.Run("matching preference wins over default", func(t *testing.T) { + domId, _ := happydns.NewIdentifierFromString("dom-1") + user := &happydns.User{Id: happydns.Identifier{1}} + userPref := &happydns.NotificationPreference{ + DomainId: &domId, + Enabled: true, + MinStatus: happydns.StatusCrit, + } + r := NewResolver(nil, &fakePrefStore{prefs: []*happydns.NotificationPreference{userPref}}) + got := r.ResolvePreference(user, happydns.CheckTarget{DomainId: domId.String()}) + if got != userPref { + t.Errorf("expected user preference, got %+v", got) + } + }) + + t.Run("non-matching scoped preference falls back to default", func(t *testing.T) { + otherDom, _ := happydns.NewIdentifierFromString("dom-other") + userPref := &happydns.NotificationPreference{ + DomainId: &otherDom, + Enabled: true, + } + r := NewResolver(nil, &fakePrefStore{prefs: []*happydns.NotificationPreference{userPref}}) + got := r.ResolvePreference(user, happydns.CheckTarget{DomainId: "dom-1"}) + if got == nil || got == userPref { + t.Errorf("expected default fallback, got %+v", got) + } + if !got.Enabled || got.MinStatus != happydns.StatusWarn { + t.Errorf("default not opt-in: %+v", got) + } + }) +} diff --git a/internal/usecase/notification/state_lock.go b/internal/usecase/notification/state_lock.go new file mode 100644 index 00000000..1928214d --- /dev/null +++ b/internal/usecase/notification/state_lock.go @@ -0,0 +1,95 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "fmt" + "sync" + + "git.happydns.org/happyDomain/model" +) + +// StateLocker serialises read-modify-write sequences against +// NotificationStateStorage on a per-(checker, target, user) basis. The +// dispatcher and the AckService both update state for the same key; without a +// shared lock, two concurrent updates would race and the last writer wins, +// which can wipe an acknowledgement or fire a duplicate notification. +// +// The lock is in-process; multi-replica deployments would need the storage +// layer itself to provide CAS or per-key serialisation. happyDomain runs as a +// single instance, so this is sufficient and is the cheapest correct option. +type StateLocker struct { + mu sync.Mutex + locks map[string]*stateLockEntry +} + +type stateLockEntry struct { + mu sync.Mutex + refCount int +} + +// NewStateLocker returns a fresh locker with no contended keys. +func NewStateLocker() *StateLocker { + return &StateLocker{locks: make(map[string]*stateLockEntry)} +} + +// Lock acquires the per-key mutex and returns an unlock function. Always defer +// the returned function — leaking it would leave the entry pinned in the map. +func (l *StateLocker) Lock(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) func() { + key := stateLockKey(checkerID, target, userId) + + l.mu.Lock() + entry, ok := l.locks[key] + if !ok { + entry = &stateLockEntry{} + l.locks[key] = entry + } + entry.refCount++ + l.mu.Unlock() + + entry.mu.Lock() + + return func() { + entry.mu.Unlock() + + l.mu.Lock() + entry.refCount-- + if entry.refCount == 0 { + delete(l.locks, key) + } + l.mu.Unlock() + } +} + +// stateLockKey mirrors the storage key shape for notification state, so that +// the dispatcher and the AckService end up serialised on the exact tuple they +// will read/write. Mismatch here would silently re-introduce the race. +func stateLockKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string { + return fmt.Sprintf( + "%s|%s|%s/%s/%s", + userId.String(), + checkerID, + target.UserId, + target.DomainId, + target.ServiceId, + ) +} diff --git a/internal/usecase/notification/tester.go b/internal/usecase/notification/tester.go new file mode 100644 index 00000000..52976760 --- /dev/null +++ b/internal/usecase/notification/tester.go @@ -0,0 +1,57 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notification + +import ( + "context" + + notifPkg "git.happydns.org/happyDomain/internal/notification" + "git.happydns.org/happyDomain/model" +) + +// Tester sends synthetic notifications synchronously. It bypasses preferences, +// state, quiet hours, and the worker pool by design: the user explicitly asked +// to verify a single channel and wants the result inline. +type Tester struct { + registry *notifPkg.Registry +} + +// NewTester builds a Tester bound to the sender registry. +func NewTester(registry *notifPkg.Registry) *Tester { + return &Tester{registry: registry} +} + +// Send delivers a test notification through the given channel and returns the +// sender error directly so the caller (typically the API) can surface it. +func (t *Tester) Send(ch *happydns.NotificationChannel, user *happydns.User) error { + sender, ok := t.registry.Get(ch.Type) + if !ok { + return notifPkg.ErrUnknownChannelType + } + cfg, err := sender.DecodeConfig(ch.Config) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), sendTimeout) + defer cancel() + return sender.SendTest(ctx, cfg, user) +} From 6f1d3432ffc12684b3679956cd18b039aff5f833 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 11 Apr 2026 08:49:29 +0700 Subject: [PATCH 07/10] checker: add execution callback for notification integration --- internal/usecase/checker/checker_engine.go | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/usecase/checker/checker_engine.go b/internal/usecase/checker/checker_engine.go index 656f538d..04030b2d 100644 --- a/internal/usecase/checker/checker_engine.go +++ b/internal/usecase/checker/checker_engine.go @@ -26,12 +26,24 @@ import ( "encoding/json" "fmt" "log" + "sync/atomic" "time" checkerPkg "git.happydns.org/happyDomain/internal/checker" "git.happydns.org/happyDomain/model" ) +// executionCallback is the signature stored under onComplete. Wrapping it in a +// named type lets us hold it via atomic.Pointer without leaking the function +// type spelling everywhere. +type executionCallback func(*happydns.Execution, *happydns.CheckEvaluation) + +// ExecutionCallbackSetter is implemented by checker engines that support +// notification callbacks after execution completion. +type ExecutionCallbackSetter interface { + SetExecutionCallback(func(*happydns.Execution, *happydns.CheckEvaluation)) +} + // checkerEngine implements the happydns.CheckerEngine interface. type checkerEngine struct { optionsUC *CheckerOptionsUsecase @@ -42,6 +54,21 @@ type checkerEngine struct { entryStore DiscoveryEntryStorage obsRefStore DiscoveryObservationStorage relatedLookup checkerPkg.RelatedObservationLookup + + // onComplete is read concurrently by RunExecution from worker goroutines + // while SetExecutionCallback writes it during app wiring (and potentially + // later, defensively). atomic.Pointer keeps the load/store race-free. + onComplete atomic.Pointer[executionCallback] +} + +// SetExecutionCallback registers a callback invoked after each successful execution. +func (e *checkerEngine) SetExecutionCallback(cb func(*happydns.Execution, *happydns.CheckEvaluation)) { + if cb == nil { + e.onComplete.Store(nil) + return + } + wrapped := executionCallback(cb) + e.onComplete.Store(&wrapped) } // NewCheckerEngine creates a new CheckerEngine implementation. Passing nil @@ -145,6 +172,13 @@ func (e *checkerEngine) RunExecution(ctx context.Context, exec *happydns.Executi log.Printf("CheckerEngine: failed to update execution: %v", err) } + // Fire notification callback. The callback decides synchronously whether + // to notify and advances state, but actual sender invocations are + // dispatched to a worker pool so a slow channel cannot wedge the engine. + if cb := e.onComplete.Load(); cb != nil { + (*cb)(exec, eval) + } + return eval, nil } From d997cb5ed3d66c5affe0c7d1e12f441fbd66757f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 11 Apr 2026 08:55:00 +0700 Subject: [PATCH 08/10] notification: add API endpoints for channels, preferences, history, and acknowledgement --- internal/api/controller/notification.go | 514 +++++++++++++++++++ internal/api/middleware/notification.go | 124 +++++ internal/api/route/checker.go | 9 +- internal/api/route/domain.go | 4 +- internal/api/route/notification.go | 103 ++++ internal/api/route/route.go | 22 + internal/api/route/service.go | 3 +- internal/api/route/zone.go | 2 + internal/app/app.go | 48 ++ internal/usecase/notification/acknowledge.go | 27 +- 10 files changed, 841 insertions(+), 15 deletions(-) create mode 100644 internal/api/controller/notification.go create mode 100644 internal/api/middleware/notification.go create mode 100644 internal/api/route/notification.go diff --git a/internal/api/controller/notification.go b/internal/api/controller/notification.go new file mode 100644 index 00000000..76cde9e7 --- /dev/null +++ b/internal/api/controller/notification.go @@ -0,0 +1,514 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package controller + +import ( + "errors" + "fmt" + "log" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + notifPkg "git.happydns.org/happyDomain/internal/notification" + notifUC "git.happydns.org/happyDomain/internal/usecase/notification" + "git.happydns.org/happyDomain/model" +) + +// maxHistoryLimit caps the ?limit= query parameter on ListHistory. The store +// loads all matching records into memory before slicing, so an unbounded limit +// is a trivial DoS vector. +const maxHistoryLimit = 500 + +// maxAnnotationLength caps the user-supplied annotation persisted with an +// acknowledgement. The string is stored in NotificationState and rendered in +// the UI; an unbounded value bloats state and is a DoS vector. +const maxAnnotationLength = 1024 + +// internalError logs the underlying error and returns a generic 500 to the +// client. Storage errors can contain keys and other implementation details; +// echoing them back leaks information. +func internalError(c *gin.Context, err error) { + log.Printf("notification controller: %v", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{ + Message: "internal server error", + }) +} + +// NotificationController handles notification-related API endpoints. +type NotificationController struct { + dispatcher *notifUC.Dispatcher + registry *notifPkg.Registry + channelStore notifUC.NotificationChannelStorage + prefStore notifUC.NotificationPreferenceStorage + recordStore notifUC.NotificationRecordStorage +} + +// NewNotificationController creates a new NotificationController. +func NewNotificationController( + dispatcher *notifUC.Dispatcher, + registry *notifPkg.Registry, + channelStore notifUC.NotificationChannelStorage, + prefStore notifUC.NotificationPreferenceStorage, + recordStore notifUC.NotificationRecordStorage, +) *NotificationController { + return &NotificationController{ + dispatcher: dispatcher, + registry: registry, + channelStore: channelStore, + prefStore: prefStore, + recordStore: recordStore, + } +} + +// ListChannelTypes returns the channel types registered by the server, so the +// UI knows which transports are available without hardcoding the list. +// +// @Summary List supported notification channel types +// @Tags notifications +// @Produce json +// @Success 200 {array} string +// @Router /notifications/channel-types [get] +func (nc *NotificationController) ListChannelTypes(c *gin.Context) { + c.JSON(http.StatusOK, nc.registry.Types()) +} + +// --- Channel CRUD --- + +// ListChannels returns all notification channels for the authenticated user. +// +// @Summary List notification channels +// @Tags notifications +// @Produce json +// @Success 200 {array} happydns.NotificationChannel +// @Router /notifications/channels [get] +func (nc *NotificationController) ListChannels(c *gin.Context) { + user := middleware.MyUser(c) + channels, err := nc.channelStore.ListChannelsByUser(user.Id) + if err != nil { + internalError(c, err) + return + } + redacted, err := nc.registry.RedactChannels(channels) + if err != nil { + internalError(c, err) + return + } + if redacted == nil { + redacted = []*happydns.NotificationChannel{} + } + c.JSON(http.StatusOK, redacted) +} + +// CreateChannel creates a new notification channel. +// +// @Summary Create a notification channel +// @Tags notifications +// @Accept json +// @Produce json +// @Param body body happydns.NotificationChannel true "Channel configuration" +// @Success 201 {object} happydns.NotificationChannel +// @Router /notifications/channels [post] +func (nc *NotificationController) CreateChannel(c *gin.Context) { + user := middleware.MyUser(c) + + var ch happydns.NotificationChannel + if err := c.ShouldBindJSON(&ch); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + ch.UserId = user.Id + + if _, err := nc.registry.DecodeChannelConfig(&ch); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + if err := nc.channelStore.CreateChannel(&ch); err != nil { + internalError(c, err) + return + } + + redacted, err := nc.registry.RedactChannel(&ch) + if err != nil { + internalError(c, err) + return + } + c.JSON(http.StatusCreated, redacted) +} + +// GetChannel returns a specific notification channel. +// +// @Summary Get a notification channel +// @Tags notifications +// @Produce json +// @Param channelId path string true "Channel ID" +// @Success 200 {object} happydns.NotificationChannel +// @Router /notifications/channels/{channelId} [get] +func (nc *NotificationController) GetChannel(c *gin.Context) { + redacted, err := nc.registry.RedactChannel(middleware.MyNotificationChannel(c)) + if err != nil { + internalError(c, err) + return + } + c.JSON(http.StatusOK, redacted) +} + +// UpdateChannel updates a notification channel. Fields absent from the request +// body are preserved from the stored channel, so a PATCH-style partial update +// does not silently zero out unrelated fields (e.g. omitting "enabled" must +// not disable the channel). +// +// @Summary Update a notification channel +// @Tags notifications +// @Accept json +// @Produce json +// @Param channelId path string true "Channel ID" +// @Param body body happydns.NotificationChannel true "Channel configuration" +// @Success 200 {object} happydns.NotificationChannel +// @Router /notifications/channels/{channelId} [put] +func (nc *NotificationController) UpdateChannel(c *gin.Context) { + existing := middleware.MyNotificationChannel(c) + + // Bind into a copy of the stored channel so json.Unmarshal only overwrites + // fields present in the body. Identity fields are then forced back to the + // stored values, regardless of what the client sent. + ch := *existing + if err := c.ShouldBindJSON(&ch); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + ch.Id = existing.Id + ch.UserId = existing.UserId + + // Carry forward stored secrets that the redacted GET response could not + // expose to the client, so a round-trip GET → PUT does not silently wipe + // them. Sender-specific behaviour lives in ConfigMerger. + merged, err := nc.registry.MergeChannelForUpdate(existing, &ch) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + ch.Config = merged + + if _, err := nc.registry.DecodeChannelConfig(&ch); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + if err := nc.channelStore.UpdateChannel(&ch); err != nil { + internalError(c, err) + return + } + + redacted, err := nc.registry.RedactChannel(&ch) + if err != nil { + internalError(c, err) + return + } + c.JSON(http.StatusOK, redacted) +} + +// DeleteChannel deletes a notification channel. +// +// @Summary Delete a notification channel +// @Tags notifications +// @Param channelId path string true "Channel ID" +// @Success 204 +// @Router /notifications/channels/{channelId} [delete] +func (nc *NotificationController) DeleteChannel(c *gin.Context) { + ch := middleware.MyNotificationChannel(c) + + if err := nc.channelStore.DeleteChannel(ch.Id); err != nil { + internalError(c, err) + return + } + + c.Status(http.StatusNoContent) +} + +// TestChannel sends a test notification through a channel. +// +// @Summary Send a test notification +// @Tags notifications +// @Param channelId path string true "Channel ID" +// @Success 200 {object} map[string]string +// @Router /notifications/channels/{channelId}/test [post] +func (nc *NotificationController) TestChannel(c *gin.Context) { + user := middleware.MyUser(c) + ch := middleware.MyNotificationChannel(c) + + if err := nc.dispatcher.SendTestNotification(ch, user); err != nil { + internalError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"}) +} + +// --- Preference CRUD --- + +// validateQuietHours rejects out-of-range quiet-hour values. The fields are +// optional pointers; nil is allowed. A 0–23 hour is required when set. +func validateQuietHours(p *happydns.NotificationPreference) error { + if p.QuietStart != nil && (*p.QuietStart < 0 || *p.QuietStart > 23) { + return fmt.Errorf("quietStart must be between 0 and 23") + } + if p.QuietEnd != nil && (*p.QuietEnd < 0 || *p.QuietEnd > 23) { + return fmt.Errorf("quietEnd must be between 0 and 23") + } + return nil +} + +// ListPreferences returns all notification preferences for the authenticated user. +// +// @Summary List notification preferences +// @Tags notifications +// @Produce json +// @Success 200 {array} happydns.NotificationPreference +// @Router /notifications/preferences [get] +func (nc *NotificationController) ListPreferences(c *gin.Context) { + user := middleware.MyUser(c) + prefs, err := nc.prefStore.ListPreferencesByUser(user.Id) + if err != nil { + internalError(c, err) + return + } + if prefs == nil { + prefs = []*happydns.NotificationPreference{} + } + c.JSON(http.StatusOK, prefs) +} + +// CreatePreference creates a new notification preference. +// +// @Summary Create a notification preference +// @Tags notifications +// @Accept json +// @Produce json +// @Param body body happydns.NotificationPreference true "Preference configuration" +// @Success 201 {object} happydns.NotificationPreference +// @Router /notifications/preferences [post] +func (nc *NotificationController) CreatePreference(c *gin.Context) { + user := middleware.MyUser(c) + + var pref happydns.NotificationPreference + if err := c.ShouldBindJSON(&pref); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + pref.UserId = user.Id + + if err := validateQuietHours(&pref); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + if err := nc.prefStore.CreatePreference(&pref); err != nil { + internalError(c, err) + return + } + + c.JSON(http.StatusCreated, pref) +} + +// GetPreference returns a specific notification preference. +// +// @Summary Get a notification preference +// @Tags notifications +// @Produce json +// @Param prefId path string true "Preference ID" +// @Success 200 {object} happydns.NotificationPreference +// @Router /notifications/preferences/{prefId} [get] +func (nc *NotificationController) GetPreference(c *gin.Context) { + c.JSON(http.StatusOK, middleware.MyNotificationPreference(c)) +} + +// UpdatePreference updates a notification preference. Fields absent from the +// request body are preserved from the stored preference (see UpdateChannel). +// +// @Summary Update a notification preference +// @Tags notifications +// @Accept json +// @Produce json +// @Param prefId path string true "Preference ID" +// @Param body body happydns.NotificationPreference true "Preference configuration" +// @Success 200 {object} happydns.NotificationPreference +// @Router /notifications/preferences/{prefId} [put] +func (nc *NotificationController) UpdatePreference(c *gin.Context) { + existing := middleware.MyNotificationPreference(c) + + pref := *existing + if err := c.ShouldBindJSON(&pref); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + pref.Id = existing.Id + pref.UserId = existing.UserId + + if err := validateQuietHours(&pref); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + if err := nc.prefStore.UpdatePreference(&pref); err != nil { + internalError(c, err) + return + } + + c.JSON(http.StatusOK, pref) +} + +// DeletePreference deletes a notification preference. +// +// @Summary Delete a notification preference +// @Tags notifications +// @Param prefId path string true "Preference ID" +// @Success 204 +// @Router /notifications/preferences/{prefId} [delete] +func (nc *NotificationController) DeletePreference(c *gin.Context) { + pref := middleware.MyNotificationPreference(c) + + if err := nc.prefStore.DeletePreference(pref.Id); err != nil { + internalError(c, err) + return + } + + c.Status(http.StatusNoContent) +} + +// --- History --- + +// ListHistory returns recent notification records for the authenticated user. +// +// @Summary List notification history +// @Tags notifications +// @Produce json +// @Param limit query int false "Maximum number of records (capped at 500)" default(50) +// @Success 200 {array} happydns.NotificationRecord +// @Router /notifications/history [get] +func (nc *NotificationController) ListHistory(c *gin.Context) { + user := middleware.MyUser(c) + + limit := 50 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + if limit > maxHistoryLimit { + limit = maxHistoryLimit + } + + records, err := nc.recordStore.ListRecordsByUser(user.Id, limit) + if err != nil { + internalError(c, err) + return + } + if records == nil { + records = []*happydns.NotificationRecord{} + } + c.JSON(http.StatusOK, records) +} + +// --- Acknowledgement --- + +// AcknowledgeIssue marks a checker issue as acknowledged. +// +// @Summary Acknowledge a checker issue +// @Tags checkers +// @Accept json +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param checkerId path string true "Checker ID" +// @Param body body happydns.AcknowledgeRequest true "Acknowledgement" +// @Success 200 {object} happydns.NotificationState +// @Router /domains/{domain}/checkers/{checkerId}/acknowledge [post] +func (nc *NotificationController) AcknowledgeIssue(c *gin.Context) { + user := middleware.MyUser(c) + target := targetFromContext(c) + checkerID := c.Param("checkerId") + + var req happydns.AcknowledgeRequest + if err := c.ShouldBindJSON(&req); err != nil { + // Body is optional for acknowledgement. + req = happydns.AcknowledgeRequest{} + } + if len(req.Annotation) > maxAnnotationLength { + req.Annotation = req.Annotation[:maxAnnotationLength] + } + + if err := nc.dispatcher.AcknowledgeIssue(user.Id, checkerID, target, user.Email, req.Annotation); err != nil { + if errors.Is(err, happydns.ErrNotificationStateNotFound) { + middleware.ErrorResponse(c, http.StatusNotFound, err) + return + } + internalError(c, err) + return + } + + state, err := nc.dispatcher.GetState(user.Id, checkerID, target) + if err != nil { + internalError(c, err) + return + } + + c.JSON(http.StatusOK, state) +} + +// ClearAcknowledgement removes an acknowledgement from a checker issue. +// +// @Summary Clear acknowledgement +// @Tags checkers +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param checkerId path string true "Checker ID" +// @Success 200 {object} happydns.NotificationState +// @Router /domains/{domain}/checkers/{checkerId}/acknowledge [delete] +func (nc *NotificationController) ClearAcknowledgement(c *gin.Context) { + user := middleware.MyUser(c) + target := targetFromContext(c) + checkerID := c.Param("checkerId") + + if err := nc.dispatcher.ClearAcknowledgement(user.Id, checkerID, target); err != nil { + if errors.Is(err, happydns.ErrNotificationStateNotFound) { + middleware.ErrorResponse(c, http.StatusNotFound, err) + return + } + internalError(c, err) + return + } + + state, err := nc.dispatcher.GetState(user.Id, checkerID, target) + if err != nil { + internalError(c, err) + return + } + + c.JSON(http.StatusOK, state) +} diff --git a/internal/api/middleware/notification.go b/internal/api/middleware/notification.go new file mode 100644 index 00000000..dda4bfb3 --- /dev/null +++ b/internal/api/middleware/notification.go @@ -0,0 +1,124 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package middleware + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + notifUC "git.happydns.org/happyDomain/internal/usecase/notification" + "git.happydns.org/happyDomain/model" +) + +const ( + ctxKeyNotificationChannel = "notification_channel" + ctxKeyNotificationPreference = "notification_preference" +) + +// NotificationChannelHandler resolves :channelId, ensures it exists and is +// owned by the authenticated user, and exposes it via MyNotificationChannel. +// Centralizing the ownership check here removes a latent bug class: any new +// per-channel endpoint cannot forget to enforce it. +func NotificationChannelHandler(store notifUC.NotificationChannelStorage) gin.HandlerFunc { + return func(c *gin.Context) { + user := MyUser(c) + if user == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "User not defined."}) + return + } + + channelId, err := happydns.NewIdentifierFromString(c.Param("channelId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid channel ID"}) + return + } + + ch, err := store.GetChannel(channelId) + if err != nil { + if errors.Is(err, happydns.ErrNotificationChannelNotFound) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"}) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + if !ch.UserId.Equals(user.Id) { + // 404 not 403: do not leak the existence of channels owned by others. + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"}) + return + } + + c.Set(ctxKeyNotificationChannel, ch) + c.Next() + } +} + +// MyNotificationChannel returns the channel resolved by NotificationChannelHandler. +// Panics if the middleware was not installed on the route — this is a wiring +// bug, not a runtime condition. +func MyNotificationChannel(c *gin.Context) *happydns.NotificationChannel { + return c.MustGet(ctxKeyNotificationChannel).(*happydns.NotificationChannel) +} + +// NotificationPreferenceHandler resolves :prefId with the same contract as +// NotificationChannelHandler. +func NotificationPreferenceHandler(store notifUC.NotificationPreferenceStorage) gin.HandlerFunc { + return func(c *gin.Context) { + user := MyUser(c) + if user == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "User not defined."}) + return + } + + prefId, err := happydns.NewIdentifierFromString(c.Param("prefId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid preference ID"}) + return + } + + pref, err := store.GetPreference(prefId) + if err != nil { + if errors.Is(err, happydns.ErrNotificationPreferenceNotFound) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"}) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + if !pref.UserId.Equals(user.Id) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"}) + return + } + + c.Set(ctxKeyNotificationPreference, pref) + c.Next() + } +} + +// MyNotificationPreference returns the preference resolved by NotificationPreferenceHandler. +func MyNotificationPreference(c *gin.Context) *happydns.NotificationPreference { + return c.MustGet(ctxKeyNotificationPreference).(*happydns.NotificationPreference) +} diff --git a/internal/api/route/checker.go b/internal/api/route/checker.go index 19cfa4a5..c08e85c5 100644 --- a/internal/api/route/checker.go +++ b/internal/api/route/checker.go @@ -68,7 +68,8 @@ func DeclareCheckerRoutes( // DeclareScopedCheckerRoutes registers checker routes scoped to a domain or service. // Called for both /api/domains/:domain/checkers and .../services/:serviceid/checkers. -func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController) { +// nc may be nil if the notification system is not configured. +func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController, nc *controller.NotificationController) { checkers := scopedRouter.Group("/checkers") checkers.GET("", cc.ListAvailableChecks) checkers.GET("/metrics", cc.GetDomainMetrics) @@ -113,4 +114,10 @@ func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.Ch // Results (under execution). executionID.GET("/results", cc.GetExecutionResults) executionID.GET("/results/:ruleName", cc.GetExecutionResult) + + // Acknowledgement (requires notification system). + if nc != nil { + checkerID.POST("/acknowledge", nc.AcknowledgeIssue) + checkerID.DELETE("/acknowledge", nc.ClearAcknowledgement) + } } diff --git a/internal/api/route/domain.go b/internal/api/route/domain.go index c5586f27..523c2c4a 100644 --- a/internal/api/route/domain.go +++ b/internal/api/route/domain.go @@ -43,6 +43,7 @@ func DeclareDomainRoutes( cc *controller.CheckerController, checkStatusUC *checkerUC.CheckStatusUsecase, domainInfoUC happydns.DomainInfoUsecase, + nc *controller.NotificationController, ) { dc := controller.NewDomainController( domainUC, @@ -72,7 +73,7 @@ func DeclareDomainRoutes( // Mount domain-scoped checker routes. if cc != nil { - DeclareScopedCheckerRoutes(apiDomainsRoutes, cc) + DeclareScopedCheckerRoutes(apiDomainsRoutes, cc, nc) } DeclareZoneRoutes( @@ -83,5 +84,6 @@ func DeclareDomainRoutes( zoneServiceUC, serviceUC, cc, + nc, ) } diff --git a/internal/api/route/notification.go b/internal/api/route/notification.go new file mode 100644 index 00000000..7e01f9e7 --- /dev/null +++ b/internal/api/route/notification.go @@ -0,0 +1,103 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package route + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + ratelimit "github.com/JGLTechnologies/gin-rate-limit" + + "git.happydns.org/happyDomain/internal/api/controller" + "git.happydns.org/happyDomain/internal/api/middleware" + notifPkg "git.happydns.org/happyDomain/internal/notification" + notifUC "git.happydns.org/happyDomain/internal/usecase/notification" + happydns "git.happydns.org/happyDomain/model" +) + +// DeclareNotificationRoutes registers notification routes under /api/notifications. +func DeclareNotificationRoutes( + apiAuthRoutes *gin.RouterGroup, + dispatcher *notifUC.Dispatcher, + registry *notifPkg.Registry, + channelStore notifUC.NotificationChannelStorage, + prefStore notifUC.NotificationPreferenceStorage, + recordStore notifUC.NotificationRecordStorage, +) *controller.NotificationController { + nc := controller.NewNotificationController(dispatcher, registry, channelStore, prefStore, recordStore) + + notif := apiAuthRoutes.Group("/notifications") + + // Channel types (advertised by the registry). + notif.GET("/channel-types", nc.ListChannelTypes) + + // Channels + channels := notif.Group("/channels") + channels.GET("", nc.ListChannels) + channels.POST("", nc.CreateChannel) + + channelID := channels.Group("/:channelId", middleware.NotificationChannelHandler(channelStore)) + channelID.GET("", nc.GetChannel) + channelID.PUT("", nc.UpdateChannel) + channelID.DELETE("", nc.DeleteChannel) + + // TestChannel triggers an outbound request — webhook/email/UnifiedPush — + // per call. Without throttling, an authenticated user could spam any + // configured endpoint at line rate. Rate-limit per user (the channel is + // owned by the user; spreading it by IP would be looser than necessary). + testRLStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{ + Rate: time.Minute, + Limit: 5, + }) + testRLMiddleware := ratelimit.RateLimiter(testRLStore, &ratelimit.Options{ + ErrorHandler: func(c *gin.Context, info ratelimit.Info) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, happydns.ErrorResponse{ + Message: "Too many test notifications. Please try again later.", + }) + }, + KeyFunc: func(c *gin.Context) string { + user := middleware.MyUser(c) + if user == nil { + return c.ClientIP() + } + return user.Id.String() + }, + }) + channelID.POST("/test", testRLMiddleware, nc.TestChannel) + + // Preferences + prefs := notif.Group("/preferences") + prefs.GET("", nc.ListPreferences) + prefs.POST("", nc.CreatePreference) + + prefID := prefs.Group("/:prefId", middleware.NotificationPreferenceHandler(prefStore)) + prefID.GET("", nc.GetPreference) + prefID.PUT("", nc.UpdatePreference) + prefID.DELETE("", nc.DeletePreference) + + // History + notif.GET("/history", nc.ListHistory) + + return nc +} + diff --git a/internal/api/route/route.go b/internal/api/route/route.go index 8f17d3d8..4741171d 100644 --- a/internal/api/route/route.go +++ b/internal/api/route/route.go @@ -31,7 +31,9 @@ import ( "git.happydns.org/happyDomain/internal/api/controller" "git.happydns.org/happyDomain/internal/api/middleware" + notifPkg "git.happydns.org/happyDomain/internal/notification" checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + notifUC "git.happydns.org/happyDomain/internal/usecase/notification" happydns "git.happydns.org/happyDomain/model" ) @@ -67,6 +69,12 @@ type Dependencies struct { PlannedProvider checkerUC.PlannedJobProvider BudgetChecker checkerUC.BudgetChecker CountManualTriggers bool + + NotificationDispatcher *notifUC.Dispatcher + NotificationRegistry *notifPkg.Registry + NotificationChannels notifUC.NotificationChannelStorage + NotificationPrefs notifUC.NotificationPreferenceStorage + NotificationRecords notifUC.NotificationRecordStorage } // @title happyDomain API @@ -154,6 +162,19 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc ) } + // Initialize notification controller if dispatcher is available. + var nc *controller.NotificationController + if dep.NotificationDispatcher != nil { + nc = DeclareNotificationRoutes( + apiAuthRoutes, + dep.NotificationDispatcher, + dep.NotificationRegistry, + dep.NotificationChannels, + dep.NotificationPrefs, + dep.NotificationRecords, + ) + } + DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc) DeclareDomainRoutes( apiAuthRoutes, @@ -168,6 +189,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc cc, dep.CheckStatusUC, dep.DomainInfo, + nc, ) DeclareProviderRoutes(apiAuthRoutes, dep.Provider) DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings) diff --git a/internal/api/route/service.go b/internal/api/route/service.go index ded089a8..0690099f 100644 --- a/internal/api/route/service.go +++ b/internal/api/route/service.go @@ -37,6 +37,7 @@ func DeclareZoneServiceRoutes( serviceUC happydns.ServiceUsecase, zoneUC happydns.ZoneUsecase, cc *controller.CheckerController, + nc *controller.NotificationController, ) { sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC) @@ -51,6 +52,6 @@ func DeclareZoneServiceRoutes( // Mount service-scoped checker routes. if cc != nil { - DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc) + DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc, nc) } } diff --git a/internal/api/route/zone.go b/internal/api/route/zone.go index bb6da44c..c6c90710 100644 --- a/internal/api/route/zone.go +++ b/internal/api/route/zone.go @@ -38,6 +38,7 @@ func DeclareZoneRoutes( zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, cc *controller.CheckerController, + nc *controller.NotificationController, ) { var checkStatusUC *checkerUC.CheckStatusUsecase if cc != nil { @@ -74,6 +75,7 @@ func DeclareZoneRoutes( serviceUC, zoneUC, cc, + nc, ) apiZonesRoutes.POST("/records", zc.AddRecords) diff --git a/internal/app/app.go b/internal/app/app.go index 555f29dc..b179ca60 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -35,6 +35,7 @@ import ( "git.happydns.org/happyDomain/internal/mailer" "git.happydns.org/happyDomain/internal/metrics" "git.happydns.org/happyDomain/internal/newsletter" + notifPkg "git.happydns.org/happyDomain/internal/notification" "git.happydns.org/happyDomain/internal/session" "git.happydns.org/happyDomain/internal/storage" "git.happydns.org/happyDomain/internal/usecase" @@ -43,6 +44,7 @@ import ( domainUC "git.happydns.org/happyDomain/internal/usecase/domain" domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log" emailAutoconfigUC "git.happydns.org/happyDomain/internal/usecase/emailautoconfig" + notifUC "git.happydns.org/happyDomain/internal/usecase/notification" "git.happydns.org/happyDomain/internal/usecase/orchestrator" providerUC "git.happydns.org/happyDomain/internal/usecase/provider" serviceUC "git.happydns.org/happyDomain/internal/usecase/service" @@ -84,6 +86,9 @@ type Usecases struct { checkerScheduler *checkerUC.Scheduler checkerJanitor *checkerUC.Janitor checkerUserGater *checkerUC.UserGater + + notificationDispatcher *notifUC.Dispatcher + notificationRegistry *notifPkg.Registry } type App struct { @@ -331,6 +336,33 @@ func (app *App) initUsecases() { // Wire scheduler notifications for incremental queue updates. domainService.SetSchedulerNotifier(app.usecases.checkerScheduler) app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler) + + // Notification system: dispatcher fans out checker results to user + // channels (email/webhook/UnifiedPush) based on per-target preferences. + baseURL := app.cfg.GetBaseURL() + registry := notifPkg.NewRegistry() + registry.Register(notifPkg.Adapt(notifPkg.NewEmailSender(app.mailer, baseURL))) + registry.Register(notifPkg.Adapt(notifPkg.NewWebhookSender(baseURL))) + registry.Register(notifPkg.Adapt(notifPkg.NewUnifiedPushSender(baseURL))) + app.usecases.notificationRegistry = registry + resolver := notifUC.NewResolver(app.store, app.store) + pool := notifUC.NewPool(registry, app.store) + tester := notifUC.NewTester(registry) + stateLocker := notifUC.NewStateLocker() + ack := notifUC.NewAckService(app.store, stateLocker) + app.usecases.notificationDispatcher = notifUC.NewDispatcher( + app.store, + app.store, + app.store, + resolver, + pool, + tester, + ack, + stateLocker, + ) + if cb, ok := app.usecases.checkerEngine.(checkerUC.ExecutionCallbackSetter); ok { + cb.SetExecutionCallback(app.usecases.notificationDispatcher.OnExecutionComplete) + } } func (app *App) setupRouter() { @@ -386,6 +418,12 @@ func (app *App) setupRouter() { PlannedProvider: app.usecases.checkerScheduler, BudgetChecker: app.usecases.checkerUserGater, CountManualTriggers: app.cfg.CheckerCountManualTriggers, + + NotificationDispatcher: app.usecases.notificationDispatcher, + NotificationRegistry: app.usecases.notificationRegistry, + NotificationChannels: app.store, + NotificationPrefs: app.store, + NotificationRecords: app.store, }, ) web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier) @@ -415,6 +453,10 @@ func (app *App) Start() { app.usecases.checkerUserGater.Start(context.Background()) } + if app.usecases.notificationDispatcher != nil { + app.usecases.notificationDispatcher.Start() + } + log.Printf("Public interface listening on %s\n", app.cfg.Bind) if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %s\n", err) @@ -437,6 +479,12 @@ func (app *App) Stop() { app.usecases.checkerUserGater.Stop() } + // Drain in-flight notification sends after the scheduler is stopped + // so no new jobs can be enqueued while we wait. + if app.usecases.notificationDispatcher != nil { + app.usecases.notificationDispatcher.Stop() + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := app.srv.Shutdown(ctx); err != nil { diff --git a/internal/usecase/notification/acknowledge.go b/internal/usecase/notification/acknowledge.go index b9cdf936..1d601a20 100644 --- a/internal/usecase/notification/acknowledge.go +++ b/internal/usecase/notification/acknowledge.go @@ -22,7 +22,6 @@ package notification import ( - "errors" "time" "git.happydns.org/happyDomain/model" @@ -45,24 +44,25 @@ func NewAckService(stateStore NotificationStateStorage, locker *StateLocker) *Ac return &AckService{stateStore: stateStore, locker: locker, nowFn: time.Now} } -// AcknowledgeIssue marks an issue as acknowledged, suppressing repeat -// notifications until the next state change. +// +// An existing state record (created by the dispatcher when an execution +// completed) is required: acknowledging an issue that the dispatcher has +// never observed is rejected with ErrNotificationStateNotFound. This avoids +// letting an authenticated client materialize arbitrary state records by +// guessing checker IDs or target tuples. func (a *AckService) AcknowledgeIssue(userId happydns.Identifier, checkerID string, target happydns.CheckTarget, acknowledgedBy string, annotation string) error { unlock := a.locker.Lock(checkerID, target, userId) defer unlock() state, err := a.stateStore.GetState(checkerID, target, userId) - if errors.Is(err, happydns.ErrNotificationStateNotFound) { - // Create a new state if one doesn't exist yet. - state = &happydns.NotificationState{ - CheckerID: checkerID, - Target: target, - UserId: userId, - LastStatus: happydns.StatusUnknown, - } - } else if err != nil { + if err != nil { return err } + // Defense in depth: the storage key already encodes userId, but reject any + // state whose stored UserId disagrees rather than silently overwriting. + if !state.UserId.Equals(userId) { + return happydns.ErrNotificationStateNotFound + } now := a.nowFn() state.Acknowledged = true @@ -82,6 +82,9 @@ func (a *AckService) ClearAcknowledgement(userId happydns.Identifier, checkerID if err != nil { return err } + if !state.UserId.Equals(userId) { + return happydns.ErrNotificationStateNotFound + } state.ClearAcknowledgement() return a.stateStore.PutState(state) From 50c8f82d03879d453b995736310813db813d879d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 29 Apr 2026 20:49:05 +0700 Subject: [PATCH 09/10] web: add notification settings UI under /me/notifications --- web/src/lib/api/notifications.ts | 149 +++++++ web/src/lib/locales/en.json | 112 +++++ web/src/lib/stores/notificationTypes.ts | 41 ++ web/src/lib/translations.ts | 1 + web/src/routes/me/+page.svelte | 11 + web/src/routes/me/notifications/+page.svelte | 62 +++ .../me/notifications/ChannelEditor.svelte | 378 +++++++++++++++++ .../me/notifications/ChannelsManager.svelte | 223 ++++++++++ .../me/notifications/HistoryList.svelte | 176 ++++++++ .../me/notifications/PreferenceEditor.svelte | 401 ++++++++++++++++++ .../notifications/PreferencesManager.svelte | 258 +++++++++++ .../routes/me/notifications/channelConfigs.ts | 101 +++++ 12 files changed, 1913 insertions(+) create mode 100644 web/src/lib/api/notifications.ts create mode 100644 web/src/lib/stores/notificationTypes.ts create mode 100644 web/src/routes/me/notifications/+page.svelte create mode 100644 web/src/routes/me/notifications/ChannelEditor.svelte create mode 100644 web/src/routes/me/notifications/ChannelsManager.svelte create mode 100644 web/src/routes/me/notifications/HistoryList.svelte create mode 100644 web/src/routes/me/notifications/PreferenceEditor.svelte create mode 100644 web/src/routes/me/notifications/PreferencesManager.svelte create mode 100644 web/src/routes/me/notifications/channelConfigs.ts diff --git a/web/src/lib/api/notifications.ts b/web/src/lib/api/notifications.ts new file mode 100644 index 00000000..07e550da --- /dev/null +++ b/web/src/lib/api/notifications.ts @@ -0,0 +1,149 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { + getNotificationsChannelTypes, + getNotificationsChannels, + postNotificationsChannels, + getNotificationsChannelsByChannelId, + putNotificationsChannelsByChannelId, + deleteNotificationsChannelsByChannelId, + postNotificationsChannelsByChannelIdTest, + getNotificationsPreferences, + postNotificationsPreferences, + getNotificationsPreferencesByPrefId, + putNotificationsPreferencesByPrefId, + deleteNotificationsPreferencesByPrefId, + getNotificationsHistory, +} from "$lib/api-base/sdk.gen"; +import type { + HappydnsNotificationChannel, + HappydnsNotificationChannelWritable, + HappydnsNotificationPreference, + HappydnsNotificationPreferenceWritable, + HappydnsNotificationRecord, +} from "$lib/api-base/types.gen"; +import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors"; + +// The OpenAPI spec models Config as a free-form object, so the generated +// Writable types omit it. We extend them with Config to keep the wrapper +// callers honest about per-type config payloads. +export type NotificationChannelInput = HappydnsNotificationChannelWritable & { + config?: Record; +}; +export type NotificationChannel = HappydnsNotificationChannel & { + config?: Record; +}; + +export type NotificationPreferenceInput = HappydnsNotificationPreferenceWritable; +export type NotificationPreference = HappydnsNotificationPreference; + +export type NotificationRecord = HappydnsNotificationRecord; + +export async function listChannelTypes(): Promise { + return unwrapSdkResponse(await getNotificationsChannelTypes()) as string[]; +} + +export async function listChannels(): Promise { + return unwrapSdkResponse(await getNotificationsChannels()) as NotificationChannel[]; +} + +export async function getChannel(id: string): Promise { + return unwrapSdkResponse( + await getNotificationsChannelsByChannelId({ path: { channelId: id } }), + ) as NotificationChannel; +} + +export async function createChannel( + channel: NotificationChannelInput, +): Promise { + return unwrapSdkResponse( + await postNotificationsChannels({ + body: channel as HappydnsNotificationChannelWritable, + }), + ) as NotificationChannel; +} + +export async function updateChannel( + id: string, + channel: NotificationChannelInput, +): Promise { + return unwrapSdkResponse( + await putNotificationsChannelsByChannelId({ + path: { channelId: id }, + body: channel as HappydnsNotificationChannelWritable, + }), + ) as NotificationChannel; +} + +export async function deleteChannel(id: string): Promise { + return unwrapEmptyResponse( + await deleteNotificationsChannelsByChannelId({ path: { channelId: id } }), + ); +} + +export async function testChannel(id: string): Promise { + return unwrapEmptyResponse( + await postNotificationsChannelsByChannelIdTest({ path: { channelId: id } }), + ); +} + +export async function listPreferences(): Promise { + return unwrapSdkResponse(await getNotificationsPreferences()) as NotificationPreference[]; +} + +export async function getPreference(id: string): Promise { + return unwrapSdkResponse( + await getNotificationsPreferencesByPrefId({ path: { prefId: id } }), + ) as NotificationPreference; +} + +export async function createPreference( + pref: NotificationPreferenceInput, +): Promise { + return unwrapSdkResponse( + await postNotificationsPreferences({ body: pref }), + ) as NotificationPreference; +} + +export async function updatePreference( + id: string, + pref: NotificationPreferenceInput, +): Promise { + return unwrapSdkResponse( + await putNotificationsPreferencesByPrefId({ + path: { prefId: id }, + body: pref, + }), + ) as NotificationPreference; +} + +export async function deletePreference(id: string): Promise { + return unwrapEmptyResponse( + await deleteNotificationsPreferencesByPrefId({ path: { prefId: id } }), + ); +} + +export async function listHistory(limit?: number): Promise { + return unwrapSdkResponse( + await getNotificationsHistory({ query: limit ? { limit } : undefined }), + ) as NotificationRecord[]; +} diff --git a/web/src/lib/locales/en.json b/web/src/lib/locales/en.json index 69f8a23c..62c6589e 100644 --- a/web/src/lib/locales/en.json +++ b/web/src/lib/locales/en.json @@ -54,6 +54,7 @@ "previous": "Previous", "rename": "Rename", "resolver": "Resolver", + "save": "Save", "run": "Run the request!", "survey": "A remark? A comment to share? Don't hesitate to write to us!", "update": "Update", @@ -514,6 +515,117 @@ "title": "Preferences", "description": "Customize your preferences" }, + "notifications": { + "title": "Notifications", + "subtitle": "Configure how and when happyDomain notifies you about issues", + "description": "Manage notification channels, preferences and history", + "manage": "Manage notifications", + "channels": { + "title": "Channels", + "description": "Destinations where notifications are sent", + "add": "Add channel", + "edit": "Edit channel", + "delete": "Delete channel", + "test": "Send test", + "type": "Type", + "typeImmutable": "Channel type cannot be changed after creation", + "name": "Name", + "enabled": "Enabled", + "disabled": "Disabled", + "unnamed": "(unnamed)", + "empty": "No channels configured yet. Add one to start receiving notifications.", + "rawJson": "Raw JSON configuration", + "rawJsonHelp": "This channel type is not known to the UI. Edit the JSON configuration directly.", + "created": "Channel created", + "updated": "Channel updated", + "deleted": "Channel deleted", + "testSent": "Test notification sent", + "confirmDelete": "Delete this channel? Existing preferences referencing it will fall back to enabled channels.", + "loadError": "Failed to load channels", + "typesError": "Failed to load channel types", + "saveError": "Failed to save channel", + "deleteError": "Failed to delete channel", + "testError": "Failed to send test notification", + "fields": { + "emailAddress": "Email address", + "emailAddressHelp": "Leave empty to use your account email.", + "webhookUrl": "Webhook URL", + "webhookHeaders": "Custom headers", + "webhookHeadersHelp": "Extra HTTP headers sent with each webhook request.", + "webhookSecret": "Signing secret", + "webhookSecretHelp": "If set, requests are signed with HMAC-SHA256.", + "unifiedPushEndpoint": "UnifiedPush endpoint", + "unifiedPushEndpointHelp": "Endpoint URL provided by your UnifiedPush distributor.", + "headerName": "Header", + "headerValue": "Value", + "addHeader": "Add header" + } + }, + "preferences": { + "title": "Preferences", + "description": "Filter what notifications you receive and where they go", + "add": "Add preference", + "edit": "Edit preference", + "delete": "Delete preference", + "empty": "No preferences yet. Add one to choose what you get notified about.", + "scope": { + "label": "Scope", + "global": "All domains", + "domain": "Specific domain", + "service": "Specific service", + "serviceLabel": "{{domain}} / service {{service}}" + }, + "domain": "Domain", + "selectDomain": "Select a domain…", + "serviceId": "Service ID", + "serviceIdHelp": "Identifier of the service inside the selected domain.", + "channels": "Channels", + "channelsHelp": "Leave all unchecked to use all enabled channels.", + "noChannels": "Add at least one channel to assign it to a preference.", + "allChannels": "All enabled channels", + "minStatus": "Minimum severity", + "minStatusHelp": "Only statuses at or above this severity trigger a notification.", + "notifyRecovery": "Notify on recovery (back to OK)", + "recovery": "Recovery", + "quietHours": "Quiet hours", + "quietHoursHelp": "Hours given in UTC. Notifications are suppressed during the window.", + "quietStart": "Start (UTC hour)", + "quietEnd": "End (UTC hour)", + "enabled": "Enabled", + "disabled": "Disabled", + "created": "Preference created", + "updated": "Preference updated", + "deleted": "Preference deleted", + "confirmDelete": "Delete this preference?", + "loadError": "Failed to load preferences", + "saveError": "Failed to save preference", + "deleteError": "Failed to delete preference", + "defaults": { + "title": "Default settings", + "description": "Used when no rule matches the target.", + "minStatus": "Notifies on Warning and above", + "channels": "Sent to every enabled channel", + "noRecovery": "Recovery alerts are not sent", + "override": "Add a rule below to override these defaults." + } + }, + "history": { + "title": "History", + "description": "Recent notifications sent on your behalf", + "empty": "No notifications have been sent yet.", + "refresh": "Refresh", + "loadMore": "Load more", + "sentAt": "Sent at", + "channel": "Channel", + "checker": "Checker", + "target": "Target", + "transition": "Status change", + "result": "Result", + "success": "Sent", + "failure": "Failed", + "loadError": "Failed to load history" + } + }, "security": { "title": "Security & Access", "description": "Manage your authentication and security settings", diff --git a/web/src/lib/stores/notificationTypes.ts b/web/src/lib/stores/notificationTypes.ts new file mode 100644 index 00000000..13ee6b6a --- /dev/null +++ b/web/src/lib/stores/notificationTypes.ts @@ -0,0 +1,41 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { writable, type Writable } from "svelte/store"; +import { listChannelTypes } from "$lib/api/notifications"; + +export const notificationChannelTypes: Writable = writable(undefined); + +let pending: Promise | undefined; + +export async function refreshNotificationChannelTypes(): Promise { + if (pending) return pending; + pending = (async () => { + try { + const types = await listChannelTypes(); + notificationChannelTypes.set(types); + return types; + } finally { + pending = undefined; + } + })(); + return pending; +} diff --git a/web/src/lib/translations.ts b/web/src/lib/translations.ts index dafa2a53..5dc70c79 100644 --- a/web/src/lib/translations.ts +++ b/web/src/lib/translations.ts @@ -51,6 +51,7 @@ interface Params { intervalMin?: string; intervalMax?: string; intervalDefault?: string; + service?: string; // add more parameters that are used here } diff --git a/web/src/routes/me/+page.svelte b/web/src/routes/me/+page.svelte index 3b8a5ad2..c541acaf 100644 --- a/web/src/routes/me/+page.svelte +++ b/web/src/routes/me/+page.svelte @@ -59,6 +59,17 @@ +

+ + {$t("settings.notifications.title")} +

+

+ {$t("settings.notifications.description")} +

+ + {$t("settings.notifications.manage")} + + {#if $userSession.email !== "_no_auth"}

diff --git a/web/src/routes/me/notifications/+page.svelte b/web/src/routes/me/notifications/+page.svelte new file mode 100644 index 00000000..4abcbcd4 --- /dev/null +++ b/web/src/routes/me/notifications/+page.svelte @@ -0,0 +1,62 @@ + + + + + + {$t("settings.notifications.title")} - happyDomain + + + + + + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
diff --git a/web/src/routes/me/notifications/ChannelEditor.svelte b/web/src/routes/me/notifications/ChannelEditor.svelte new file mode 100644 index 00000000..4d5c42ef --- /dev/null +++ b/web/src/routes/me/notifications/ChannelEditor.svelte @@ -0,0 +1,378 @@ + + + + + + + {channel?.id + ? $t("settings.notifications.channels.edit") + : $t("settings.notifications.channels.add")} + + +
{ e.preventDefault(); save(); }}> +
+ + + {#each channelTypes as t (t)} + + {/each} + + {#if channel?.id} + + {$t("settings.notifications.channels.typeImmutable")} + + {/if} +
+ +
+ + +
+ +
+ + +
+ +
+ + {#if schema} + {#each schema.fields as field (field.key)} +
+ + + {#if field.kind === "text" || field.kind === "url"} + { + config = { + ...config, + [field.key]: (e.target as HTMLInputElement).value, + }; + }} + required={field.required} + /> + {:else if field.kind === "secret"} +
+ { + config = { + ...config, + [field.key]: (e.target as HTMLInputElement).value, + }; + }} + /> + +
+ {:else if field.kind === "headers"} + {@const headers = (config[field.key] as Record) ?? {}} + {#each Object.entries(headers) as [k, v] (k)} +
+ + setHeaderKey( + field, + k, + (e.target as HTMLInputElement).value, + )} + /> + + setHeaderValue( + field, + k, + (e.target as HTMLInputElement).value, + )} + /> + +
+ {/each} + + {/if} + + {#if field.i18nHelp} + + {$t(field.i18nHelp)} + + {/if} +
+ {/each} + {:else if type} +
+ + + {#if rawJsonError} +
{rawJsonError}
+ {/if} + + {$t("settings.notifications.channels.rawJsonHelp")} + +
+ {/if} +
+
+ + + + +
diff --git a/web/src/routes/me/notifications/ChannelsManager.svelte b/web/src/routes/me/notifications/ChannelsManager.svelte new file mode 100644 index 00000000..7b8aa391 --- /dev/null +++ b/web/src/routes/me/notifications/ChannelsManager.svelte @@ -0,0 +1,223 @@ + + + + +
+

+ {$t("settings.notifications.channels.description")} +

+ +
+ +{#if loading} +
+ +
+{:else if channels.length === 0} +
+ {$t("settings.notifications.channels.empty")} +
+{:else} +
    + {#each channels as channel (channel.id)} +
  • +
    +
    + {channel.name || $t("settings.notifications.channels.unnamed")} + {channel.type} + {#if !channel.enabled} + + {$t("settings.notifications.channels.disabled")} + + {/if} +
    +
    +
    + + + +
    +
  • + {/each} +
+{/if} + + (editorOpen = false)} +/> diff --git a/web/src/routes/me/notifications/HistoryList.svelte b/web/src/routes/me/notifications/HistoryList.svelte new file mode 100644 index 00000000..e39d4d81 --- /dev/null +++ b/web/src/routes/me/notifications/HistoryList.svelte @@ -0,0 +1,176 @@ + + + + +
+

+ {$t("settings.notifications.history.description")} +

+ +
+ +{#if loading} +
+ +
+{:else if records.length === 0} +
+ {$t("settings.notifications.history.empty")} +
+{:else} +
+ + + + + + + + + + + + + {#each records as r (r.id)} + + + + + + + + + {/each} + +
{$t("settings.notifications.history.sentAt")}{$t("settings.notifications.history.channel")}{$t("settings.notifications.history.checker")}{$t("settings.notifications.history.target")}{$t("settings.notifications.history.transition")}{$t("settings.notifications.history.result")}
{formatDate(r.sentAt)} + {r.channelType} + {r.checkerId}{formatTarget(r.target)} + + {$t(getStatusI18nKey(r.oldStatus))} + + → + + {$t(getStatusI18nKey(r.newStatus))} + + + {#if r.success} + + + {$t("settings.notifications.history.success")} + + {:else} + + + {$t("settings.notifications.history.failure")} + + {#if r.error} +
{r.error}
+ {/if} + {/if} +
+
+ + {#if records.length >= limit} +
+ +
+ {/if} +{/if} diff --git a/web/src/routes/me/notifications/PreferenceEditor.svelte b/web/src/routes/me/notifications/PreferenceEditor.svelte new file mode 100644 index 00000000..8e0b9893 --- /dev/null +++ b/web/src/routes/me/notifications/PreferenceEditor.svelte @@ -0,0 +1,401 @@ + + + + + + + {preference?.id + ? $t("settings.notifications.preferences.edit") + : $t("settings.notifications.preferences.add")} + + +
{ e.preventDefault(); save(); }}> +
+ + {$t("settings.notifications.preferences.scope.label")} + +
+ + +
+
+ + +
+
+ + +
+
+ + {#if scope === "domain" || scope === "service"} +
+ + + + {#each domainList as d (d.id)} + + {/each} + +
+ {/if} + + {#if scope === "service"} +
+ + + + {$t("settings.notifications.preferences.serviceIdHelp")} + +
+ {/if} + +
+ + {#if channels.length === 0} +
+ {$t("settings.notifications.preferences.noChannels")} +
+ {:else} +
+ {#each channels as c (c.id)} +
+ toggleChannel(c.id ?? "")} + /> + +
+ {/each} +
+ + {$t("settings.notifications.preferences.channelsHelp")} + + {/if} +
+ +
+ + + + + + + + + + {$t("settings.notifications.preferences.minStatusHelp")} + +
+ +
+ + +
+ +
+ + +
+ {#if quietHoursEnabled} +
+
+ + +
+
+ + +
+ + {$t("settings.notifications.preferences.quietHoursHelp")} + +
+ {/if} + +
+ + +
+
+
+ + + + +
diff --git a/web/src/routes/me/notifications/PreferencesManager.svelte b/web/src/routes/me/notifications/PreferencesManager.svelte new file mode 100644 index 00000000..586a13e8 --- /dev/null +++ b/web/src/routes/me/notifications/PreferencesManager.svelte @@ -0,0 +1,258 @@ + + + + +
+

+ {$t("settings.notifications.preferences.description")} +

+ +
+ +{#if loading} +
+ +
+{:else} + {#if !hasGlobalPreference} +
+
+
+ + {$t("settings.notifications.preferences.defaults.title")} +
+

+ {$t("settings.notifications.preferences.defaults.description")} +

+
    +
  • + + {$t("settings.notifications.preferences.defaults.minStatus")} +
  • +
  • + + {$t("settings.notifications.preferences.defaults.channels")} +
  • +
  • + + {$t("settings.notifications.preferences.defaults.noRecovery")} +
  • +
+ + {$t("settings.notifications.preferences.defaults.override")} + +
+
+ {/if} +{#if preferences.length === 0} +
+ {$t("settings.notifications.preferences.empty")} +
+{:else} +
    + {#each preferences as pref (pref.id)} +
  • +
    +
    + {scopeLabel(pref)} + {#if !pref.enabled} + + {$t("settings.notifications.preferences.disabled")} + + {/if} + + ≥ {$t(getStatusI18nKey(pref.minStatus))} + + {#if pref.notifyRecovery} + + + {$t("settings.notifications.preferences.recovery")} + + {/if} + {#if pref.quietStart !== undefined && pref.quietEnd !== undefined && pref.quietStart !== null && pref.quietEnd !== null} + + + {pref.quietStart}h–{pref.quietEnd}h UTC + + {/if} +
    + {channelsLabel(pref)} +
    +
    + + +
    +
  • + {/each} +
+{/if} +{/if} + + (editorOpen = false)} +/> diff --git a/web/src/routes/me/notifications/channelConfigs.ts b/web/src/routes/me/notifications/channelConfigs.ts new file mode 100644 index 00000000..e663ebf5 --- /dev/null +++ b/web/src/routes/me/notifications/channelConfigs.ts @@ -0,0 +1,101 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// FieldKind drives ChannelEditor's per-field rendering. Keep the set small; +// anything more exotic should fall through to the generic JSON editor. +export type FieldKind = "text" | "url" | "secret" | "headers"; + +export interface ChannelConfigField { + key: string; + kind: FieldKind; + required?: boolean; + i18nLabel: string; + i18nHelp?: string; +} + +export interface ChannelConfigSchema { + fields: ChannelConfigField[]; +} + +// Known transports shipped today. Field names match the JSON tags on +// EmailConfig / WebhookConfig / UnifiedPushConfig in +// internal/notification/*_sender.go. Adding a new sender on the backend +// only requires a one-file change here, otherwise the editor falls back +// to a raw JSON textarea. +export const CHANNEL_CONFIG_SCHEMAS: Record = { + email: { + fields: [ + { + key: "emailAddress", + kind: "text", + i18nLabel: "settings.notifications.channels.fields.emailAddress", + i18nHelp: "settings.notifications.channels.fields.emailAddressHelp", + }, + ], + }, + webhook: { + fields: [ + { + key: "webhookUrl", + kind: "url", + required: true, + i18nLabel: "settings.notifications.channels.fields.webhookUrl", + }, + { + key: "webhookHeaders", + kind: "headers", + i18nLabel: "settings.notifications.channels.fields.webhookHeaders", + i18nHelp: "settings.notifications.channels.fields.webhookHeadersHelp", + }, + { + key: "webhookSecret", + kind: "secret", + i18nLabel: "settings.notifications.channels.fields.webhookSecret", + i18nHelp: "settings.notifications.channels.fields.webhookSecretHelp", + }, + ], + }, + unifiedpush: { + fields: [ + { + key: "unifiedPushEndpoint", + kind: "url", + required: true, + i18nLabel: "settings.notifications.channels.fields.unifiedPushEndpoint", + i18nHelp: "settings.notifications.channels.fields.unifiedPushEndpointHelp", + }, + ], + }, +}; + +export function getChannelConfigSchema(type: string | undefined): ChannelConfigSchema | undefined { + if (!type) return undefined; + return CHANNEL_CONFIG_SCHEMAS[type]; +} + +export function emptyConfigForSchema(schema: ChannelConfigSchema): Record { + const cfg: Record = {}; + for (const f of schema.fields) { + if (f.kind === "headers") cfg[f.key] = {}; + else cfg[f.key] = ""; + } + return cfg; +} From 342aac04bd0485021b1eef9afdf68ad927a1b149 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 29 Apr 2026 22:05:52 +0700 Subject: [PATCH 10/10] docs: add checker notifications overview --- docs/checker-notifications.md | 110 ++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/checker-notifications.md diff --git a/docs/checker-notifications.md b/docs/checker-notifications.md new file mode 100644 index 00000000..ec0b4c9a --- /dev/null +++ b/docs/checker-notifications.md @@ -0,0 +1,110 @@ +# Checker notifications + +Notifications turn checker results into user-visible alerts. The system watches +every completed `Execution`, decides whether the status transition deserves an +alert under the user's preferences, and fans the message out to one or more +delivery channels (email, webhook, UnifiedPush). Audit records and per-issue +acknowledgements are kept so the user can see what was sent, and silence noisy +incidents until they recover. + +## Goals + +- **Transition driven, not poll driven.** Only state changes notify. Identical + back to back results stay silent. This is what keeps a flapping checker from + paging on every run. +- **Per user, per scope policy.** A user can configure preferences globally, + per domain, or per service, and the most specific rule wins (service over + domain over global). Channels are owned by the user, and may be allow listed + per preference. +- **Opt in by default.** A user with no configured preference still receives + `warn` and above alerts on all enabled channels, so onboarding does not + require authoring rules. A configured preference can lower or raise that + bar, suppress recovery notices, or set quiet hours. +- **Acknowledgement closes the loop.** A user can acknowledge an active issue + and stop further alerts at the same severity until the incident recovers or + escalates. +- **Auditable delivery.** Every send, and every failure (including back + pressure drops), is recorded so users can confirm an alert really left the + building. +- **Decoupled from the checker engine.** Notification dispatch hangs off a + callback the engine fires after each execution, so a slow channel cannot + wedge a checker run. + +## Architecture + +The checker engine fires a registered callback after each `ExecutionDone`. The +callback enters the notification subsystem, which loads the user's state and +preferences, runs a pure policy function over the status transition, persists +the new state, and on a positive decision enqueues sends onto a bounded worker +pool. Workers resolve the matching sender from a type indexed registry, and +write a record of the result into the audit log. + +State, preferences, channels, and records each have a dedicated storage +interface. A per `(checker, target, user)` mutex serialises the read modify +write done by the dispatcher and by manual acknowledgement actions, so an ack +cannot be silently overwritten by a concurrent checker run. The HTTP layer +(`internal/api/route/notification.go`) exposes CRUD on channels, preferences, +history, and the acknowledge and clear endpoints scoped to a checker. The +Svelte UI under `web/src/routes/me/notifications/` drives all of this. + +Key types: + +- `Dispatcher` (`internal/usecase/notification/dispatcher.go`): the seam + between checker and notification, glues every collaborator together and + owns no I/O of its own. +- `Resolver`: picks the most specific preference for a target, and the + channels (filtered by the preference allow list) that should carry the + alert. +- `policy.decide`: pure function returning skip, advance, or notify, plus + recovery, escalation, and clear ack flags. Unit tested. +- `StateLocker`: in process per key mutex shared by the dispatcher and the + ack service. +- `Pool`: bounded queue (256) and fixed worker pool (4) with a 15s send + timeout, records every result and writes an audit row on saturation rather + than dropping silently. +- `Registry` and `ChannelSender` (`internal/notification/sender.go`): typed + registry of transports, with `TypedSender[C]` and `Adapt` providing + decode, validate, redact, merge, and send test for free. +- `AckService`: acknowledge, clear, get, and list state, behind the same + state lock. +- Models (`model/notification.go`): `NotificationChannel`, + `NotificationPreference`, `NotificationState`, `NotificationRecord`. + +### Decision flow inside `policy.decide` + +1. `oldStatus == newStatus` returns skip. +2. Compute `isRecovery` (`newStatus < warn` while `oldStatus >= warn`) and + `isEscalation` (`newStatus > oldStatus && newStatus >= warn`). Either one + sets `ClearAck`, since the incident is over or has worsened. +3. No preference, or `pref.Enabled=false`, returns advance (record the + transition, do not send). +4. Non recovery below `pref.MinStatus` returns advance. +5. Recovery while `!pref.NotifyRecovery` returns advance. +6. Active acknowledgement, with `!ClearAck`, and not a recovery, returns + advance. The user already knows. +7. Inside `pref.QuietStart..QuietEnd` (UTC, wraps midnight if start is + greater than end) returns advance. +8. Otherwise, notify. + +Advance updates `LastStatus` only. Notify also stamps `LastNotifiedAt`, and +this stamp is written **before** enqueuing, so a fast re run sees the new +status and skips, even if the worker pool has not yet drained. + +## Existing channel implementations + +All senders live in `internal/notification/`. They share `safe_http.go` +(outbound URL validation against private and loopback ranges, redirect +bounds), and where relevant `httpjson.go` for JSON POSTs. Each registers a +constant of type `happydns.NotificationChannelType`. + +| Type | Constant | Config (JSON) | Notes | +|---------------|-------------------------|---------------------------------------------|-----------------------------------------------------------------------------------| +| `email` | `ChannelTypeEmail` | `{ "address"?: string }` | Falls back to the user's account email. Renders via the existing `Mailer`. Base URL captured at construction. | +| `webhook` | `ChannelTypeWebhook` | `{ "url": string, "secret"?: string, ... }` | POSTs JSON. Optional HMAC SHA256 signature header derived from `secret`. | +| `unifiedpush` | `ChannelTypeUnifiedPush`| `{ "endpoint": string }` | POST to the distributor provided endpoint. URL is validated against the safe HTTP allow list. | + +Each sender is implemented as a `TypedSender[C]` and exposed through `Adapt`, +so JSON decode, `Validate`, redact, merge, and `SendTest` are uniform across +transports. Senders that carry secrets implement `ConfigRedactor[C]`, so the +client never sees the secret, and `ConfigMerger[C]`, so an empty value on +update preserves the stored secret rather than wiping it.