init
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user