Files
DoGaMa-serv/internal/notification/service.go
T

426 lines
13 KiB
Go

// Package notification manages encrypted channels and bounded asynchronous delivery.
package notification
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/smtp"
"net/url"
"strconv"
"strings"
"time"
)
type Channel struct {
ID, Name, Type string
Enabled bool
Events []string
Configured bool
}
type Input struct {
Name, Type string
Enabled bool
Events []string
Config map[string]string
}
type Event struct{ Type, Title, Message, InstanceName, OperationID string }
type Service struct {
db *sql.DB
aead cipher.AEAD
client *http.Client
resolver *net.Resolver
now func() time.Time
}
func New(db *sql.DB, key []byte) (*Service, error) {
if len(key) != 32 {
return nil, errors.New("notification encryption key must be exactly 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
s := &Service{db: db, aead: aead, resolver: net.DefaultResolver, now: time.Now}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = s.dialSafe
s.client = &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
return s.validateURL(req.Context(), req.URL)
}}
return s, nil
}
func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, error) {
if strings.TrimSpace(in.Name) == "" || !validType(in.Type) {
return Channel{}, errors.New("invalid notification channel")
}
if err := validateConfigShape(in.Type, in.Config); err != nil {
return Channel{}, err
}
encrypted, err := s.seal(in.Config)
if err != nil {
return Channel{}, err
}
events, _ := json.Marshal(normalizeEvents(in.Events))
now := s.now().UTC().Format(time.RFC3339Nano)
if id == "" {
id = randomID()
}
_, err = s.db.ExecContext(ctx, `INSERT INTO notification_channels(id,name,type,enabled,encrypted_config,event_filter_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,type=excluded.type,enabled=excluded.enabled,encrypted_config=excluded.encrypted_config,event_filter_json=excluded.event_filter_json,updated_at=excluded.updated_at`, id, strings.TrimSpace(in.Name), in.Type, in.Enabled, encrypted, string(events), now, now)
if err != nil {
return Channel{}, fmt.Errorf("save notification channel: %w", err)
}
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true}, nil
}
func (s *Service) List(ctx context.Context) ([]Channel, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,name,type,enabled,event_filter_json,length(encrypted_config)>0 FROM notification_channels ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Channel
for rows.Next() {
var c Channel
var body string
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Enabled, &body, &c.Configured); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(body), &c.Events)
out = append(out, c)
}
return out, rows.Err()
}
func (s *Service) Delete(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM notification_channels WHERE id=?`, id)
return err
}
func (s *Service) Queue(ctx context.Context, event Event) error {
if !validEvent(event.Type) || len(event.Message) > 1000 {
return errors.New("invalid notification event")
}
payload, _ := json.Marshal(event)
rows, err := s.db.QueryContext(ctx, `SELECT id,event_filter_json FROM notification_channels WHERE enabled=1`)
if err != nil {
return err
}
var channelIDs []string
for rows.Next() {
var id, filter string
if err := rows.Scan(&id, &filter); err != nil {
rows.Close()
return err
}
var events []string
_ = json.Unmarshal([]byte(filter), &events)
if matches(events, event.Type) {
channelIDs = append(channelIDs, id)
}
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
now := s.now().UTC().Format(time.RFC3339Nano)
for _, id := range channelIDs {
if _, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now); err != nil {
return err
}
}
return nil
}
func (s *Service) Test(ctx context.Context, id string) error {
return s.queueForChannel(ctx, id, Event{Type: "notification.test", Title: "DoGaMa test notification", Message: "This is a test notification from DoGaMa."})
}
func (s *Service) queueForChannel(ctx context.Context, id string, event Event) error {
var enabled bool
if err := s.db.QueryRowContext(ctx, `SELECT enabled FROM notification_channels WHERE id=?`, id).Scan(&enabled); err != nil {
return err
}
payload, _ := json.Marshal(event)
now := s.now().UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now)
return err
}
func (s *Service) RunDue(ctx context.Context) error {
rows, err := s.db.QueryContext(ctx, `SELECT d.id,d.attempt,d.payload_redacted,c.type,c.encrypted_config FROM notification_deliveries d JOIN notification_channels c ON c.id=d.channel_id WHERE d.status IN ('queued','retrying') AND d.next_attempt_at<=? ORDER BY d.next_attempt_at LIMIT 20`, s.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
type job struct {
id, typ, payload string
attempt int
encrypted []byte
}
var jobs []job
for rows.Next() {
var j job
if err := rows.Scan(&j.id, &j.attempt, &j.payload, &j.typ, &j.encrypted); err != nil {
rows.Close()
return err
}
jobs = append(jobs, j)
}
rows.Close()
for _, j := range jobs {
config, e := s.open(j.encrypted)
if e == nil {
e = s.deliver(ctx, j.id, j.typ, config, []byte(j.payload))
}
attempt := j.attempt + 1
if e == nil {
if _, updateErr := s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status='succeeded',attempt=?,completed_at=?,last_error_code='' WHERE id=?`, attempt, s.now().UTC().Format(time.RFC3339Nano), j.id); updateErr != nil {
return updateErr
}
} else {
status := "retrying"
if attempt >= 5 {
status = "failed"
}
delay := time.Duration(1<<min(attempt, 6)) * time.Minute
_, _ = s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status=?,attempt=?,next_attempt_at=?,last_error_code=? WHERE id=?`, status, attempt, s.now().UTC().Add(delay).Format(time.RFC3339Nano), errorCode(e), j.id)
}
}
return nil
}
func (s *Service) deliver(ctx context.Context, id, typ string, c map[string]string, payload []byte) error {
if typ == "email" {
host := c["host"]
port := c["port"]
if port == "" {
port = "587"
}
addr := net.JoinHostPort(host, port)
var auth smtp.Auth
if c["username"] != "" {
auth = smtp.PlainAuth("", c["username"], c["password"], host)
}
msg := []byte("To: " + c["to"] + "\r\nSubject: DoGaMa notification\r\nContent-Type: application/json\r\n\r\n" + string(payload))
return sendSMTP(ctx, addr, host, auth, c["from"], strings.Split(c["to"], ","), msg)
}
u, err := url.Parse(c["url"])
if err != nil {
return err
}
if err = s.validateURL(ctx, u); err != nil {
return err
}
body := payload
if typ == "discord" {
var event Event
_ = json.Unmarshal(payload, &event)
body, _ = json.Marshal(map[string]string{"content": event.Title + "\n" + event.Message})
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-DoGaMa-Event-ID", id)
timestamp := strconv.FormatInt(s.now().Unix(), 10)
req.Header.Set("X-DoGaMa-Timestamp", timestamp)
if secret := c["signing_secret"]; secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(timestamp + "." + string(body)))
req.Header.Set("X-DoGaMa-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("remote_status_%d", resp.StatusCode)
}
return nil
}
func (s *Service) validateURL(ctx context.Context, u *url.URL) error {
if u.Scheme != "https" || u.User != nil || u.Hostname() == "" {
return errors.New("unsafe_destination")
}
ips, err := s.resolver.LookupNetIP(ctx, "ip", u.Hostname())
if err != nil {
return errors.New("destination_resolution_failed")
}
for _, ip := range ips {
if unsafeIP(ip) {
return errors.New("unsafe_destination")
}
}
return nil
}
func (s *Service) dialSafe(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, errors.New("unsafe_destination")
}
ips, err := s.resolver.LookupNetIP(ctx, "ip", host)
if err != nil || len(ips) == 0 {
return nil, errors.New("destination_resolution_failed")
}
dialer := net.Dialer{Timeout: 10 * time.Second}
for _, ip := range ips {
if unsafeIP(ip) {
return nil, errors.New("unsafe_destination")
}
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return connection, nil
}
}
return nil, errors.New("delivery_failed")
}
func sendSMTP(ctx context.Context, address, host string, auth smtp.Auth, from string, recipients []string, message []byte) error {
dialer := net.Dialer{Timeout: 10 * time.Second}
connection, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return err
}
client, err := smtp.NewClient(connection, host)
if err != nil {
_ = connection.Close()
return err
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); !ok {
return errors.New("smtp_tls_required")
}
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return err
}
if auth != nil {
if err := client.Auth(auth); err != nil {
return err
}
}
if err := client.Mail(from); err != nil {
return err
}
for _, recipient := range recipients {
if err := client.Rcpt(strings.TrimSpace(recipient)); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
if _, err = w.Write(message); err != nil {
_ = w.Close()
return err
}
if err = w.Close(); err != nil {
return err
}
return client.Quit()
}
func unsafeIP(ip netip.Addr) bool {
return !ip.IsValid() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified()
}
func (s *Service) seal(config map[string]string) ([]byte, error) {
body, _ := json.Marshal(config)
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return s.aead.Seal(nonce, nonce, body, nil), nil
}
func (s *Service) open(body []byte) (map[string]string, error) {
n := s.aead.NonceSize()
if len(body) < n {
return nil, errors.New("invalid encrypted channel")
}
plain, err := s.aead.Open(nil, body[:n], body[n:], nil)
if err != nil {
return nil, err
}
var out map[string]string
err = json.Unmarshal(plain, &out)
return out, err
}
func validType(v string) bool { return v == "email" || v == "webhook" || v == "discord" }
func validEvent(v string) bool {
return v == "notification.test" || strings.HasSuffix(v, ".failed") || strings.HasSuffix(v, ".completed") || strings.HasSuffix(v, ".required")
}
func validateConfigShape(typ string, c map[string]string) error {
if typ == "email" {
if c["host"] == "" || c["from"] == "" || c["to"] == "" {
return errors.New("email host, from and to are required")
}
return nil
}
u, err := url.Parse(c["url"])
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil {
return errors.New("an HTTPS webhook URL is required")
}
return nil
}
func normalizeEvents(in []string) []string {
seen := map[string]bool{}
out := []string{}
for _, v := range in {
v = strings.TrimSpace(v)
if validEvent(v) && !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
func matches(filter []string, event string) bool {
for _, v := range filter {
if v == event || v == "*" {
return true
}
}
return false
}
func errorCode(err error) string {
v := err.Error()
if strings.HasPrefix(v, "remote_status_") {
return v
}
switch v {
case "unsafe_destination", "destination_resolution_failed":
return v
}
return "delivery_failed"
}
func randomID() string {
b := make([]byte, 18)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}