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.
This commit is contained in:
nemunaire 2026-04-29 20:57:03 +07:00
commit ab97185611
3 changed files with 368 additions and 0 deletions

View file

@ -0,0 +1,101 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package notification
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"git.happydns.org/happyDomain/model"
)
// Shared 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,
}
}
// 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 {
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
}
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.",
}
}

View file

@ -0,0 +1,132 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package notification
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"syscall"
"time"
)
// We drain the response only for keep-alive; body is unused.
const maxResponseBodyBytes = 64 * 1024
var errBlockedAddress = errors.New("address resolves to a blocked range")
// 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 {
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
}
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
}
// 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 {
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)
}
// Refuses private/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
},
}
}

View file

@ -0,0 +1,135 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package notification
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strings"
"time"
"git.happydns.org/happyDomain/model"
)
// Reserved by the HTTP client or used to spoof outbound identity (smuggling/host-routing risk).
var disallowedWebhookHeaders = map[string]struct{}{
"host": {},
"content-length": {},
"content-encoding": {},
"transfer-encoding": {},
"connection": {},
"upgrade": {},
"te": {},
"trailer": {},
}
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"
type WebhookConfig struct {
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
// HMAC-SHA256 signing key.
Secret string `json:"secret,omitempty"`
// Set only by RedactConfig — never stored or accepted on input.
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
}
// dashboardURL is captured here — server identity, not per-notification data.
type WebhookSender struct {
client *http.Client
dashboardURL string
}
func NewWebhookSender(dashboardURL string) *WebhookSender {
return &WebhookSender{
client: newSafeHTTPClient(10 * time.Second),
dashboardURL: dashboardURL,
}
}
func (s *WebhookSender) Type() happydns.NotificationChannelType { return ChannelTypeWebhook }
func (s *WebhookSender) RedactConfig(cfg WebhookConfig) WebhookConfig {
cfg.HasSecret = cfg.Secret != ""
cfg.Secret = ""
return cfg
}
// 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
}
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: catches stored channels that pre-date Validate().
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)))
}
})
}