// Package config — runtime source overlay. // // Only runtime-sourced (WebUI-created) sources live in the JSON store file; // auto rules and gateway keys now live in config.yaml. The store also tracks // deleted sources/adapters so a restart does not resurrect them. package config import ( "encoding/json" "log" "os" "sync" ) // Store persists web-UI editable runtime sources to a JSON file so edits // survive restarts. Base YAML sources are merged underneath. type Store struct { mu sync.Mutex path string data RuntimeConfig box *SecretBox } func NewStore(path string) *Store { s := &Store{path: path} if box, err := NewSecretBox(path); err == nil { s.box = box } else { log.Printf("[config] secrets disabled: %v (sensitive values will be stored in plaintext)", err) } return s } // SecretBox exposes the box used for persist-time encryption. May be nil. func (s *Store) SecretBox() *SecretBox { s.mu.Lock() defer s.mu.Unlock() return s.box } // Load reads the runtime file (missing file = empty state). func (s *Store) Load() error { s.mu.Lock() defer s.mu.Unlock() data, err := os.ReadFile(s.path) if err != nil { if os.IsNotExist(err) { s.data = RuntimeConfig{} return nil } return err } if err := json.Unmarshal(data, &s.data); err != nil { return err } if s.box != nil { s.decryptLocked() } return nil } // decryptLocked replaces persisted ciphertext fields with their plaintext in // memory (the runtime always works with plaintext; only the file is sealed). func (s *Store) decryptLocked() { for i := range s.data.Sources { s.data.Sources[i].APIKey = s.box.MustDecrypt(s.data.Sources[i].APIKey) for k, v := range s.data.Sources[i].Headers { s.data.Sources[i].Headers[k] = s.box.MustDecrypt(v) } } for i := range s.data.Keys { s.data.Keys[i].Key = s.box.MustDecrypt(s.data.Keys[i].Key) } } // List returns the runtime sources (those edited via web UI). func (s *Store) List() []Source { s.mu.Lock() defer s.mu.Unlock() out := make([]Source, len(s.data.Sources)) copy(out, s.data.Sources) return out } // Upsert adds or replaces a runtime source and persists. func (s *Store) Upsert(src Source) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.data.Sources { if s.data.Sources[i].Name == src.Name { s.data.Sources[i] = src s.data.DeletedSources = removeString(s.data.DeletedSources, src.Name) return s.persistLocked() } } s.data.Sources = append(s.data.Sources, src) s.data.DeletedSources = removeString(s.data.DeletedSources, src.Name) return s.persistLocked() } // Remove deletes a runtime source from the store and persists. Base YAML // sources are handled (truly removed from the config file) by the caller. func (s *Store) Remove(name string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() kept := s.data.Sources[:0] removed := false for _, src := range s.data.Sources { if src.Name == name { removed = true continue } kept = append(kept, src) } s.data.Sources = kept return removed, s.persistLocked() } // DeletedSources returns the set of source names that were deleted via UI. func (s *Store) DeletedSources() map[string]bool { s.mu.Lock() defer s.mu.Unlock() out := map[string]bool{} for _, name := range s.data.DeletedSources { out[name] = true } return out } // DeletedAdapters returns the set of adapter names that were deleted via UI. func (s *Store) DeletedAdapters() map[string]bool { s.mu.Lock() defer s.mu.Unlock() out := map[string]bool{} for _, name := range s.data.DeletedAdapters { out[name] = true } return out } // SeedTemplates adds preset templates that the store has never seen before. // A preset is seeded at most once: its name is recorded in PresetTemplates so // a user who edits or deletes it never gets it silently restored on restart. func (s *Store) SeedTemplates(presets []SourceTemplate) error { s.mu.Lock() defer s.mu.Unlock() seen := make(map[string]bool, len(s.data.PresetTemplates)) for _, n := range s.data.PresetTemplates { seen[n] = true } existing := make(map[string]bool, len(s.data.SourceTemplates)) for _, t := range s.data.SourceTemplates { existing[t.Name] = true } added := false for _, p := range presets { if p.Name == "" || seen[p.Name] { continue } s.data.PresetTemplates = append(s.data.PresetTemplates, p.Name) added = true if existing[p.Name] { continue // user already has a template by this name: never overwrite } s.data.SourceTemplates = append(s.data.SourceTemplates, p) } if !added { return nil } return s.persistLocked() } // UpsertTemplate adds or replaces a source template and persists. func (s *Store) UpsertTemplate(t SourceTemplate) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.data.SourceTemplates { if s.data.SourceTemplates[i].Name == t.Name { s.data.SourceTemplates[i] = t return s.persistLocked() } } s.data.SourceTemplates = append(s.data.SourceTemplates, t) return s.persistLocked() } // ListTemplates returns the saved source templates. func (s *Store) ListTemplates() []SourceTemplate { s.mu.Lock() defer s.mu.Unlock() out := make([]SourceTemplate, len(s.data.SourceTemplates)) copy(out, s.data.SourceTemplates) return out } // RemoveTemplate deletes a source template by name and persists. func (s *Store) RemoveTemplate(name string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() kept := s.data.SourceTemplates[:0] removed := false for _, t := range s.data.SourceTemplates { if t.Name == name { removed = true continue } kept = append(kept, t) } s.data.SourceTemplates = kept if removed { return true, s.persistLocked() } return false, nil } // LoadLegacy returns the full legacy RuntimeConfig from the file (used for // one-time migration into config.yaml). Returns nil if file is missing. func (s *Store) LoadLegacy() *RuntimeConfig { s.mu.Lock() defer s.mu.Unlock() cp := s.data return &cp } func (s *Store) persistLocked() error { if s.box != nil { s.encryptLocked() } b, err := json.MarshalIndent(s.data, "", " ") if err != nil { return err } if s.box != nil { s.decryptLocked() } if err := os.WriteFile(s.path, b, 0644); err != nil { return err } return nil } func (s *Store) encryptLocked() { for i := range s.data.Sources { if v, err := s.box.Encrypt(s.data.Sources[i].APIKey); err == nil { s.data.Sources[i].APIKey = v } h := s.data.Sources[i].Headers for k, v := range h { if v == "" { continue } if e, err := s.box.Encrypt(v); err == nil { h[k] = e } } } for i := range s.data.Keys { if v, err := s.box.Encrypt(s.data.Keys[i].Key); err == nil { s.data.Keys[i].Key = v } } } func removeString(list []string, s string) []string { out := list[:0] for _, x := range list { if x != s { out = append(out, x) } } return out }