Files
OwnCord/Server/config/save.go
T

265 lines
8.8 KiB
Go
Raw Normal View History

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
}