feat: implement ntfy-discord bridge in Go
- SSE subscription to ntfy with auto-reconnect - Discord webhook integration with embed formatting - Priority to color mapping, tag to emoji conversion - Native HashiCorp Vault support (Kubernetes + token auth) - Hot reload secrets via fsnotify or Vault polling - Prometheus metrics (/metrics endpoint) - Health/ready endpoints for Kubernetes probes - Comprehensive unit tests and fuzz tests - Multi-stage Docker build (~10MB scratch image) - CI/CD pipeline for Gitea Actions
This commit is contained in:
137
internal/ntfy/client.go
Normal file
137
internal/ntfy/client.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package ntfy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message represents an ntfy message
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
Time int64 `json:"time"`
|
||||
Expires int64 `json:"expires,omitempty"`
|
||||
Event string `json:"event"`
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Click string `json:"click,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
// Client subscribes to ntfy topics via SSE
|
||||
type Client struct {
|
||||
baseURL string
|
||||
topics []string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new ntfy SSE client
|
||||
func NewClient(baseURL string, topics []string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
topics: topics,
|
||||
client: &http.Client{
|
||||
Timeout: 0, // No timeout for SSE
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe connects to ntfy and streams messages to the channel
|
||||
// It automatically reconnects with exponential backoff on failure
|
||||
func (c *Client) Subscribe(ctx context.Context, msgCh chan<- Message) {
|
||||
backoff := time.Second
|
||||
maxBackoff := time.Minute
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
err := c.connect(ctx, msgCh)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return // Context cancelled
|
||||
}
|
||||
slog.Error("ntfy connection failed", "error", err, "backoff", backoff)
|
||||
time.Sleep(backoff)
|
||||
backoff = min(backoff*2, maxBackoff)
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset backoff on successful connection
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) connect(ctx context.Context, msgCh chan<- Message) error {
|
||||
// Build URL with all topics
|
||||
topicPath := strings.Join(c.topics, ",")
|
||||
url := fmt.Sprintf("%s/%s/json", c.baseURL, topicPath)
|
||||
|
||||
slog.Info("connecting to ntfy", "url", url)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
slog.Info("connected to ntfy", "topics", c.topics)
|
||||
|
||||
// Read line by line (ntfy sends newline-delimited JSON)
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg Message
|
||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||
slog.Warn("failed to parse ntfy message", "error", err, "line", line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip keepalive/open events
|
||||
if msg.Event == "keepalive" || msg.Event == "open" {
|
||||
slog.Debug("ntfy event", "event", msg.Event)
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Event == "message" {
|
||||
slog.Debug("received ntfy message",
|
||||
"id", msg.ID,
|
||||
"topic", msg.Topic,
|
||||
"title", msg.Title,
|
||||
)
|
||||
msgCh <- msg
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("read stream: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("stream closed")
|
||||
}
|
||||
227
internal/ntfy/client_test.go
Normal file
227
internal/ntfy/client_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package ntfy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
client := NewClient("http://ntfy.example.com", []string{"alerts", "updates"})
|
||||
|
||||
if client.baseURL != "http://ntfy.example.com" {
|
||||
t.Errorf("baseURL = %s, want http://ntfy.example.com", client.baseURL)
|
||||
}
|
||||
|
||||
if len(client.topics) != 2 {
|
||||
t.Errorf("topics length = %d, want 2", len(client.topics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
|
||||
client := NewClient("http://ntfy.example.com/", []string{"test"})
|
||||
|
||||
if client.baseURL != "http://ntfy.example.com" {
|
||||
t.Errorf("baseURL = %s, want http://ntfy.example.com (trailing slash removed)", client.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessage_JSON(t *testing.T) {
|
||||
msg := Message{
|
||||
ID: "test-123",
|
||||
Time: 1706803200,
|
||||
Event: "message",
|
||||
Topic: "alerts",
|
||||
Title: "Test Alert",
|
||||
Message: "This is a test",
|
||||
Priority: 4,
|
||||
Tags: []string{"warning", "fire"},
|
||||
Click: "https://example.com",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if decoded.ID != msg.ID {
|
||||
t.Errorf("ID = %s, want %s", decoded.ID, msg.ID)
|
||||
}
|
||||
|
||||
if decoded.Priority != msg.Priority {
|
||||
t.Errorf("Priority = %d, want %d", decoded.Priority, msg.Priority)
|
||||
}
|
||||
|
||||
if len(decoded.Tags) != 2 {
|
||||
t.Errorf("Tags length = %d, want 2", len(decoded.Tags))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Subscribe_ReceivesMessages(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Event: "open"},
|
||||
{Event: "message", ID: "msg1", Topic: "test", Title: "First", Message: "Hello"},
|
||||
{Event: "keepalive"},
|
||||
{Event: "message", ID: "msg2", Topic: "test", Title: "Second", Message: "World"},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
for _, msg := range messages {
|
||||
data, _ := json.Marshal(msg)
|
||||
fmt.Fprintf(w, "%s\n", data)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, []string{"test"})
|
||||
msgCh := make(chan Message, 10)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
go client.Subscribe(ctx, msgCh)
|
||||
|
||||
// Should receive only "message" events
|
||||
received := make([]Message, 0)
|
||||
timeout := time.After(1 * time.Second)
|
||||
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case msg := <-msgCh:
|
||||
received = append(received, msg)
|
||||
if len(received) >= 2 {
|
||||
break loop
|
||||
}
|
||||
case <-timeout:
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
if len(received) != 2 {
|
||||
t.Errorf("received %d messages, want 2", len(received))
|
||||
}
|
||||
|
||||
if len(received) > 0 && received[0].ID != "msg1" {
|
||||
t.Errorf("first message ID = %s, want msg1", received[0].ID)
|
||||
}
|
||||
|
||||
if len(received) > 1 && received[1].ID != "msg2" {
|
||||
t.Errorf("second message ID = %s, want msg2", received[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Subscribe_FilterEvents(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Event: "open"},
|
||||
{Event: "keepalive"},
|
||||
{Event: "message", ID: "actual", Topic: "test"},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
for _, msg := range messages {
|
||||
data, _ := json.Marshal(msg)
|
||||
fmt.Fprintf(w, "%s\n", data)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, []string{"test"})
|
||||
msgCh := make(chan Message, 10)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
go client.Subscribe(ctx, msgCh)
|
||||
|
||||
select {
|
||||
case msg := <-msgCh:
|
||||
if msg.Event != "message" {
|
||||
t.Errorf("received event = %s, want message", msg.Event)
|
||||
}
|
||||
if msg.ID != "actual" {
|
||||
t.Errorf("message ID = %s, want actual", msg.ID)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Error("timeout waiting for message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Subscribe_ContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Slow server - keeps connection open
|
||||
time.Sleep(10 * time.Second)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, []string{"test"})
|
||||
msgCh := make(chan Message, 10)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
client.Subscribe(ctx, msgCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Cancel quickly
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
// Should exit promptly
|
||||
select {
|
||||
case <-done:
|
||||
// Success
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Subscribe did not exit after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_connect_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("internal error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, []string{"test"})
|
||||
msgCh := make(chan Message, 10)
|
||||
|
||||
err := client.connect(context.Background(), msgCh)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for server error response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_connect_URLConstruction(t *testing.T) {
|
||||
var requestedURL string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedURL = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, []string{"alerts", "updates"})
|
||||
client.connect(context.Background(), make(chan Message))
|
||||
|
||||
expected := "/alerts,updates/json"
|
||||
if requestedURL != expected {
|
||||
t.Errorf("URL path = %s, want %s", requestedURL, expected)
|
||||
}
|
||||
}
|
||||
58
internal/ntfy/fuzz_test.go
Normal file
58
internal/ntfy/fuzz_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package ntfy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzParseMessage tests that JSON unmarshaling doesn't panic on arbitrary input
|
||||
func FuzzParseMessage(f *testing.F) {
|
||||
// Seed corpus with valid messages
|
||||
f.Add(`{"event":"message","topic":"test","title":"hello","message":"world"}`)
|
||||
f.Add(`{"event":"message","topic":"alerts","priority":5,"tags":["warning"]}`)
|
||||
f.Add(`{"event":"keepalive"}`)
|
||||
f.Add(`{"event":"open"}`)
|
||||
f.Add(`{}`)
|
||||
f.Add(`{"id":"abc123","time":1706803200,"expires":1706889600}`)
|
||||
f.Add(`{"click":"https://example.com","icon":"https://example.com/icon.png"}`)
|
||||
|
||||
// Edge cases
|
||||
f.Add(``)
|
||||
f.Add(`null`)
|
||||
f.Add(`[]`)
|
||||
f.Add(`"string"`)
|
||||
f.Add(`123`)
|
||||
f.Add(`{"priority":-1}`)
|
||||
f.Add(`{"priority":999999999}`)
|
||||
f.Add(`{"tags":[]}`)
|
||||
f.Add(`{"tags":["","",""]}`)
|
||||
|
||||
f.Fuzz(func(t *testing.T, data string) {
|
||||
var msg Message
|
||||
// Should never panic regardless of input
|
||||
_ = json.Unmarshal([]byte(data), &msg)
|
||||
|
||||
// If it parsed, accessing fields should not panic
|
||||
_ = msg.ID
|
||||
_ = msg.Event
|
||||
_ = msg.Topic
|
||||
_ = msg.Title
|
||||
_ = msg.Message
|
||||
_ = msg.Priority
|
||||
_ = msg.Click
|
||||
_ = len(msg.Tags)
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzParseMessageBytes tests binary input doesn't cause panics
|
||||
func FuzzParseMessageBytes(f *testing.F) {
|
||||
f.Add([]byte(`{"event":"message"}`))
|
||||
f.Add([]byte{0x00})
|
||||
f.Add([]byte{0xff, 0xfe})
|
||||
f.Add([]byte("\xef\xbb\xbf{}")) // BOM + JSON
|
||||
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
var msg Message
|
||||
_ = json.Unmarshal(data, &msg)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user