feat(secrets): encrypt sensitive store fields (AES-GCM, master.key 0600, api_key_env) + fast models-endpoint probing (fix zen backlog + source status) + UI cleanup (drop redundant parens labels, grid models 4/row)

This commit is contained in:
root
2026-08-10 10:33:03 +08:00
parent e0978db150
commit 839f33ba01
8 changed files with 405 additions and 56 deletions

View File

@ -2,6 +2,7 @@ package config
import (
"encoding/json"
"log"
"os"
"sync"
)
@ -12,10 +13,25 @@ type Store struct {
mu sync.Mutex
path string
data RuntimeConfig
box *SecretBox
}
func NewStore(path string) *Store {
return &Store{path: path}
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 of source
// api_key / header values read from other places (e.g. YAML). 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).
@ -30,7 +46,27 @@ func (s *Store) Load() error {
}
return err
}
return json.Unmarshal(data, &s.data)
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).
@ -115,11 +151,44 @@ func (s *Store) DeletedAdapters() map[string]bool {
}
func (s *Store) persistLocked() error {
if s.box != nil {
s.encryptLocked()
}
b, err := json.MarshalIndent(s.data, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, b, 0644)
if s.box != nil {
s.decryptLocked()
}
if err := os.WriteFile(s.path, b, 0644); err != nil {
return err
}
return nil
}
// encryptLocked seals sensitive fields for the write; decryptLocked runs
// right after marshaling so in-memory data stays plaintext.
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 containsString(list []string, s string) bool {