139 lines
4.5 KiB
Go
139 lines
4.5 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/mail"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"text/template"
|
|
)
|
|
|
|
var topicPattern = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
|
|
|
|
func Secret(value string) (string, error) {
|
|
if strings.HasPrefix(value, "env:") {
|
|
v := os.Getenv(strings.TrimPrefix(value, "env:"))
|
|
if v == "" {
|
|
return "", fmt.Errorf("required secret environment variable is missing")
|
|
}
|
|
return v, nil
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// Live is opt-in: omitted fields in new or existing configurations never send.
|
|
type OutboundConfig struct {
|
|
Topic string `json:"topic,omitempty"`
|
|
SMTPHost string `json:"smtp_host,omitempty"`
|
|
SMTPPort int `json:"smtp_port,omitempty"`
|
|
TLSMode string `json:"tls_mode,omitempty"`
|
|
Username string `json:"username,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
From string `json:"from,omitempty"`
|
|
To []string `json:"to,omitempty"`
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Provider string `json:"provider"`
|
|
URL string `json:"url"`
|
|
BearerToken string `json:"bearer_token,omitempty"`
|
|
TimeoutS int `json:"timeout_seconds"`
|
|
Live bool `json:"live"`
|
|
}
|
|
|
|
func IsOutbound(kind string) bool {
|
|
switch kind {
|
|
case "discord", "webhook", "smtp", "ntfy", "gotify":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ValidateOutbound(o OutboundConfig) error {
|
|
if strings.TrimSpace(o.ID) == "" {
|
|
return fmt.Errorf("Ausgang: id fehlt")
|
|
}
|
|
if !IsOutbound(o.Provider) {
|
|
return fmt.Errorf("Ausgang %q: unbekannter Provider", o.ID)
|
|
}
|
|
if o.TimeoutS < 0 || o.TimeoutS > 300 {
|
|
return fmt.Errorf("Ausgang %q: Timeout muss 0..300 sein", o.ID)
|
|
}
|
|
if o.Provider == "smtp" {
|
|
if o.SMTPHost == "" || strings.ContainsAny(o.SMTPHost, "\r\n /:") && net.ParseIP(o.SMTPHost) == nil || o.SMTPPort < 1 || o.SMTPPort > 65535 {
|
|
return fmt.Errorf("Ausgang %q: SMTP-Host/Port ungültig", o.ID)
|
|
}
|
|
if o.TLSMode != "tls" && o.TLSMode != "starttls" {
|
|
return fmt.Errorf("SMTP benötigt tls oder starttls")
|
|
}
|
|
if len(o.To) == 0 {
|
|
return fmt.Errorf("SMTP benötigt Empfänger")
|
|
}
|
|
for _, address := range append([]string{o.From}, o.To...) {
|
|
if strings.ContainsAny(address, "\r\n") {
|
|
return fmt.Errorf("ungültige Mailadresse")
|
|
}
|
|
a, err := mail.ParseAddress(address)
|
|
if err != nil || a.Address != address {
|
|
return fmt.Errorf("SMTP benötigt reine Mailadressen ohne Anzeigenamen")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if o.Provider == "ntfy" && !topicPattern.MatchString(o.Topic) {
|
|
return fmt.Errorf("ntfy benötigt Topic mit 1..64 Buchstaben, Ziffern, _ oder -")
|
|
}
|
|
u, err := url.Parse(o.URL)
|
|
if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" {
|
|
return fmt.Errorf("Ausgang %q: gültige HTTP(S)-URL ohne Benutzerinfo oder Fragment erforderlich", o.ID)
|
|
}
|
|
if o.Provider == "discord" {
|
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
if u.Scheme != "https" || u.Host != "discord.com" || len(parts) != 4 || parts[0] != "api" || parts[1] != "webhooks" || parts[2] == "" || parts[3] == "" {
|
|
return fmt.Errorf("Ausgang %q: Discord-URL muss https://discord.com/api/webhooks/ID/TOKEN entsprechen", o.ID)
|
|
}
|
|
if o.BearerToken != "" {
|
|
return fmt.Errorf("Ausgang %q: Discord benötigt keinen Bearer-Token", o.ID)
|
|
}
|
|
}
|
|
if strings.ContainsAny(o.BearerToken, "\r\n") {
|
|
return fmt.Errorf("Ausgang %q: ungültiger Bearer-Token", o.ID)
|
|
}
|
|
if o.TimeoutS < 0 || o.TimeoutS > 300 {
|
|
return fmt.Errorf("Ausgang %q: timeout_seconds muss 0..300 sein (0 = 15 Sekunden)", o.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateOutbounds(c Config) error {
|
|
outputs := map[string]OutboundConfig{}
|
|
for _, o := range c.Outbounds {
|
|
if err := ValidateOutbound(o); err != nil {
|
|
return err
|
|
}
|
|
if _, exists := outputs[o.ID]; exists {
|
|
return fmt.Errorf("Ausgang id %q ist doppelt", o.ID)
|
|
}
|
|
outputs[o.ID] = o
|
|
}
|
|
for _, m := range c.Mappings {
|
|
target := strings.ToLower(m.Target)
|
|
if IsOutbound(target) {
|
|
o, ok := outputs[m.OutboundID]
|
|
if !ok || o.Provider != target {
|
|
return fmt.Errorf("mapping %q: passender Ausgang für %s erforderlich", m.ID, target)
|
|
}
|
|
} else if m.OutboundID != "" {
|
|
return fmt.Errorf("mapping %q: Divera247 verwendet keinen outbound_id", m.ID)
|
|
}
|
|
for field, value := range map[string]string{"title_template": m.TitleTemplate, "text_template": m.TextTemplate, "address_template": m.AddressTemplate} {
|
|
if _, err := template.New(field).Parse(value); err != nil {
|
|
return fmt.Errorf("mapping %q: ungültiges %s: %w", m.ID, field, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|