package config import ( "bytes" "errors" "fmt" "os" "path/filepath" "strconv" "sync" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/structs" "github.com/knadh/koanf/v2" goyaml "go.yaml.in/yaml/v3" ) // DefaultPath is the CWD-relative config file path the server loads at // startup. Shared by main.go, token_cli.go and the setup wizard so they can // never disagree about which file is authoritative. const DefaultPath = "config.yaml" // Patch lists the config.yaml keys the first-run setup wizard may change. // A nil field leaves the file's current value untouched. type Patch struct { ServerPort *int ServerName *string TLSMode *string // self_signed | acme | manual | off TLSDomain *string UploadMaxSizeMB *int VoiceQuality *string // low | medium | high // VoiceAutoDownload toggles voice.auto_download_livekit (download and run // livekit-server automatically when no livekit_binary is set). VoiceAutoDownload *bool // VoiceAPIKey/VoiceAPISecret are written ONLY when the file's // corresponding value is absent or empty. This persists the // runtime-generated LiveKit credentials (see applyVoiceDefaults) so voice // tokens survive restarts, without ever clobbering operator-set values. VoiceAPIKey *string VoiceAPISecret *string } // saveMu serialises Save calls so concurrent writers cannot interleave the // read-modify-write cycle. var saveMu sync.Mutex // Save patches the config file at path with the non-nil fields of p, // preserving comments, key order, hand edits and keys it does not model. // The write is atomic (temp file + rename) and the result is verified to // parse and unmarshal before it replaces the original — Save never leaves // behind a file the server would refuse to boot from. func Save(path string, p Patch) error { saveMu.Lock() defer saveMu.Unlock() raw, err := os.ReadFile(path) //nolint:gosec // G304: path comes from trusted wiring, not request input if errors.Is(err, os.ErrNotExist) { // File deleted since startup — start from the shipped template so the // documentation comments still end up in the patched file. raw = []byte(defaultYAML) } else if err != nil { return fmt.Errorf("reading config file %s: %w", path, err) } var doc goyaml.Node if err := goyaml.Unmarshal(raw, &doc); err != nil { return fmt.Errorf("parsing config file %s: %w", path, err) } root, err := mappingRoot(&doc) if err != nil { return fmt.Errorf("config file %s: %w", path, err) } applyPatch(root, p) var buf bytes.Buffer enc := goyaml.NewEncoder(&buf) enc.SetIndent(2) if err := enc.Encode(&doc); err != nil { return fmt.Errorf("encoding config: %w", err) } if err := enc.Close(); err != nil { return fmt.Errorf("encoding config: %w", err) } // Sanity gate: the buffer must survive the same parse+unmarshal path Load // uses. A bug here must fail the request, never brick the server's boot. if err := verifyLoadable(buf.Bytes()); err != nil { return fmt.Errorf("refusing to write config that would not load: %w", err) } return atomicWrite(path, buf.Bytes()) } // applyPatch upserts every non-nil Patch field into the document root. func applyPatch(root *goyaml.Node, p Patch) { if p.ServerPort != nil { setScalar(section(root, "server"), "port", strconv.Itoa(*p.ServerPort), "!!int") } if p.ServerName != nil { setScalar(section(root, "server"), "name", *p.ServerName, "!!str") } if p.TLSMode != nil { setScalar(section(root, "tls"), "mode", *p.TLSMode, "!!str") } if p.TLSDomain != nil { setScalar(section(root, "tls"), "domain", *p.TLSDomain, "!!str") } if p.UploadMaxSizeMB != nil { setScalar(section(root, "upload"), "max_size_mb", strconv.Itoa(*p.UploadMaxSizeMB), "!!int") } if p.VoiceQuality != nil { setScalar(section(root, "voice"), "quality", *p.VoiceQuality, "!!str") } if p.VoiceAutoDownload != nil { setScalar(section(root, "voice"), "auto_download_livekit", strconv.FormatBool(*p.VoiceAutoDownload), "!!bool") } if p.VoiceAPIKey != nil { if cur := findValue(section(root, "voice"), "livekit_api_key"); cur == nil || cur.Value == "" { setScalar(section(root, "voice"), "livekit_api_key", *p.VoiceAPIKey, "!!str") } } if p.VoiceAPISecret != nil { if cur := findValue(section(root, "voice"), "livekit_api_secret"); cur == nil || cur.Value == "" { setScalar(section(root, "voice"), "livekit_api_secret", *p.VoiceAPISecret, "!!str") } } } // mappingRoot returns the top-level mapping of the parsed document, creating // an empty document+mapping when the file was empty or comments-only. func mappingRoot(doc *goyaml.Node) (*goyaml.Node, error) { if doc.Kind == 0 { doc.Kind = goyaml.DocumentNode } if len(doc.Content) == 0 { m := &goyaml.Node{Kind: goyaml.MappingNode, Tag: "!!map"} doc.Content = []*goyaml.Node{m} return m, nil } root := doc.Content[0] // A document holding only a null scalar (e.g. a file with nothing but // comments) can be promoted to a mapping in place, keeping its comments. if root.Kind == goyaml.ScalarNode && root.Tag == "!!null" { root.Kind = goyaml.MappingNode root.Tag = "!!map" root.Value = "" return root, nil } if root.Kind != goyaml.MappingNode { return nil, errors.New("root is not a YAML mapping") } return root, nil } // section returns the mapping value node for a top-level section key, // creating and appending the section when absent. A present-but-null section // (e.g. bare "voice:" with all children commented out) is promoted to a // mapping in place so its comments survive. func section(root *goyaml.Node, name string) *goyaml.Node { if v := findValue(root, name); v != nil { if v.Kind != goyaml.MappingNode { v.Kind = goyaml.MappingNode v.Tag = "!!map" v.Value = "" v.Style = 0 v.Content = nil } return v } k := &goyaml.Node{Kind: goyaml.ScalarNode, Tag: "!!str", Value: name} v := &goyaml.Node{Kind: goyaml.MappingNode, Tag: "!!map"} root.Content = append(root.Content, k, v) return v } // findValue returns the value node for key within a mapping node, or nil. func findValue(m *goyaml.Node, key string) *goyaml.Node { for i := 0; i+1 < len(m.Content); i += 2 { if m.Content[i].Value == key { return m.Content[i+1] } } return nil } // setScalar upserts key: value inside the mapping node m. Values are set as // node content, never spliced into text, so a value containing YAML syntax // is encoded as one (quoted) scalar — injection is structurally impossible. func setScalar(m *goyaml.Node, key, value, tag string) { if v := findValue(m, key); v != nil { v.Kind = goyaml.ScalarNode v.Tag = tag v.Value = value v.Style = 0 // let the encoder pick minimal correct quoting v.Content = nil return } m.Content = append(m.Content, &goyaml.Node{Kind: goyaml.ScalarNode, Tag: "!!str", Value: key}, &goyaml.Node{Kind: goyaml.ScalarNode, Tag: tag, Value: value}, ) } // bytesProvider adapts a raw byte slice to koanf's Provider interface so the // verification pass can reuse the exact YAML parser Load uses, without a // temp file or an extra dependency. type bytesProvider []byte func (b bytesProvider) ReadBytes() ([]byte, error) { return b, nil } func (b bytesProvider) Read() (map[string]any, error) { return nil, errors.New("bytesProvider requires a parser") } // verifyLoadable checks that raw would survive Load's parse+unmarshal path. func verifyLoadable(raw []byte) error { if err := validateYAML(raw); err != nil { return err } k := koanf.New(".") if err := k.Load(structs.Provider(defaults(), "koanf"), nil); err != nil { return err } if err := k.Load(bytesProvider(raw), yaml.Parser()); err != nil { return err } var cfg Config return k.Unmarshal("", &cfg) } // atomicWrite replaces path with data via temp file + rename so a crash // mid-write can never leave a truncated config. os.Rename replaces the // destination on both Unix and Windows. func atomicWrite(path string, data []byte) error { dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, ".config-*.yaml.tmp") if err != nil { return fmt.Errorf("creating temp config: %w", err) } tmpName := tmp.Name() defer os.Remove(tmpName) //nolint:errcheck // no-op after successful rename if err := tmp.Chmod(0o600); err != nil { tmp.Close() //nolint:errcheck,gosec // best-effort cleanup on error path return fmt.Errorf("setting temp config permissions: %w", err) } if _, err := tmp.Write(data); err != nil { tmp.Close() //nolint:errcheck,gosec // best-effort cleanup on error path return fmt.Errorf("writing temp config: %w", err) } if err := tmp.Sync(); err != nil { tmp.Close() //nolint:errcheck,gosec // best-effort cleanup on error path return fmt.Errorf("syncing temp config: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("closing temp config: %w", err) } if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("replacing config file: %w", err) } return nil }