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 }