Compare commits
11 commits
342aac04bd
...
023b35e167
| Author | SHA1 | Date | |
|---|---|---|---|
| 023b35e167 | |||
| d27fae2b27 | |||
| 8f468bcad1 | |||
| 9f9e61bac2 | |||
| 08c8a6bd9c | |||
| ae8528d620 | |||
| ab97185611 | |||
| 0eacde4dd1 | |||
| d821bc27a1 | |||
| 97ec5eca7a | |||
| f606e414db |
27 changed files with 220 additions and 551 deletions
|
|
@ -27,6 +27,7 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
|
|
@ -36,19 +37,13 @@ import (
|
|||
"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.
|
||||
// Caps ?limit= so an unbounded request can't OOM the in-memory slice.
|
||||
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.
|
||||
// Bounds the persisted annotation to prevent state bloat.
|
||||
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.
|
||||
// Storage errors may contain keys/internals; never echo them back.
|
||||
func internalError(c *gin.Context, err error) {
|
||||
log.Printf("notification controller: %v", err)
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{
|
||||
|
|
@ -56,7 +51,6 @@ func internalError(c *gin.Context, err error) {
|
|||
})
|
||||
}
|
||||
|
||||
// NotificationController handles notification-related API endpoints.
|
||||
type NotificationController struct {
|
||||
dispatcher *notifUC.Dispatcher
|
||||
registry *notifPkg.Registry
|
||||
|
|
@ -65,7 +59,6 @@ type NotificationController struct {
|
|||
recordStore notifUC.NotificationRecordStorage
|
||||
}
|
||||
|
||||
// NewNotificationController creates a new NotificationController.
|
||||
func NewNotificationController(
|
||||
dispatcher *notifUC.Dispatcher,
|
||||
registry *notifPkg.Registry,
|
||||
|
|
@ -82,9 +75,6 @@ func NewNotificationController(
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -94,10 +84,6 @@ 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
|
||||
|
|
@ -121,8 +107,6 @@ func (nc *NotificationController) ListChannels(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, redacted)
|
||||
}
|
||||
|
||||
// CreateChannel creates a new notification channel.
|
||||
//
|
||||
// @Summary Create a notification channel
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
|
|
@ -159,8 +143,6 @@ func (nc *NotificationController) CreateChannel(c *gin.Context) {
|
|||
c.JSON(http.StatusCreated, redacted)
|
||||
}
|
||||
|
||||
// GetChannel returns a specific notification channel.
|
||||
//
|
||||
// @Summary Get a notification channel
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
|
|
@ -176,10 +158,7 @@ func (nc *NotificationController) GetChannel(c *gin.Context) {
|
|||
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).
|
||||
// Absent body fields are preserved so omitting one (e.g. "enabled") doesn't silently zero it.
|
||||
//
|
||||
// @Summary Update a notification channel
|
||||
// @Tags notifications
|
||||
|
|
@ -192,9 +171,7 @@ func (nc *NotificationController) GetChannel(c *gin.Context) {
|
|||
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.
|
||||
// Bind onto a copy so json.Unmarshal only overwrites present fields; identity fields are forced back below.
|
||||
ch := *existing
|
||||
if err := c.ShouldBindJSON(&ch); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
|
|
@ -204,9 +181,7 @@ func (nc *NotificationController) UpdateChannel(c *gin.Context) {
|
|||
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.
|
||||
// Carry forward stored secrets so a GET → PUT round-trip does not wipe them.
|
||||
merged, err := nc.registry.MergeChannelForUpdate(existing, &ch)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
|
|
@ -232,8 +207,6 @@ func (nc *NotificationController) UpdateChannel(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, redacted)
|
||||
}
|
||||
|
||||
// DeleteChannel deletes a notification channel.
|
||||
//
|
||||
// @Summary Delete a notification channel
|
||||
// @Tags notifications
|
||||
// @Param channelId path string true "Channel ID"
|
||||
|
|
@ -250,8 +223,6 @@ func (nc *NotificationController) DeleteChannel(c *gin.Context) {
|
|||
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"
|
||||
|
|
@ -269,10 +240,7 @@ func (nc *NotificationController) TestChannel(c *gin.Context) {
|
|||
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.
|
||||
// Hours, when set, must be in 0–23. Timezone, when set, must be a valid IANA name.
|
||||
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")
|
||||
|
|
@ -280,11 +248,14 @@ func validateQuietHours(p *happydns.NotificationPreference) error {
|
|||
if p.QuietEnd != nil && (*p.QuietEnd < 0 || *p.QuietEnd > 23) {
|
||||
return fmt.Errorf("quietEnd must be between 0 and 23")
|
||||
}
|
||||
if p.Timezone != "" {
|
||||
if _, err := time.LoadLocation(p.Timezone); err != nil {
|
||||
return fmt.Errorf("timezone %q is not a valid IANA name", p.Timezone)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPreferences returns all notification preferences for the authenticated user.
|
||||
//
|
||||
// @Summary List notification preferences
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
|
|
@ -303,8 +274,6 @@ func (nc *NotificationController) ListPreferences(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, prefs)
|
||||
}
|
||||
|
||||
// CreatePreference creates a new notification preference.
|
||||
//
|
||||
// @Summary Create a notification preference
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
|
|
@ -336,8 +305,6 @@ func (nc *NotificationController) CreatePreference(c *gin.Context) {
|
|||
c.JSON(http.StatusCreated, pref)
|
||||
}
|
||||
|
||||
// GetPreference returns a specific notification preference.
|
||||
//
|
||||
// @Summary Get a notification preference
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
|
|
@ -348,8 +315,7 @@ 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).
|
||||
// Absent body fields preserved (see UpdateChannel).
|
||||
//
|
||||
// @Summary Update a notification preference
|
||||
// @Tags notifications
|
||||
|
|
@ -384,8 +350,6 @@ func (nc *NotificationController) UpdatePreference(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, pref)
|
||||
}
|
||||
|
||||
// DeletePreference deletes a notification preference.
|
||||
//
|
||||
// @Summary Delete a notification preference
|
||||
// @Tags notifications
|
||||
// @Param prefId path string true "Preference ID"
|
||||
|
|
@ -402,10 +366,6 @@ func (nc *NotificationController) DeletePreference(c *gin.Context) {
|
|||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- History ---
|
||||
|
||||
// ListHistory returns recent notification records for the authenticated user.
|
||||
//
|
||||
// @Summary List notification history
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
|
|
@ -436,10 +396,6 @@ func (nc *NotificationController) ListHistory(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
|
||||
// --- Acknowledgement ---
|
||||
|
||||
// AcknowledgeIssue marks a checker issue as acknowledged.
|
||||
//
|
||||
// @Summary Acknowledge a checker issue
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
|
|
@ -481,8 +437,6 @@ func (nc *NotificationController) AcknowledgeIssue(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
// ClearAcknowledgement removes an acknowledgement from a checker issue.
|
||||
//
|
||||
// @Summary Clear acknowledgement
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
|
|
|
|||
|
|
@ -36,10 +36,7 @@ const (
|
|||
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.
|
||||
// Centralizes ownership check so per-channel endpoints cannot forget it.
|
||||
func NotificationChannelHandler(store notifUC.NotificationChannelStorage) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user := MyUser(c)
|
||||
|
|
@ -75,15 +72,11 @@ func NotificationChannelHandler(store notifUC.NotificationChannelStorage) gin.Ha
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Panics if middleware not installed — wiring bug, not runtime.
|
||||
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)
|
||||
|
|
@ -118,7 +111,6 @@ func NotificationPreferenceHandler(store notifUC.NotificationPreferenceStorage)
|
|||
}
|
||||
}
|
||||
|
||||
// MyNotificationPreference returns the preference resolved by NotificationPreferenceHandler.
|
||||
func MyNotificationPreference(c *gin.Context) *happydns.NotificationPreference {
|
||||
return c.MustGet(ctxKeyNotificationPreference).(*happydns.NotificationPreference)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import (
|
|||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// DeclareNotificationRoutes registers notification routes under /api/notifications.
|
||||
func DeclareNotificationRoutes(
|
||||
apiAuthRoutes *gin.RouterGroup,
|
||||
dispatcher *notifUC.Dispatcher,
|
||||
|
|
@ -61,10 +60,7 @@ func DeclareNotificationRoutes(
|
|||
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).
|
||||
// Rate-limit per user: each test triggers an outbound request and channels are user-owned.
|
||||
testRLStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
|
||||
Rate: time.Minute,
|
||||
Limit: 5,
|
||||
|
|
|
|||
|
|
@ -441,6 +441,19 @@ func (app *App) Start() {
|
|||
go app.insights.Run()
|
||||
}
|
||||
|
||||
// Reconcile executions left "running" by a previous process that
|
||||
// crashed or was killed mid-run, before the scheduler starts queuing
|
||||
// new work.
|
||||
if recoverer, ok := app.usecases.checkerEngine.(interface {
|
||||
RecoverStaleExecutions(ctx context.Context) (int, error)
|
||||
}); ok {
|
||||
if n, err := recoverer.RecoverStaleExecutions(context.Background()); err != nil {
|
||||
log.Printf("CheckerEngine: failed to recover stale executions: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("CheckerEngine: recovered %d stale execution(s) from previous run", n)
|
||||
}
|
||||
}
|
||||
|
||||
if app.usecases.checkerScheduler != nil {
|
||||
app.usecases.checkerScheduler.Start(context.Background())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,9 +33,8 @@ import (
|
|||
|
||||
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.
|
||||
// Empty means fall back to the user's account email.
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -49,16 +48,12 @@ func (c EmailConfig) Validate() error {
|
|||
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.
|
||||
// baseURL is captured here — 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}
|
||||
}
|
||||
|
|
@ -76,17 +71,11 @@ func (s *EmailSender) Send(_ context.Context, c EmailConfig, payload *Notificati
|
|||
|
||||
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.
|
||||
// Strip CR/LF to prevent RFC 5322 header injection.
|
||||
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.
|
||||
// Wrap third-party-sourced fields as Markdown code spans to neutralize injected link syntax in DKIM-signed mail; Annotation is user-authored, no boundary.
|
||||
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))
|
||||
|
|
@ -111,16 +100,11 @@ func (s *EmailSender) Send(_ context.Context, c EmailConfig, payload *Notificati
|
|||
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).
|
||||
// Wraps s as a code span; backticks become apostrophes to avoid fence accounting.
|
||||
func mdLiteral(s string) string {
|
||||
return "`" + strings.ReplaceAll(s, "`", "'") + "`"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import (
|
|||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// httpJSONPayload is the wire format used by both webhook and UnifiedPush.
|
||||
// Shared by both webhook and UnifiedPush.
|
||||
type httpJSONPayload struct {
|
||||
Event string `json:"event"`
|
||||
Checker string `json:"checker"`
|
||||
|
|
@ -60,8 +60,7 @@ func buildHTTPPayload(p *NotificationPayload, dashboardURL string) httpJSONPaylo
|
|||
}
|
||||
}
|
||||
|
||||
// postJSON marshals body, POSTs it, runs decorate (e.g. for HMAC/headers),
|
||||
// and returns an error on a non-2xx response or transport failure.
|
||||
// decorate runs after marshal so it can sign the exact bytes (e.g. HMAC).
|
||||
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 {
|
||||
|
|
@ -90,7 +89,6 @@ func postJSON(ctx context.Context, client *http.Client, url string, body any, de
|
|||
return nil
|
||||
}
|
||||
|
||||
// testPayload builds a synthetic payload used by SendTest implementations.
|
||||
func testPayload(rcpt Recipient) *NotificationPayload {
|
||||
return &NotificationPayload{
|
||||
Recipient: rcpt,
|
||||
|
|
|
|||
|
|
@ -32,18 +32,12 @@ import (
|
|||
"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.
|
||||
// We drain the response only for keep-alive; body is unused.
|
||||
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.
|
||||
// Reject non-http(s), missing host, or private/loopback IP literals; DNS hosts re-checked at dial time.
|
||||
func validateOutboundURL(rawURL string) (*url.URL, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
|
|
@ -64,10 +58,6 @@ func validateOutboundURL(rawURL string) (*url.URL, error) {
|
|||
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
|
||||
|
|
@ -84,9 +74,7 @@ func isPublicIP(ip net.IP) bool {
|
|||
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.
|
||||
// Re-check resolved IP to defeat DNS rebinding.
|
||||
func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
|
|
@ -121,8 +109,7 @@ func safeDialContext(ctx context.Context, network, address string) (net.Conn, er
|
|||
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.
|
||||
// Refuses private/loopback addresses and re-validates each redirect hop.
|
||||
func newSafeHTTPClient(timeout time.Duration) *http.Client {
|
||||
transport := &http.Transport{
|
||||
DialContext: safeDialContext,
|
||||
|
|
|
|||
|
|
@ -30,21 +30,13 @@ import (
|
|||
"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.
|
||||
// Carries only what at least one transport needs, so other transports cannot leak the user record.
|
||||
type Recipient struct {
|
||||
// Email is the recipient's email address. May be empty for transports
|
||||
// that don't need it (webhook, UnifiedPush).
|
||||
// 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.
|
||||
// Senders receive only render-needed data — no user object, no server config — so adding a transport cannot leak privileged data.
|
||||
type NotificationPayload struct {
|
||||
Recipient Recipient
|
||||
CheckerID string
|
||||
|
|
@ -56,72 +48,38 @@ type NotificationPayload struct {
|
|||
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.
|
||||
// Senders own their config shape so adding a transport is a one-file change.
|
||||
// Most implementations should embed TypedSender[C] via Adapt rather than implementing this directly.
|
||||
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.
|
||||
// Strip secrets to presence booleans before echoing config back to clients.
|
||||
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.
|
||||
// Preserve stored secrets when client submits empty fields (client never sees them on read).
|
||||
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.
|
||||
// Optional capability: senders with secret fields opt in by implementing this on their TypedSender.
|
||||
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.
|
||||
// Strongly-typed contract; Adapt wraps it as ChannelSender, providing JSON decode, validation, type-asserted dispatch, and SendTest.
|
||||
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}
|
||||
}
|
||||
|
|
@ -191,19 +149,16 @@ func (a *typedAdapter[C]) MergeForUpdate(existing, incoming json.RawMessage) (js
|
|||
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.
|
||||
// Senders self-register at startup; adding a 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.
|
||||
// Panics on duplicate — programming error.
|
||||
func (r *Registry) Register(s ChannelSender) {
|
||||
t := s.Type()
|
||||
if _, exists := r.senders[t]; exists {
|
||||
|
|
@ -212,13 +167,11 @@ func (r *Registry) Register(s ChannelSender) {
|
|||
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 {
|
||||
|
|
@ -227,9 +180,6 @@ func (r *Registry) Types() []happydns.NotificationChannelType {
|
|||
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 {
|
||||
|
|
@ -238,10 +188,7 @@ func (r *Registry) DecodeChannelConfig(ch *happydns.NotificationChannel) (Channe
|
|||
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.
|
||||
// Channels of unknown types are returned unchanged so administrators can still observe legacy data.
|
||||
func (r *Registry) RedactChannel(ch *happydns.NotificationChannel) (*happydns.NotificationChannel, error) {
|
||||
if ch == nil {
|
||||
return nil, nil
|
||||
|
|
@ -260,8 +207,6 @@ func (r *Registry) RedactChannel(ch *happydns.NotificationChannel) (*happydns.No
|
|||
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 {
|
||||
|
|
@ -274,10 +219,7 @@ func (r *Registry) RedactChannels(chs []*happydns.NotificationChannel) ([]*happy
|
|||
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.
|
||||
// Caller should DecodeConfig the returned raw before persisting.
|
||||
func (r *Registry) MergeChannelForUpdate(existing, incoming *happydns.NotificationChannel) (json.RawMessage, error) {
|
||||
s, ok := r.Get(incoming.Type)
|
||||
if !ok {
|
||||
|
|
@ -286,6 +228,4 @@ func (r *Registry) MergeChannelForUpdate(existing, incoming *happydns.Notificati
|
|||
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")
|
||||
|
|
|
|||
|
|
@ -33,9 +33,7 @@ import (
|
|||
|
||||
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"`
|
||||
}
|
||||
|
||||
|
|
@ -49,15 +47,12 @@ func (c UnifiedPushConfig) Validate() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// UnifiedPushSender sends notifications via the UnifiedPush protocol. The
|
||||
// dashboard URL is captured here rather than threaded through the payload.
|
||||
// dashboardURL is captured here — server identity, not per-notification data.
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -35,10 +35,7 @@ import (
|
|||
"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.
|
||||
// Reserved by the HTTP client or used to spoof outbound identity (smuggling/host-routing risk).
|
||||
var disallowedWebhookHeaders = map[string]struct{}{
|
||||
"host": {},
|
||||
"content-length": {},
|
||||
|
|
@ -50,8 +47,6 @@ var disallowedWebhookHeaders = map[string]struct{}{
|
|||
"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")
|
||||
|
|
@ -67,19 +62,12 @@ func validateHeader(k, v string) error {
|
|||
|
||||
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.
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
|
||||
// Secret, if set, is used to compute an HMAC-SHA256 signature header.
|
||||
// HMAC-SHA256 signing key.
|
||||
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.
|
||||
// Set only by RedactConfig — never stored or accepted on input.
|
||||
HasSecret bool `json:"hasSecret,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -98,16 +86,12 @@ func (c WebhookConfig) Validate() error {
|
|||
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.
|
||||
// dashboardURL is captured here — 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),
|
||||
|
|
@ -117,19 +101,13 @@ func NewWebhookSender(dashboardURL string) *WebhookSender {
|
|||
|
||||
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".
|
||||
// Preserve stored secret on empty submit; client never receives it back, so absence means "no change".
|
||||
func (s *WebhookSender) MergeForUpdate(existing, incoming WebhookConfig) WebhookConfig {
|
||||
if incoming.Secret == "" {
|
||||
incoming.Secret = existing.Secret
|
||||
|
|
@ -142,9 +120,7 @@ func (s *WebhookSender) Send(ctx context.Context, c WebhookConfig, payload *Noti
|
|||
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.
|
||||
// Defense in depth: catches stored channels that pre-date Validate().
|
||||
if err := validateHeader(k, v); err != nil {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,13 +30,7 @@ import (
|
|||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Channel storage layout:
|
||||
// notifch|<channelId> -> full NotificationChannel (source of truth)
|
||||
// notifch-user|<userId>|<channelId> -> "" (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.
|
||||
// Layout: notifch|<channelId> -> full record; notifch-user|<userId>|<channelId> -> "" (index only, no double-write).
|
||||
|
||||
const (
|
||||
notifchPrimaryPrefix = "notifch|"
|
||||
|
|
@ -51,8 +45,6 @@ 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|<userId>|<channelId>" key.
|
||||
func channelIdFromUserIndexKey(key string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(key, notifchUserPrefix)
|
||||
if !ok {
|
||||
|
|
@ -83,9 +75,8 @@ func (s *KVStorage) ListChannelsByUser(userId happydns.Identifier) ([]*happydns.
|
|||
}
|
||||
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)
|
||||
// Index drift: skip rather than fail the whole list.
|
||||
log.Printf("storage: channel index points to missing channel %q: %v", idStr, err)
|
||||
continue
|
||||
}
|
||||
channels = append(channels, ch)
|
||||
|
|
@ -113,10 +104,9 @@ func (s *KVStorage) CreateChannel(ch *happydns.NotificationChannel) error {
|
|||
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.
|
||||
// Roll back primary so a failed index write doesn't orphan it.
|
||||
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)
|
||||
log.Printf("storage: orphan channel %q after index write failed (rollback also failed: %v)", ch.Id.String(), delErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -124,8 +114,7 @@ func (s *KVStorage) CreateChannel(ch *happydns.NotificationChannel) error {
|
|||
}
|
||||
|
||||
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.
|
||||
// Index has no payload, so only the primary needs writing.
|
||||
return s.db.Put(notifchPrimaryKey(ch.Id), ch)
|
||||
}
|
||||
|
||||
|
|
@ -135,13 +124,12 @@ func (s *KVStorage) DeleteChannel(channelId happydns.Identifier) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Delete the index first so a partial failure leaves the channel hidden
|
||||
// from listings rather than visible-but-broken.
|
||||
// Delete index first so partial failure hides the channel rather than leaving it 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)
|
||||
log.Printf("storage: channel %q index removed but primary delete failed: %v", channelId.String(), err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -30,9 +30,7 @@ import (
|
|||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Preference storage layout mirrors notification_channel.go: the primary
|
||||
// "notifpref|<id>" key holds the record; "notifpref-user|<userId>|<id>" is a
|
||||
// presence-only secondary index for cheap per-user listing.
|
||||
// Same primary+per-user-index layout as notification_channel.go.
|
||||
|
||||
const (
|
||||
notifprefPrimaryPrefix = "notifpref|"
|
||||
|
|
@ -77,7 +75,7 @@ func (s *KVStorage) ListPreferencesByUser(userId happydns.Identifier) ([]*happyd
|
|||
}
|
||||
pref, err := s.GetPreference(id)
|
||||
if err != nil {
|
||||
log.Printf("storage: preference index points to missing preference %s: %v", idStr, err)
|
||||
log.Printf("storage: preference index points to missing preference %q: %v", idStr, err)
|
||||
continue
|
||||
}
|
||||
prefs = append(prefs, pref)
|
||||
|
|
@ -106,7 +104,7 @@ func (s *KVStorage) CreatePreference(pref *happydns.NotificationPreference) erro
|
|||
}
|
||||
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)
|
||||
log.Printf("storage: orphan preference %q after index write failed (rollback also failed: %v)", pref.Id.String(), delErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -127,7 +125,7 @@ func (s *KVStorage) DeletePreference(prefId happydns.Identifier) error {
|
|||
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)
|
||||
log.Printf("storage: preference %q index removed but primary delete failed: %v", prefId.String(), err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -29,13 +29,7 @@ import (
|
|||
"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.
|
||||
// Built from explicit fields so SDK changes to CheckTarget can't reshape the key and orphan stored records.
|
||||
func notifStateKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string {
|
||||
return fmt.Sprintf(
|
||||
"notifstate|%s|%s|%s/%s/%s",
|
||||
|
|
|
|||
|
|
@ -329,6 +329,39 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
|
|||
return result, eval, nil
|
||||
}
|
||||
|
||||
// RecoverStaleExecutions scans all executions and marks any still in Pending
|
||||
// or Running state as Failed. It is intended to be called at startup to
|
||||
// reconcile state left over from a previous process that crashed or was
|
||||
// killed mid-execution: without it, the affected executions would remain
|
||||
// "running" forever in the UI. Returns the number of executions updated.
|
||||
func (e *checkerEngine) RecoverStaleExecutions(ctx context.Context) (int, error) {
|
||||
iter, err := e.execStore.ListAllExecutions()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("listing executions: %w", err)
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
n := 0
|
||||
for iter.Next() {
|
||||
exec := iter.Item()
|
||||
if exec.Status != happydns.ExecutionPending && exec.Status != happydns.ExecutionRunning {
|
||||
continue
|
||||
}
|
||||
endTime := time.Now()
|
||||
exec.Status = happydns.ExecutionFailed
|
||||
exec.EndedAt = &endTime
|
||||
if exec.Error == "" {
|
||||
exec.Error = "execution interrupted by server restart"
|
||||
}
|
||||
if err := e.execStore.UpdateExecution(exec); err != nil {
|
||||
log.Printf("CheckerEngine: failed to recover stale execution %s: %v", exec.Id.String(), err)
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RelatedLookup exposes the engine's Related resolver so controllers can
|
||||
// build ReportContexts with cross-checker observations pre-resolved. Returns
|
||||
// nil when discovery storage is not wired.
|
||||
|
|
|
|||
|
|
@ -27,24 +27,19 @@ import (
|
|||
"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.
|
||||
// Depends only on the state store — no senders, no preferences.
|
||||
type AckService struct {
|
||||
stateStore NotificationStateStorage
|
||||
locker *StateLocker
|
||||
|
||||
// nowFn is the clock used for AcknowledgedAt. Overridable by same-package
|
||||
// tests; defaults to time.Now.
|
||||
// Overridable for tests.
|
||||
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.
|
||||
// locker is shared with the Dispatcher to avoid racing on the same state record.
|
||||
func NewAckService(stateStore NotificationStateStorage, locker *StateLocker) *AckService {
|
||||
return &AckService{stateStore: stateStore, locker: locker, nowFn: time.Now}
|
||||
}
|
||||
|
||||
//
|
||||
// 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
|
||||
|
|
@ -73,7 +68,6 @@ func (a *AckService) AcknowledgeIssue(userId happydns.Identifier, checkerID stri
|
|||
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()
|
||||
|
|
@ -90,12 +84,10 @@ func (a *AckService) ClearAcknowledgement(userId happydns.Identifier, checkerID
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,7 @@ import (
|
|||
"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.
|
||||
// Glue between checker execution and notification system; owns no I/O — all of it lives in collaborators.
|
||||
type Dispatcher struct {
|
||||
stateStore NotificationStateStorage
|
||||
userStore UserGetter
|
||||
|
|
@ -47,14 +42,11 @@ type Dispatcher struct {
|
|||
ack *AckService
|
||||
locker *StateLocker
|
||||
|
||||
// nowFn is the clock used for state timestamps. Overridable by same-package
|
||||
// tests to drive time-dependent policy decisions deterministically.
|
||||
// Overridable for tests.
|
||||
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.
|
||||
// Caller owns Pool lifecycle.
|
||||
func NewDispatcher(
|
||||
stateStore NotificationStateStorage,
|
||||
userStore UserGetter,
|
||||
|
|
@ -78,16 +70,9 @@ func NewDispatcher(
|
|||
}
|
||||
}
|
||||
|
||||
// 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() }
|
||||
func (d *Dispatcher) Stop() { d.pool.Stop() }
|
||||
|
||||
// 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
|
||||
|
|
@ -100,16 +85,13 @@ func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydn
|
|||
|
||||
user, err := d.userStore.GetUser(*userId)
|
||||
if err != nil {
|
||||
log.Printf("notification: failed to load user %s: %v", userId, err)
|
||||
log.Printf("notification: failed to load user %q: %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.
|
||||
// Serialize with AckService so concurrent updates can't wipe an ack or fire duplicates.
|
||||
unlock := d.locker.Lock(exec.CheckerID, exec.Target, *userId)
|
||||
defer unlock()
|
||||
|
||||
|
|
@ -123,9 +105,7 @@ func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydn
|
|||
|
||||
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.
|
||||
// Recovery/escalation invalidates ack: incident is over or has worsened.
|
||||
if dec.ClearAck {
|
||||
state.ClearAcknowledgement()
|
||||
}
|
||||
|
|
@ -140,9 +120,7 @@ func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydn
|
|||
|
||||
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.
|
||||
// Mark before enqueue so a rapid re-run sees oldStatus == newStatus and skips.
|
||||
d.markNotified(state, newStatus)
|
||||
|
||||
for _, ch := range d.resolver.ResolveChannels(user, pref) {
|
||||
|
|
@ -194,8 +172,7 @@ func (d *Dispatcher) buildPayload(user *happydns.User, exec *happydns.Execution,
|
|||
}
|
||||
}
|
||||
|
||||
// advanceState persists the new observed status without claiming a
|
||||
// notification was sent. Used when policy suppresses the alert.
|
||||
// Persist the observed status without claiming a notification was sent (policy suppressed it).
|
||||
func (d *Dispatcher) advanceState(state *happydns.NotificationState, newStatus happydns.Status) {
|
||||
state.LastStatus = newStatus
|
||||
if err := d.stateStore.PutState(state); err != nil {
|
||||
|
|
@ -203,8 +180,6 @@ func (d *Dispatcher) advanceState(state *happydns.NotificationState, newStatus h
|
|||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
|
@ -213,28 +188,22 @@ func (d *Dispatcher) markNotified(state *happydns.NotificationState, newStatus h
|
|||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,40 +27,24 @@ import (
|
|||
"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.
|
||||
// Reason is for logging only — callers must not branch on its 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
|
||||
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.
|
||||
// Pure predicate. nil pref means "no preference" — suppress notify, still advance state. now is injected for quiet-hour tests.
|
||||
func decide(state *happydns.NotificationState, pref *happydns.NotificationPreference, newStatus happydns.Status, now time.Time) decision {
|
||||
oldStatus := state.LastStatus
|
||||
|
||||
|
|
@ -94,9 +78,7 @@ func decide(state *happydns.NotificationState, pref *happydns.NotificationPrefer
|
|||
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.
|
||||
// Active ack means user already knows; recoveries skip this check.
|
||||
if state.Acknowledged && !clearAck && !isRecovery {
|
||||
d.Action = actionAdvance
|
||||
d.Reason = "acknowledged"
|
||||
|
|
@ -113,13 +95,18 @@ func decide(state *happydns.NotificationState, pref *happydns.NotificationPrefer
|
|||
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()
|
||||
loc := time.UTC
|
||||
if pref.Timezone != "" {
|
||||
// Validated at write time; on a stale/invalid value we silently fall back to UTC rather than firing during what the user thinks are quiet hours.
|
||||
if l, err := time.LoadLocation(pref.Timezone); err == nil {
|
||||
loc = l
|
||||
}
|
||||
}
|
||||
hour := now.In(loc).Hour()
|
||||
start := *pref.QuietStart
|
||||
end := *pref.QuietEnd
|
||||
if start <= end {
|
||||
|
|
|
|||
|
|
@ -202,3 +202,25 @@ func TestIsQuietHour(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQuietHourTimezone(t *testing.T) {
|
||||
// 02:30 UTC == 12:30 Asia/Tokyo (UTC+9), so a 9-17 quiet window in Tokyo should fire while UTC says off-hours.
|
||||
now := time.Date(2026, 4, 29, 2, 30, 0, 0, time.UTC)
|
||||
pref := &happydns.NotificationPreference{
|
||||
QuietStart: ptr(9),
|
||||
QuietEnd: ptr(17),
|
||||
Timezone: "Asia/Tokyo",
|
||||
}
|
||||
if !isQuietHour(pref, now) {
|
||||
t.Fatalf("expected quiet hour in Asia/Tokyo at local 11:30, got false")
|
||||
}
|
||||
pref.Timezone = ""
|
||||
if isQuietHour(pref, now) {
|
||||
t.Fatalf("expected non-quiet in UTC at 02:30, got true")
|
||||
}
|
||||
// Invalid TZ falls back to UTC.
|
||||
pref.Timezone = "Not/AReal_Zone"
|
||||
if isQuietHour(pref, now) {
|
||||
t.Fatalf("expected fallback to UTC for invalid timezone, got quiet hour")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,24 +34,15 @@ import (
|
|||
)
|
||||
|
||||
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.
|
||||
// On overflow, an audit record is written so back-pressure losses are visible in history.
|
||||
dispatchQueueSize = 256
|
||||
// sendTimeout caps a single Send call. Long enough for a polite remote,
|
||||
// short enough that a wedged endpoint cannot starve workers.
|
||||
// Caps a single Send so 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.
|
||||
// Bounds the persisted error to keep the audit log small.
|
||||
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 {
|
||||
|
|
@ -60,16 +51,13 @@ func truncateError(s string) string {
|
|||
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.
|
||||
// Async send fan-out; no policy — caller decides whether to enqueue.
|
||||
type Pool struct {
|
||||
registry *notifPkg.Registry
|
||||
recordStore NotificationRecordStorage
|
||||
|
|
@ -79,12 +67,10 @@ type Pool struct {
|
|||
stopped atomic.Bool
|
||||
stopOnce sync.Once
|
||||
|
||||
// nowFn is the clock used to stamp audit records. Overridable by
|
||||
// same-package tests; defaults to time.Now.
|
||||
// Overridable for tests.
|
||||
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,
|
||||
|
|
@ -94,7 +80,6 @@ func NewPool(registry *notifPkg.Registry, recordStore NotificationRecordStorage)
|
|||
}
|
||||
}
|
||||
|
||||
// Start spins up the workers. Must be called before Enqueue.
|
||||
func (p *Pool) Start() {
|
||||
for range dispatchWorkers {
|
||||
p.wg.Add(1)
|
||||
|
|
@ -102,9 +87,7 @@ func (p *Pool) Start() {
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Idempotent. Post-Stop, Enqueue is a no-op so a racing caller doesn't panic on send-to-closed-channel.
|
||||
func (p *Pool) Stop() {
|
||||
p.stopOnce.Do(func() {
|
||||
p.stopped.Store(true)
|
||||
|
|
@ -120,9 +103,7 @@ func (p *Pool) worker() {
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// On saturation, persists an audit record so the missed alert surfaces in history.
|
||||
func (p *Pool) Enqueue(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) bool {
|
||||
if p.stopped.Load() {
|
||||
return false
|
||||
|
|
@ -132,9 +113,8 @@ func (p *Pool) Enqueue(ch *happydns.NotificationChannel, payload *notifPkg.Notif
|
|||
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)
|
||||
// Saturated: record the miss rather than silently drop.
|
||||
log.Printf("notification: dispatch queue full, recording back-pressure failure for channel %q (%q)", ch.Id, ch.Type)
|
||||
p.recordSaturation(ch, payload, user)
|
||||
return false
|
||||
}
|
||||
|
|
@ -149,12 +129,11 @@ func (p *Pool) recordSaturation(ch *happydns.NotificationChannel, payload *notif
|
|||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
log.Printf("notification: failed to send via %q channel %q: %v", ch.Type, ch.Id, err)
|
||||
rec.Success = false
|
||||
rec.Error = truncateError(err.Error())
|
||||
} else {
|
||||
|
|
@ -180,8 +159,7 @@ func (p *Pool) runSend(ch *happydns.NotificationChannel, payload *notifPkg.Notif
|
|||
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.
|
||||
// Caller fills Success/Error after the send attempt.
|
||||
func newRecord(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User, sentAt time.Time) *happydns.NotificationRecord {
|
||||
return &happydns.NotificationRecord{
|
||||
UserId: user.Id,
|
||||
|
|
|
|||
|
|
@ -27,27 +27,21 @@ import (
|
|||
"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.
|
||||
// Read-only, safe 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.
|
||||
// Specificity service > domain > global; falls back to DefaultNotificationPreference so opt-in defaults flow through.
|
||||
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)
|
||||
log.Printf("notification: failed to load preferences for user %q: %v", user.Id, err)
|
||||
return happydns.DefaultNotificationPreference()
|
||||
}
|
||||
|
||||
|
|
@ -66,12 +60,10 @@ func (r *Resolver) ResolvePreference(user *happydns.User, target happydns.CheckT
|
|||
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)
|
||||
log.Printf("notification: failed to load channels for user %q: %v", user.Id, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,15 +28,7 @@ import (
|
|||
"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.
|
||||
// Per-key serialization for state read-modify-write; without it, ack and dispatcher races wipe acks or fire duplicates. In-process only — single instance.
|
||||
type StateLocker struct {
|
||||
mu sync.Mutex
|
||||
locks map[string]*stateLockEntry
|
||||
|
|
@ -47,13 +39,11 @@ type stateLockEntry struct {
|
|||
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.
|
||||
// Always defer the returned unlock — leaking it pins the map entry.
|
||||
func (l *StateLocker) Lock(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) func() {
|
||||
key := stateLockKey(checkerID, target, userId)
|
||||
|
||||
|
|
@ -80,9 +70,7 @@ func (l *StateLocker) Lock(checkerID string, target happydns.CheckTarget, userId
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Must match the storage tuple exactly; mismatch silently re-introduces the race.
|
||||
func stateLockKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string {
|
||||
return fmt.Sprintf(
|
||||
"%s|%s|%s/%s/%s",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import (
|
|||
"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)
|
||||
|
|
@ -36,7 +35,6 @@ type NotificationChannelStorage interface {
|
|||
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)
|
||||
|
|
@ -45,7 +43,6 @@ type NotificationPreferenceStorage interface {
|
|||
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
|
||||
|
|
@ -53,19 +50,16 @@ type NotificationStateStorage interface {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,20 +28,15 @@ import (
|
|||
"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.
|
||||
// Synchronous and bypasses preferences/state/quiet-hours — user explicitly verifying one channel.
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -26,144 +26,69 @@ import (
|
|||
"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.
|
||||
// Config is opaque to the model: 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"`
|
||||
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
|
||||
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
|
||||
Type NotificationChannelType `json:"type" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
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"`
|
||||
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
|
||||
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
|
||||
DomainId *Identifier `json:"domainId,omitempty" swaggertype:"string"`
|
||||
ServiceId *Identifier `json:"serviceId,omitempty" swaggertype:"string"`
|
||||
// Empty means all enabled channels.
|
||||
ChannelIds []Identifier `json:"channelIds,omitempty" swaggertype:"array,string"`
|
||||
MinStatus Status `json:"minStatus"`
|
||||
NotifyRecovery bool `json:"notifyRecovery"`
|
||||
// Hours 0-23, interpreted in Timezone (IANA name; empty means UTC).
|
||||
QuietStart *int `json:"quietStart,omitempty"`
|
||||
QuietEnd *int `json:"quietEnd,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
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").
|
||||
CheckerID string `json:"checkerId"`
|
||||
Target CheckTarget `json:"target"`
|
||||
UserId Identifier `json:"userId" swaggertype:"string"`
|
||||
LastStatus Status `json:"lastStatus"`
|
||||
LastNotifiedAt time.Time `json:"lastNotifiedAt" format:"date-time"`
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty" format:"date-time"`
|
||||
// User email or "api".
|
||||
AcknowledgedBy string `json:"acknowledgedBy,omitempty"`
|
||||
|
||||
// Annotation is a user-provided note on the acknowledgement.
|
||||
Annotation string `json:"annotation,omitempty"`
|
||||
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.
|
||||
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
|
||||
UserId Identifier `json:"userId" swaggertype:"string"`
|
||||
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"`
|
||||
ChannelId Identifier `json:"channelId" swaggertype:"string"`
|
||||
CheckerID string `json:"checkerId"`
|
||||
Target CheckTarget `json:"target"`
|
||||
OldStatus Status `json:"oldStatus"`
|
||||
NewStatus Status `json:"newStatus"`
|
||||
SentAt time.Time `json:"sentAt" format:"date-time"`
|
||||
Success bool `json:"success"`
|
||||
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.
|
||||
// Called both on explicit user clear and when a transition invalidates the ack.
|
||||
func (s *NotificationState) ClearAcknowledgement() {
|
||||
s.Acknowledged = false
|
||||
s.AcknowledgedAt = nil
|
||||
|
|
@ -171,13 +96,7 @@ func (s *NotificationState) ClearAcknowledgement() {
|
|||
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.
|
||||
// Implicit fallback when no preference is configured: opt-in at Warn+ on all enabled channels. Returned with zero Id/UserId; not persisted.
|
||||
func DefaultNotificationPreference() *NotificationPreference {
|
||||
return &NotificationPreference{
|
||||
MinStatus: StatusWarn,
|
||||
|
|
@ -186,12 +105,7 @@ func DefaultNotificationPreference() *NotificationPreference {
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Returns 2 service / 1 domain / 0 global / -1 no-match.
|
||||
func (p *NotificationPreference) MatchesTarget(target CheckTarget) int {
|
||||
if p.ServiceId != nil {
|
||||
if p.ServiceId.String() == target.ServiceId {
|
||||
|
|
|
|||
|
|
@ -43,9 +43,7 @@ import type {
|
|||
} 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.
|
||||
// Extend Writable types with Config — OpenAPI models it as free-form so the generated types omit it.
|
||||
export type NotificationChannelInput = HappydnsNotificationChannelWritable & {
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -63,9 +63,7 @@
|
|||
let saving: boolean = $state(false);
|
||||
let revealedSecrets: Record<string, boolean> = $state({});
|
||||
|
||||
// Reset form whenever the editor opens. We only depend on `open` so the
|
||||
// reset runs exactly once per opening; other props are read via untrack
|
||||
// to avoid retriggering on incidental updates (e.g. types loading).
|
||||
// Reset only on open transition; other props read via untrack so type loading doesn't retrigger.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
untrack(() => {
|
||||
|
|
@ -90,8 +88,7 @@
|
|||
});
|
||||
|
||||
function onTypeChange() {
|
||||
// When the user picks a different type, reset config to the defaults
|
||||
// for the new schema so we never send fields that don't apply.
|
||||
// Reset to schema defaults so we never send fields that don't apply.
|
||||
const schema = getChannelConfigSchema(type);
|
||||
config = schema ? emptyConfigForSchema(schema) : {};
|
||||
rawJson = JSON.stringify(config, null, 2);
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@
|
|||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// FieldKind drives ChannelEditor's per-field rendering. Keep the set small;
|
||||
// anything more exotic should fall through to the generic JSON editor.
|
||||
// Keep the set small — anything exotic falls through to the raw JSON editor.
|
||||
export type FieldKind = "text" | "url" | "secret" | "headers";
|
||||
|
||||
export interface ChannelConfigField {
|
||||
|
|
@ -35,11 +34,7 @@ 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.
|
||||
// Field names mirror JSON tags in internal/notification/*_sender.go.
|
||||
export const CHANNEL_CONFIG_SCHEMAS: Record<string, ChannelConfigSchema> = {
|
||||
email: {
|
||||
fields: [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue