278 lines
7.7 KiB
Go
278 lines
7.7 KiB
Go
package config
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
const CurrentVersion = 1
|
|
|
|
type Config struct {
|
|
Version int `json:"version"`
|
|
Server ServerConfig `json:"server"`
|
|
Divera DiveraConfig `json:"divera"`
|
|
Ingress IngressConfig `json:"ingress"`
|
|
Mappings []Mapping `json:"mappings"`
|
|
Outbounds []OutboundConfig `json:"outbounds,omitempty"`
|
|
}
|
|
|
|
type ServerConfig struct {
|
|
Listen string `json:"listen"`
|
|
AdminUsername string `json:"admin_username"`
|
|
AdminPasswordHash string `json:"admin_password_hash"`
|
|
SessionSecret string `json:"session_secret"`
|
|
}
|
|
|
|
type DiveraConfig struct {
|
|
BaseURL string `json:"base_url"`
|
|
AccessKey string `json:"access_key"`
|
|
UCR int64 `json:"ucr,omitempty"`
|
|
TimeoutS int `json:"timeout_seconds"`
|
|
DryRun bool `json:"dry_run"`
|
|
}
|
|
|
|
type IngressConfig struct {
|
|
Mail []MailIngress `json:"mail,omitempty"`
|
|
Discord DiscordIngress `json:"discord"`
|
|
AllowUnauthenticated bool `json:"allow_unauthenticated"`
|
|
NtfyTokens map[string]string `json:"ntfy_tokens"`
|
|
WebhookTokens map[string]string `json:"webhook_tokens"`
|
|
GotifyTokens []string `json:"gotify_tokens"`
|
|
}
|
|
|
|
type Mapping struct {
|
|
OutboundID string `json:"outbound_id,omitempty"`
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
Source string `json:"source"`
|
|
ChannelRegex string `json:"channel_regex,omitempty"`
|
|
TitleRegex string `json:"title_regex,omitempty"`
|
|
MessageRegex string `json:"message_regex,omitempty"`
|
|
MinPriority int `json:"min_priority,omitempty"`
|
|
Target string `json:"target"`
|
|
TitleTemplate string `json:"title_template"`
|
|
TextTemplate string `json:"text_template"`
|
|
AddressTemplate string `json:"address_template,omitempty"`
|
|
NotificationType int `json:"notification_type"`
|
|
Clusters []int64 `json:"clusters,omitempty"`
|
|
ClusterRoutes map[string]int `json:"cluster_routes,omitempty"`
|
|
Groups []int64 `json:"groups,omitempty"`
|
|
Users []int64 `json:"users,omitempty"`
|
|
Vehicles []int64 `json:"vehicles,omitempty"`
|
|
SendPush bool `json:"send_push"`
|
|
SendSMS bool `json:"send_sms"`
|
|
SendCall bool `json:"send_call"`
|
|
SendMail bool `json:"send_mail"`
|
|
SendPager bool `json:"send_pager"`
|
|
PrivateMode bool `json:"private_mode"`
|
|
Extra map[string]any `json:"extra,omitempty"`
|
|
}
|
|
|
|
func Default() Config {
|
|
return Config{
|
|
Version: CurrentVersion,
|
|
Server: ServerConfig{Listen: ":8080", AdminUsername: "admin", SessionSecret: randomSecret()},
|
|
Divera: DiveraConfig{BaseURL: "https://app.divera247.com", TimeoutS: 15, DryRun: true},
|
|
Ingress: IngressConfig{
|
|
NtfyTokens: make(map[string]string), WebhookTokens: make(map[string]string), GotifyTokens: []string{},
|
|
},
|
|
Mappings: []Mapping{
|
|
{
|
|
ID: "example-ntfy-alarm", Name: "Beispiel ntfy -> Alarm", Enabled: false,
|
|
Source: "ntfy", ChannelRegex: "^alarm$", Target: "alarm",
|
|
TitleTemplate: "{{if .Title}}{{.Title}}{{else}}ALARM{{end}}", TextTemplate: "{{.Message}}",
|
|
NotificationType: 2, SendPush: true,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func randomSecret() string {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
type Store struct {
|
|
path string
|
|
mu sync.RWMutex
|
|
cfg Config
|
|
}
|
|
|
|
func Open(path string) (*Store, error) {
|
|
s := &Store{path: path}
|
|
b, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
s.cfg = Default()
|
|
if err := s.saveLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(b, &s.cfg); err != nil {
|
|
return nil, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
normalize(&s.cfg)
|
|
return s, nil
|
|
}
|
|
|
|
func normalize(c *Config) {
|
|
if c.Version == 0 {
|
|
c.Version = CurrentVersion
|
|
}
|
|
if c.Server.Listen == "" {
|
|
c.Server.Listen = ":8080"
|
|
}
|
|
if c.Server.AdminUsername == "" {
|
|
c.Server.AdminUsername = "admin"
|
|
}
|
|
if c.Server.SessionSecret == "" {
|
|
c.Server.SessionSecret = randomSecret()
|
|
}
|
|
if c.Divera.BaseURL == "" {
|
|
c.Divera.BaseURL = "https://app.divera247.com"
|
|
}
|
|
if c.Divera.TimeoutS <= 0 {
|
|
c.Divera.TimeoutS = 15
|
|
}
|
|
if c.Ingress.NtfyTokens == nil {
|
|
c.Ingress.NtfyTokens = map[string]string{}
|
|
}
|
|
if c.Ingress.WebhookTokens == nil {
|
|
c.Ingress.WebhookTokens = map[string]string{}
|
|
}
|
|
if c.Ingress.GotifyTokens == nil {
|
|
c.Ingress.GotifyTokens = []string{}
|
|
}
|
|
}
|
|
|
|
func (s *Store) Get() Config {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
b, _ := json.Marshal(s.cfg)
|
|
var out Config
|
|
_ = json.Unmarshal(b, &out)
|
|
return out
|
|
}
|
|
|
|
func (s *Store) Replace(c Config) error {
|
|
normalize(&c)
|
|
if err := Validate(c); err != nil {
|
|
return err
|
|
}
|
|
if c.Version != CurrentVersion {
|
|
return fmt.Errorf("unsupported config version %d", c.Version)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
old := s.cfg
|
|
s.cfg = c
|
|
if err := s.saveLocked(); err != nil {
|
|
s.cfg = old
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) Update(fn func(*Config) error) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
copyBytes, _ := json.Marshal(s.cfg)
|
|
var next Config
|
|
_ = json.Unmarshal(copyBytes, &next)
|
|
if err := fn(&next); err != nil {
|
|
return err
|
|
}
|
|
normalize(&next)
|
|
old := s.cfg
|
|
s.cfg = next
|
|
if err := s.saveLocked(); err != nil {
|
|
s.cfg = old
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) saveLocked() error {
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil {
|
|
return err
|
|
}
|
|
b, err := json.MarshalIndent(s.cfg, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, append(b, '\n'), 0600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(tmp, 0600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path)
|
|
}
|
|
|
|
// Validate checks user-editable configuration before it is persisted.
|
|
func Validate(c Config) error {
|
|
if err := validateIngress(c.Ingress); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(c.Server.Listen) == "" {
|
|
return fmt.Errorf("server.listen darf nicht leer sein")
|
|
}
|
|
if strings.TrimSpace(c.Server.AdminUsername) == "" {
|
|
return fmt.Errorf("server.admin_username darf nicht leer sein")
|
|
}
|
|
if c.Divera.TimeoutS < 1 || c.Divera.TimeoutS > 300 {
|
|
return fmt.Errorf("divera.timeout_seconds muss zwischen 1 und 300 liegen")
|
|
}
|
|
if err := validateOutbounds(c); err != nil {
|
|
return err
|
|
}
|
|
seen := map[string]bool{}
|
|
for i, m := range c.Mappings {
|
|
if strings.TrimSpace(m.ID) == "" {
|
|
return fmt.Errorf("mapping %d: id fehlt", i+1)
|
|
}
|
|
if seen[m.ID] {
|
|
return fmt.Errorf("mapping id %q ist doppelt", m.ID)
|
|
}
|
|
seen[m.ID] = true
|
|
switch m.Source {
|
|
case "", "any", "ntfy", "gotify", "webhook", "mail", "discord":
|
|
default:
|
|
return fmt.Errorf("mapping %q: unbekannte quelle %q", m.ID, m.Source)
|
|
}
|
|
switch strings.ToLower(m.Target) {
|
|
case "alarm", "alarms", "news", "message", "mitteilung", "event", "termin", "discord", "webhook", "smtp", "ntfy", "gotify":
|
|
default:
|
|
return fmt.Errorf("mapping %q: unbekanntes ziel %q", m.ID, m.Target)
|
|
}
|
|
for field, pattern := range map[string]string{"channel_regex": m.ChannelRegex, "title_regex": m.TitleRegex, "message_regex": m.MessageRegex} {
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
if _, err := regexp.Compile(pattern); err != nil {
|
|
return fmt.Errorf("mapping %q: %s ist kein gültiger regulärer Ausdruck: %v", m.ID, field, err)
|
|
}
|
|
}
|
|
if m.NotificationType < 0 || m.NotificationType > 4 {
|
|
return fmt.Errorf("mapping %q: notification_type muss 0..4 sein", m.ID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|