This commit is contained in:
2026-09-16 06:26:16 +02:00
parent 95b0779db0
commit 4f8e0bbb04
51 changed files with 6290 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
// Package outbound implements HTTP delivery independently of ingress protocols.
package outbound
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/example/notify-gateway/internal/config"
)
type Response struct {
StatusCode int
Body string
Retryable bool
RetryAfter time.Duration
}
func Send(ctx context.Context, destination config.OutboundConfig, payload map[string]any) (Response, error) {
return SendWithID(ctx, destination, payload, "")
}
func SendWithID(ctx context.Context, destination config.OutboundConfig, payload map[string]any, id string) (Response, error) {
if err := config.ValidateOutbound(destination); err != nil {
return Response{}, err
}
b, err := json.Marshal(payload)
if err != nil {
return Response{}, fmt.Errorf("encode outbound payload: %w", err)
}
if !destination.Live {
preview, _ := json.Marshal(map[string]any{"dry_run": true, "provider": destination.Provider, "body": payload})
return Response{StatusCode: http.StatusOK, Body: string(preview)}, nil
}
if destination.Provider == "smtp" {
return sendSMTP(ctx, destination, payload, id)
}
token, err := config.Secret(destination.BearerToken)
if err != nil {
return Response{}, err
}
if destination.Provider == "ntfy" {
p := map[string]any{"topic": destination.Topic, "title": payload["title"], "message": payload["message"]}
priority := 0
switch v := payload["priority"].(type) {
case int:
priority = v
case float64:
priority = int(v)
}
if priority > 0 {
p["priority"] = min(priority, 5)
}
b, err = json.Marshal(p)
if err != nil {
return Response{}, err
}
}
u, _ := url.Parse(destination.URL)
if destination.Provider == "discord" {
q := u.Query()
q.Set("wait", "true")
u.RawQuery = q.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(b))
if err != nil {
return Response{}, fmt.Errorf("outbound request could not be created")
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
if destination.Provider == "gotify" {
req.Header.Set("X-Gotify-Key", token)
} else {
req.Header.Set("Authorization", "Bearer "+token)
}
}
if id != "" {
req.Header.Set("Idempotency-Key", id)
req.Header.Set("X-Notify-Gateway-Delivery", id)
}
timeout := destination.TimeoutS
if timeout == 0 {
timeout = 15
}
client := &http.Client{
Timeout: time.Duration(timeout) * time.Second,
// Never forward webhook credentials to a redirect destination.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Do(req)
if err != nil {
// url.Error embeds the URL, including Discord's secret token.
if ctx.Err() != nil {
return Response{Retryable: true}, ctx.Err()
}
return Response{Retryable: true}, fmt.Errorf("outbound HTTP request failed (connection or timeout)")
}
defer resp.Body.Close()
_, readErr := io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
out := Response{StatusCode: resp.StatusCode, Retryable: RetryableHTTP(resp.StatusCode), RetryAfter: ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now())}
// Remote bodies may echo credentials or content. Only expose the status.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return out, fmt.Errorf("outbound HTTP %d", resp.StatusCode)
}
if readErr != nil {
out.Retryable = true
return out, fmt.Errorf("outbound response could not be read")
}
return out, nil
}
func RetryableHTTP(code int) bool {
return code == 0 || code == 408 || code == 425 || code == 429 || code >= 500
}
func ParseRetryAfter(value string, now time.Time) time.Duration {
if n, err := strconv.ParseFloat(value, 64); err == nil && n > 0 && n <= 86400 {
return time.Duration(n * float64(time.Second))
}
if t, err := http.ParseTime(value); err == nil && t.After(now) {
return min(t.Sub(now), 24*time.Hour)
}
return 0
}
+118
View File
@@ -0,0 +1,118 @@
package outbound
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/example/notify-gateway/internal/config"
)
func TestWebhookDeliveryAndDryRun(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Method != "POST" || r.Header.Get("Authorization") != "Bearer private-token" || r.Header.Get("Content-Type") != "application/json" {
t.Error("incorrect outbound request")
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload["message"] != "Grüße" {
t.Error("incorrect payload")
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
o := config.OutboundConfig{ID: "test", Provider: "webhook", URL: srv.URL, BearerToken: "private-token"}
r, err := Send(context.Background(), o, map[string]any{"message": "Grüße"})
if err != nil || calls != 0 || !strings.Contains(r.Body, `"dry_run":true`) || strings.Contains(r.Body, "private-token") {
t.Fatalf("dry-run: %+v %v calls=%d", r, err, calls)
}
o.Live = true
r, err = Send(context.Background(), o, map[string]any{"message": "Grüße"})
if err != nil || r.StatusCode != 204 || calls != 1 {
t.Fatalf("live: %+v %v calls=%d", r, err, calls)
}
}
func TestHTTPFailuresAndRedirectsDoNotLeakSecrets(t *testing.T) {
for _, status := range []int{302, 400, 429, 500} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Location", "/secret")
w.WriteHeader(status)
fmt.Fprint(w, "private-token")
}))
defer srv.Close()
r, err := Send(context.Background(), config.OutboundConfig{ID: "test", Provider: "webhook", URL: srv.URL + "/private-token", Live: true}, map[string]any{})
if err == nil || r.StatusCode != status || calls != 1 || r.Body != "" || strings.Contains(err.Error(), "private-token") {
t.Fatalf("result=%+v err=%v calls=%d", r, err, calls)
}
})
}
}
type transportFunc func(*http.Request) (*http.Response, error)
func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestDiscordWaitAndTransportErrorRedaction(t *testing.T) {
previous := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = previous })
http.DefaultTransport = transportFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Query().Get("wait") != "true" || r.URL.Query().Get("thread_id") != "123" {
t.Error("Discord query parameters missing")
}
return nil, fmt.Errorf("failure at %s", r.URL)
})
_, err := Send(context.Background(), config.OutboundConfig{ID: "discord", Provider: "discord", URL: "https://discord.com/api/webhooks/123/private-token?thread_id=123&wait=false", Live: true}, map[string]any{"content": "hello"})
if err == nil || strings.Contains(err.Error(), "private-token") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := Send(ctx, config.OutboundConfig{ID: "test", Provider: "webhook", URL: "http://127.0.0.1:1/private-token", Live: true}, map[string]any{})
if err != context.Canceled {
t.Fatalf("error=%v", err)
}
}
func TestNtfyAndGotifyProtocols(t *testing.T) {
for _, provider := range []string{"ntfy", "gotify"} {
t.Run(provider, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if payload["title"] != "Test" || payload["message"] != "Hello" {
t.Error(payload)
}
if provider == "ntfy" {
if payload["topic"] != "ops" || payload["priority"] != float64(5) || r.Header.Get("Authorization") != "Bearer secret" {
t.Error("ntfy protocol", payload)
}
} else {
if r.Header.Get("X-Gotify-Key") != "secret" || r.Header.Get("Authorization") != "" {
t.Error("gotify auth")
}
}
w.WriteHeader(200)
}))
defer srv.Close()
t.Setenv("TEST_PROVIDER_TOKEN", "secret")
_, err := Send(context.Background(), config.OutboundConfig{ID: provider, Provider: provider, URL: srv.URL, Topic: "ops", BearerToken: "env:TEST_PROVIDER_TOKEN", Live: true}, map[string]any{"title": "Test", "message": "Hello", "priority": 9})
if err != nil {
t.Fatal(err)
}
})
}
}
+123
View File
@@ -0,0 +1,123 @@
package outbound
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"mime"
"net"
"net/smtp"
"net/textproto"
"strconv"
"strings"
"time"
"github.com/example/notify-gateway/internal/config"
)
func mailBytes(o config.OutboundConfig, p map[string]any, id string) []byte {
title, _ := p["title"].(string)
message, _ := p["message"].(string)
title = strings.NewReplacer("\r", " ", "\n", " ").Replace(title)
// Force base64 for non-ASCII bodies and wrap per MIME line-length limits.
encoded := base64.StdEncoding.EncodeToString([]byte(message))
var body strings.Builder
for len(encoded) > 76 {
body.WriteString(encoded[:76] + "\r\n")
encoded = encoded[76:]
}
body.WriteString(encoded + "\r\n")
headers := []string{"From: " + o.From, "To: " + strings.Join(o.To, ", "), "Subject: " + mime.QEncoding.Encode("utf-8", title), "Date: " + time.Now().Format(time.RFC1123Z), "MIME-Version: 1.0", "Content-Type: text/plain; charset=utf-8", "Content-Transfer-Encoding: base64", "Auto-Submitted: auto-generated", "X-Notify-Gateway: 1"}
if id != "" {
headers = append(headers, "Message-ID: <"+id+"@notify-gateway.local>")
}
return []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + body.String())
}
func sendSMTP(ctx context.Context, o config.OutboundConfig, p map[string]any, id string) (resp Response, err error) {
return sendSMTPWithTLS(ctx, o, p, id, &tls.Config{ServerName: o.SMTPHost, MinVersion: tls.VersionTLS12})
}
func sendSMTPWithTLS(ctx context.Context, o config.OutboundConfig, p map[string]any, id string, tlsConfig *tls.Config) (resp Response, err error) {
defer func() {
if err != nil {
var e *textproto.Error
if errors.As(err, &e) {
resp.StatusCode = e.Code
resp.Retryable = e.Code >= 400 && e.Code < 500
} else {
resp.Retryable = true
}
err = errors.New("SMTP delivery failed")
}
}()
password, err := config.Secret(o.Password)
if err != nil {
return Response{}, err
}
timeout := o.TimeoutS
if timeout == 0 {
timeout = 15
}
dialer := net.Dialer{Timeout: time.Duration(timeout) * time.Second}
address := net.JoinHostPort(o.SMTPHost, strconv.Itoa(o.SMTPPort))
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return resp, err
}
defer conn.Close()
rawConn := conn
stop := context.AfterFunc(ctx, func() { rawConn.Close() })
defer stop()
if err = conn.SetDeadline(time.Now().Add(time.Duration(timeout) * time.Second)); err != nil {
return resp, err
}
if o.TLSMode == "tls" {
secure := tls.Client(conn, tlsConfig)
if err = secure.HandshakeContext(ctx); err != nil {
return resp, err
}
conn = secure
}
c, err := smtp.NewClient(conn, o.SMTPHost)
if err != nil {
return resp, err
}
defer c.Close()
if o.TLSMode == "starttls" {
if ok, _ := c.Extension("STARTTLS"); !ok {
return resp, fmt.Errorf("STARTTLS required")
}
if err = c.StartTLS(tlsConfig); err != nil {
return resp, err
}
}
if o.Username != "" {
if err = c.Auth(smtp.PlainAuth("", o.Username, password, o.SMTPHost)); err != nil {
return resp, err
}
}
if err = c.Mail(o.From); err != nil {
return resp, err
}
for _, to := range o.To {
if err = c.Rcpt(to); err != nil {
return resp, err
}
}
w, err := c.Data()
if err != nil {
return resp, err
}
if _, err = w.Write(mailBytes(o, p, id)); err != nil {
return resp, err
}
if err = w.Close(); err != nil {
return resp, err
}
// DATA has been acknowledged. A failed QUIT must not cause another delivery.
_ = c.Quit()
return Response{StatusCode: 250}, nil
}
+135
View File
@@ -0,0 +1,135 @@
package outbound
import (
"context"
"crypto/tls"
"encoding/base64"
"io"
"net"
"net/mail"
"net/textproto"
"strconv"
"strings"
"testing"
"github.com/example/notify-gateway/internal/config"
"github.com/example/notify-gateway/internal/testutil"
)
func smtpFixture(t *testing.T, mode string, rcptCode int) (config.OutboundConfig, *tls.Config, <-chan []byte) {
t.Helper()
serverTLS, clientTLS := testutil.TLSConfigs()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
received := make(chan []byte, 1)
done := make(chan struct{})
go func() {
defer close(done)
raw, err := listener.Accept()
if err != nil {
return
}
defer raw.Close()
var conn net.Conn = raw
if mode == "tls" {
conn = tls.Server(raw, serverTLS)
}
tp := textproto.NewConn(conn)
tp.PrintfLine("220 test SMTP")
for {
line, err := tp.ReadLine()
if err != nil {
return
}
verb := strings.Split(line, " ")[0]
switch verb {
case "EHLO":
if mode == "starttls" {
tp.PrintfLine("250-test\r\n250-STARTTLS\r\n250 AUTH PLAIN")
} else {
tp.PrintfLine("250-test\r\n250 AUTH PLAIN")
}
case "STARTTLS":
tp.PrintfLine("220 upgrade")
conn = tls.Server(raw, serverTLS)
tp = textproto.NewConn(conn)
case "AUTH":
data, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(line, "AUTH PLAIN "))
if string(data) != "\x00user\x00password" {
t.Error("unexpected SMTP auth")
}
tp.PrintfLine("235 OK")
case "MAIL":
tp.PrintfLine("250 OK")
case "RCPT":
tp.PrintfLine("%d recipient", rcptCode)
case "DATA":
tp.PrintfLine("354 data")
b, err := tp.ReadDotBytes()
if err != nil {
return
}
received <- b
tp.PrintfLine("250 accepted")
case "QUIT":
return // A lost QUIT response must still count as success.
default:
tp.PrintfLine("500 unsupported")
}
}
}()
t.Cleanup(func() { listener.Close(); <-done })
host, port, _ := net.SplitHostPort(listener.Addr().String())
p, _ := strconv.Atoi(port)
return config.OutboundConfig{ID: "mail", Provider: "smtp", SMTPHost: host, SMTPPort: p, TLSMode: mode, Username: "user", Password: "password", From: "sender@example.org", To: []string{"to@example.org"}, Live: true, TimeoutS: 2}, clientTLS, received
}
func TestSMTPEncryptedDeliveryAndMIME(t *testing.T) {
for _, mode := range []string{"tls", "starttls"} {
t.Run(mode, func(t *testing.T) {
o, tlsConfig, received := smtpFixture(t, mode, 250)
r, err := sendSMTPWithTLS(context.Background(), o, map[string]any{"title": "Grüße\r\nBcc: bad@example.org", "message": "Hallo Welt äöü"}, "stable-id", tlsConfig)
if err != nil || r.StatusCode != 250 {
t.Fatalf("%+v %v", r, err)
}
msg, err := mail.ReadMessage(strings.NewReader(string(<-received)))
if err != nil {
t.Fatal(err)
}
if msg.Header.Get("Bcc") != "" || msg.Header.Get("Message-ID") != "<stable-id@notify-gateway.local>" || msg.Header.Get("Auto-Submitted") != "auto-generated" {
t.Fatal("unsafe/missing headers")
}
body, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, msg.Body))
if err != nil || string(body) != "Hallo Welt äöü" {
t.Fatalf("body=%s %v", body, err)
}
})
}
}
func TestSMTPFailures(t *testing.T) {
for _, code := range []int{450, 550} {
t.Run(strconv.Itoa(code), func(t *testing.T) {
o, tlsConfig, _ := smtpFixture(t, "tls", code)
r, err := sendSMTPWithTLS(context.Background(), o, map[string]any{"message": "test"}, "id", tlsConfig)
if err == nil || r.StatusCode != code || r.Retryable != (code == 450) {
t.Fatalf("%+v %v", r, err)
}
})
}
t.Run("untrusted certificate", func(t *testing.T) {
o, _, _ := smtpFixture(t, "tls", 250)
if _, err := sendSMTP(context.Background(), o, map[string]any{"message": "test"}, "id"); err == nil {
t.Fatal("untrusted certificate accepted")
}
})
t.Run("no STARTTLS", func(t *testing.T) {
o, tlsConfig, _ := smtpFixture(t, "plain", 250)
o.TLSMode = "starttls"
if _, err := sendSMTPWithTLS(context.Background(), o, map[string]any{"message": "test"}, "id", tlsConfig); err == nil {
t.Fatal("plaintext SMTP accepted")
}
})
}