Files
jbergner 45ca18b74e
release-tag / release-image (push) Failing after 1m20s
init
2026-08-31 17:09:21 +02:00

534 lines
15 KiB
Go

package notify
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/tls"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/mail"
"net/smtp"
"net/url"
"strconv"
"strings"
"time"
)
type Channel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Config map[string]string `json:"config"`
Enabled bool `json:"enabled"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type Input struct {
Name string `json:"name"`
Type string `json:"type"`
Config map[string]string `json:"config"`
Enabled *bool `json:"enabled"`
}
type Message struct {
Title string
Body string
Status string
MonitorID int64
}
type Service struct {
db *sql.DB
key []byte
client *http.Client
}
func New(db *sql.DB, key []byte) *Service {
return &Service{db: db, key: key, client: &http.Client{Timeout: 15 * time.Second}}
}
func sanitize(c map[string]string) map[string]string {
out := map[string]string{}
for k, v := range c {
if isSecretKey(k) {
if v != "" {
out[k] = "••••••••"
}
} else {
out[k] = v
}
}
return out
}
func isSecretKey(k string) bool {
lk := strings.ToLower(k)
return strings.Contains(lk, "password") || strings.Contains(lk, "token") || strings.Contains(lk, "secret")
}
func (s *Service) encryptString(v string) (string, error) {
if v == "" || strings.HasPrefix(v, "enc:v1:") {
return v, nil
}
b, err := aes.NewCipher(s.key)
if err != nil {
return "", err
}
g, err := cipher.NewGCM(b)
if err != nil {
return "", err
}
nonce := make([]byte, g.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return "", err
}
out := g.Seal(nonce, nonce, []byte(v), nil)
return "enc:v1:" + base64.RawStdEncoding.EncodeToString(out), nil
}
func (s *Service) decryptString(v string) (string, error) {
if !strings.HasPrefix(v, "enc:v1:") {
return v, nil
}
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(v, "enc:v1:"))
if err != nil {
return "", err
}
b, err := aes.NewCipher(s.key)
if err != nil {
return "", err
}
g, err := cipher.NewGCM(b)
if err != nil {
return "", err
}
if len(raw) < g.NonceSize() {
return "", errors.New("invalid encrypted notification config")
}
plain, err := g.Open(nil, raw[:g.NonceSize()], raw[g.NonceSize():], nil)
return string(plain), err
}
func (s *Service) encodeConfig(c map[string]string) (string, error) {
out := map[string]string{}
for k, v := range c {
if isSecretKey(k) {
e, err := s.encryptString(v)
if err != nil {
return "", err
}
out[k] = e
} else {
out[k] = v
}
}
b, err := json.Marshal(out)
return string(b), err
}
func (s *Service) decodeConfig(raw string) (map[string]string, error) {
out := map[string]string{}
if strings.TrimSpace(raw) == "" {
return out, nil
}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil, err
}
for k, v := range out {
if isSecretKey(k) {
d, err := s.decryptString(v)
if err != nil {
return nil, err
}
out[k] = d
}
}
return out, nil
}
func normalize(in *Input) error {
in.Name = strings.TrimSpace(in.Name)
in.Type = strings.ToLower(strings.TrimSpace(in.Type))
if in.Name == "" || len(in.Name) > 120 || strings.ContainsAny(in.Name, "\r\n") {
return errors.New("valid notification name required")
}
if in.Config == nil {
in.Config = map[string]string{}
}
endpoint := func(key string) error {
raw := strings.TrimSpace(in.Config[key])
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
return fmt.Errorf("%s must be an absolute http(s) URL without credentials", key)
}
in.Config[key] = strings.TrimRight(raw, "/")
return nil
}
switch in.Type {
case "webhook":
if err := endpoint("url"); err != nil {
return err
}
case "ntfy":
if err := endpoint("server"); err != nil {
return err
}
if topic := strings.TrimSpace(in.Config["topic"]); topic == "" || len(topic) > 200 || strings.ContainsAny(topic, "\r\n/?#") {
return errors.New("valid ntfy topic required")
}
case "gotify":
if err := endpoint("server"); err != nil {
return err
}
if strings.TrimSpace(in.Config["token"]) == "" {
return errors.New("gotify token required")
}
case "smtp":
host := strings.TrimSpace(in.Config["host"])
if host == "" || strings.ContainsAny(host, "\r\n/") {
return errors.New("valid smtp host required")
}
security := strings.ToLower(strings.TrimSpace(in.Config["security"]))
if security == "" {
security = "starttls"
}
if security == "ssl" {
security = "tls"
}
if security != "none" && security != "starttls" && security != "tls" {
return errors.New("smtp security must be none, starttls or tls")
}
in.Config["security"] = security
port := strings.TrimSpace(in.Config["port"])
if port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return errors.New("smtp port must be between 1 and 65535")
}
}
if _, err := mail.ParseAddress(strings.TrimSpace(in.Config["from"])); err != nil {
return errors.New("valid smtp from address required")
}
if _, err := mail.ParseAddressList(strings.TrimSpace(in.Config["to"])); err != nil {
return errors.New("valid smtp recipient list required")
}
if boolConfig(in.Config["auth"]) && strings.TrimSpace(in.Config["username"]) == "" {
return errors.New("smtp username required when authentication is enabled")
}
default:
return errors.New("type must be webhook, ntfy, gotify or smtp")
}
return nil
}
func (s *Service) List(ctx context.Context) ([]Channel, error) {
rows, e := s.db.QueryContext(ctx, `SELECT id,name,type,config_json,enabled,created_at,updated_at FROM notification_channels ORDER BY name`)
if e != nil {
return nil, e
}
defer rows.Close()
out := []Channel{}
for rows.Next() {
var c Channel
var raw string
if e := rows.Scan(&c.ID, &c.Name, &c.Type, &raw, &c.Enabled, &c.CreatedAt, &c.UpdatedAt); e != nil {
return nil, e
}
c.Config, e = s.decodeConfig(raw)
if e != nil {
return nil, e
}
c.Config = sanitize(c.Config)
out = append(out, c)
}
return out, rows.Err()
}
func (s *Service) Create(ctx context.Context, in Input) (Channel, error) {
if e := normalize(&in); e != nil {
return Channel{}, e
}
en := true
if in.Enabled != nil {
en = *in.Enabled
}
raw, e := s.encodeConfig(in.Config)
if e != nil {
return Channel{}, e
}
now := time.Now().Unix()
r, e := s.db.ExecContext(ctx, `INSERT INTO notification_channels(name,type,config_json,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?)`, in.Name, in.Type, raw, en, now, now)
if e != nil {
return Channel{}, e
}
id, _ := r.LastInsertId()
return Channel{ID: id, Name: in.Name, Type: in.Type, Config: sanitize(in.Config), Enabled: en, CreatedAt: now, UpdatedAt: now}, nil
}
func (s *Service) Update(ctx context.Context, id int64, in Input) (Channel, error) {
if e := normalize(&in); e != nil {
return Channel{}, e
}
old, e := s.get(ctx, id)
if e != nil {
return Channel{}, e
}
for k, v := range in.Config {
if v == "••••••••" {
in.Config[k] = old.Config[k]
}
}
en := true
if in.Enabled != nil {
en = *in.Enabled
}
raw, e := s.encodeConfig(in.Config)
if e != nil {
return Channel{}, e
}
now := time.Now().Unix()
_, e = s.db.ExecContext(ctx, `UPDATE notification_channels SET name=?,type=?,config_json=?,enabled=?,updated_at=? WHERE id=?`, in.Name, in.Type, raw, en, now, id)
if e != nil {
return Channel{}, e
}
return Channel{ID: id, Name: in.Name, Type: in.Type, Config: sanitize(in.Config), Enabled: en, CreatedAt: old.CreatedAt, UpdatedAt: now}, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
_, e := s.db.ExecContext(ctx, `DELETE FROM notification_channels WHERE id=?`, id)
return e
}
func (s *Service) get(ctx context.Context, id int64) (Channel, error) {
var c Channel
var raw string
e := s.db.QueryRowContext(ctx, `SELECT id,name,type,config_json,enabled,created_at,updated_at FROM notification_channels WHERE id=?`, id).Scan(&c.ID, &c.Name, &c.Type, &raw, &c.Enabled, &c.CreatedAt, &c.UpdatedAt)
if e != nil {
return c, e
}
c.Config, e = s.decodeConfig(raw)
return c, e
}
func (s *Service) Test(ctx context.Context, id int64) error {
c, e := s.get(ctx, id)
if e != nil {
return e
}
return s.send(ctx, c, Message{Title: "Dockwatch test notification", Body: "Your notification provider is configured correctly.", Status: "test"})
}
func (s *Service) Broadcast(ctx context.Context, m Message) {
rows, e := s.db.QueryContext(ctx, `SELECT id,name,type,config_json,enabled,created_at,updated_at FROM notification_channels WHERE enabled=1`)
if e != nil {
return
}
defer rows.Close()
for rows.Next() {
var c Channel
var raw string
if rows.Scan(&c.ID, &c.Name, &c.Type, &raw, &c.Enabled, &c.CreatedAt, &c.UpdatedAt) == nil {
c.Config, e = s.decodeConfig(raw)
if e != nil {
continue
}
go func(c Channel) {
x, k := context.WithTimeout(context.Background(), 20*time.Second)
defer k()
_ = s.send(x, c, m)
}(c)
}
}
}
func (s *Service) send(ctx context.Context, c Channel, m Message) error {
switch c.Type {
case "webhook":
return s.webhook(ctx, c, m)
case "ntfy":
return s.ntfy(ctx, c, m)
case "gotify":
return s.gotify(ctx, c, m)
case "smtp":
return s.smtp(ctx, c, m)
}
return errors.New("unsupported notification type")
}
func (s *Service) webhook(ctx context.Context, c Channel, m Message) error {
u := c.Config["url"]
if u == "" {
return errors.New("webhook url required")
}
b, _ := json.Marshal(map[string]any{"title": m.Title, "body": m.Body, "status": m.Status, "monitor_id": m.MonitorID, "timestamp": time.Now().Unix()})
req, e := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(b))
if e != nil {
return e
}
req.Header.Set("Content-Type", "application/json")
if t := c.Config["bearer_token"]; t != "" {
req.Header.Set("Authorization", "Bearer "+t)
}
r, e := s.client.Do(req)
if e != nil {
return e
}
defer r.Body.Close()
if r.StatusCode >= 300 {
return fmt.Errorf("webhook: %s", r.Status)
}
return nil
}
func (s *Service) ntfy(ctx context.Context, c Channel, m Message) error {
u := strings.TrimRight(c.Config["server"], "/") + "/" + c.Config["topic"]
if c.Config["server"] == "" || c.Config["topic"] == "" {
return errors.New("ntfy server and topic required")
}
req, e := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(m.Body))
if e != nil {
return e
}
req.Header.Set("Title", m.Title)
req.Header.Set("Tags", map[string]string{"down": "rotating_light", "up": "white_check_mark"}[m.Status])
if t := c.Config["token"]; t != "" {
req.Header.Set("Authorization", "Bearer "+t)
}
r, e := s.client.Do(req)
if e != nil {
return e
}
defer r.Body.Close()
if r.StatusCode >= 300 {
return fmt.Errorf("ntfy: %s", r.Status)
}
return nil
}
func (s *Service) gotify(ctx context.Context, c Channel, m Message) error {
server := strings.TrimRight(c.Config["server"], "/")
token := c.Config["token"]
if server == "" || token == "" {
return errors.New("gotify server and token required")
}
u := server + "/message?token=" + url.QueryEscape(token)
b, _ := json.Marshal(map[string]any{"title": m.Title, "message": m.Body, "priority": 5})
req, e := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(b))
if e != nil {
return e
}
req.Header.Set("Content-Type", "application/json")
r, e := s.client.Do(req)
if e != nil {
return e
}
defer r.Body.Close()
if r.StatusCode >= 300 {
return fmt.Errorf("gotify: %s", r.Status)
}
return nil
}
func boolConfig(v string) bool {
v = strings.ToLower(strings.TrimSpace(v))
return v == "1" || v == "true" || v == "yes" || v == "on"
}
func (s *Service) smtp(ctx context.Context, c Channel, m Message) error {
host := strings.TrimSpace(c.Config["host"])
port := strings.TrimSpace(c.Config["port"])
security := strings.ToLower(strings.TrimSpace(c.Config["security"]))
if security == "" {
security = "starttls"
}
if security == "ssl" {
security = "tls"
}
if port == "" {
if security == "tls" {
port = "465"
} else {
port = "587"
}
}
fromRaw := strings.TrimSpace(c.Config["from"])
toRaw := strings.TrimSpace(c.Config["to"])
if host == "" || fromRaw == "" || toRaw == "" {
return errors.New("smtp host, from and to required")
}
fromAddr, err := mail.ParseAddress(fromRaw)
if err != nil {
return errors.New("invalid smtp from address")
}
toAddrs, err := mail.ParseAddressList(toRaw)
if err != nil || len(toAddrs) == 0 {
return errors.New("invalid smtp recipient list")
}
if security != "none" && security != "starttls" && security != "tls" {
return errors.New("smtp security must be none, starttls or tls")
}
authEnabled := boolConfig(c.Config["auth"])
// Backward compatibility: existing configurations with a username implied auth.
if c.Config["auth"] == "" && strings.TrimSpace(c.Config["username"]) != "" {
authEnabled = true
}
if authEnabled && strings.TrimSpace(c.Config["username"]) == "" {
return errors.New("smtp username required when authentication is enabled")
}
addr := net.JoinHostPort(host, port)
dialer := &net.Dialer{Timeout: 15 * time.Second}
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12, InsecureSkipVerify: boolConfig(c.Config["skip_verify"])}
var conn net.Conn
if security == "tls" {
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
} else {
conn, err = dialer.DialContext(ctx, "tcp", addr)
}
if err != nil {
return err
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
} else {
_ = conn.SetDeadline(time.Now().Add(20 * time.Second))
}
cl, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer cl.Close()
if security == "starttls" {
ok, _ := cl.Extension("STARTTLS")
if !ok {
return errors.New("smtp server does not support STARTTLS")
}
if err = cl.StartTLS(tlsCfg); err != nil {
return err
}
}
if authEnabled {
if ok, _ := cl.Extension("AUTH"); !ok {
return errors.New("smtp server does not advertise AUTH")
}
auth := smtp.PlainAuth("", c.Config["username"], c.Config["password"], host)
if err = cl.Auth(auth); err != nil {
return err
}
}
if err = cl.Mail(fromAddr.Address); err != nil {
return err
}
tos := make([]string, 0, len(toAddrs))
for _, a := range toAddrs {
tos = append(tos, a.String())
if err = cl.Rcpt(a.Address); err != nil {
return err
}
}
w, err := cl.Data()
if err != nil {
return err
}
subject := strings.NewReplacer("\r", " ", "\n", " ").Replace(m.Title)
body := strings.ReplaceAll(strings.ReplaceAll(m.Body, "\r\n", "\n"), "\r", "\n")
body = strings.ReplaceAll(body, "\n", "\r\n")
msg := []byte("To: " + strings.Join(tos, ", ") + "\r\nFrom: " + fromAddr.String() + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n" + body + "\r\n")
if _, err = w.Write(msg); err != nil {
_ = w.Close()
return err
}
if err = w.Close(); err != nil {
return err
}
return cl.Quit()
}