// Package saconfig reads and edits CS2-SimpleAdmin.json (config version 25, SimpleAdmin 1.8.2b). // // Only the options listed in Settings can be changed. Everything else in the file, including keys // this package doesn't know about, is written back unchanged. Missing options take SimpleAdmin's // defaults, since CounterStrikeSharp fills those in when it loads the file. package saconfig import ( "bytes" "encoding/json" "fmt" "os" "sync" ) // Setting describes one editable option under OtherSettings. type Setting struct { Key string `json:"key"` Label string `json:"label"` Help string `json:"help"` Type string `json:"type"` // int, bool, select Options []Option `json:"options,omitempty"` Default any `json:"-"` Min int `json:"min,omitempty"` } type Option struct { Value int `json:"value"` Label string `json:"label"` } // Settings are the OtherSettings the panel exposes, with SimpleAdmin 1.8.2b's defaults. var Settings = []Setting{ {Key: "BanType", Label: "Ban by", Type: "select", Default: 1, Help: "What a ban matches when the banned player tries to join.", Options: []Option{{0, "SteamID only"}, {1, "SteamID and IP address"}}}, {Key: "CheckMultiAccountsByIp", Label: "Catch alt accounts by IP", Type: "bool", Default: true, Help: "Also refuse players who share any IP address a banned player has used."}, {Key: "ExpireOldIpBans", Label: "Stop matching IPs after", Type: "int", Default: 0, Help: "Days. Dynamic and shared IPs make old IP matches hit the wrong people. 0 keeps them forever."}, {Key: "MaxBanDuration", Label: "Longest ban", Type: "int", Default: 60 * 24 * 7, Min: 1, Help: "Minutes. Staff without @css/permban can't ban longer than this, or permanently."}, {Key: "MaxMuteDuration", Label: "Longest gag or mute", Type: "int", Default: 60 * 24 * 7, Min: 1, Help: "Minutes. Staff without @css/permmute can't block longer than this, or permanently."}, {Key: "TimeMode", Label: "Count block time", Type: "select", Default: 1, Help: "Whether gags and mutes run out in real time, or only while the player is on the server.", Options: []Option{{1, "In real time"}, {0, "Only while online"}}}, {Key: "ShowActivityType", Label: "Announce punishments", Type: "select", Default: 2, Help: "Tell players in chat when someone is banned, gagged or muted.", Options: []Option{{0, "Don't announce"}, {1, "Announce, admin name shown to staff only"}, {2, "Announce with admin name"}, {3, "Announce to staff only"}}}, {Key: "DisableDangerousCommands", Label: "Block commands on several players", Type: "bool", Default: true, Help: "Stops in-game commands from hitting more than one player at once, such as @all."}, {Key: "ReloadAdminsEveryMapChange", Label: "Reload admins on map change", Type: "bool", Default: false, Help: "Re-read staff and ranks from the database at every map change."}, {Key: "NotifyPenaltiesToAdminOnConnect", Label: "Tell staff about joining players' records", Type: "bool", Default: true, Help: "When a player with bans or blocks on record joins, list them to staff in chat."}, } // Database is the DatabaseConfig block. type Database struct { Type string Host string Port int User string Password string Name string SSLMode string } type File struct { path string mu sync.Mutex } func Open(path string) *File { return &File{path: path} } func (f *File) Path() string { return f.path } func (f *File) load() (map[string]any, error) { data, err := os.ReadFile(f.path) if err != nil { return nil, err } dec := json.NewDecoder(bytes.NewReader(data)) dec.UseNumber() var m map[string]any if err := dec.Decode(&m); err != nil { return nil, fmt.Errorf("%s: %w", f.path, err) } return m, nil } func section(m map[string]any, key string) map[string]any { if v, ok := m[key].(map[string]any); ok { return v } return map[string]any{} } func str(m map[string]any, key, def string) string { if v, ok := m[key].(string); ok { return v } return def } func num(m map[string]any, key string, def int) int { if v, ok := m[key].(json.Number); ok { if n, err := v.Int64(); err == nil { return int(n) } } return def } func boolean(m map[string]any, key string, def bool) bool { if v, ok := m[key].(bool); ok { return v } return def } // Database returns the database connection settings. func (f *File) Database() (Database, error) { m, err := f.load() if err != nil { return Database{}, err } d := section(m, "DatabaseConfig") return Database{ Type: str(d, "DatabaseType", "SQLite"), Host: str(d, "DatabaseHost", ""), Port: num(d, "DatabasePort", 3306), User: str(d, "DatabaseUser", ""), Password: str(d, "DatabasePassword", ""), Name: str(d, "DatabaseName", ""), SSLMode: str(d, "DatabaseSSlMode", "preferred"), }, nil } // General holds top-level options the panel needs to behave like SimpleAdmin. type General struct { Timezone string MultiServerMode bool } func (f *File) General() (General, error) { m, err := f.load() if err != nil { return General{}, err } return General{ Timezone: str(m, "Timezone", "UTC"), MultiServerMode: boolean(m, "MultiServerMode", true), }, nil } // Values returns the current value of every editable setting. func (f *File) Values() (map[string]any, error) { m, err := f.load() if err != nil { return nil, err } o := section(m, "OtherSettings") out := map[string]any{} for _, s := range Settings { switch def := s.Default.(type) { case bool: out[s.Key] = boolean(o, s.Key, def) case int: out[s.Key] = num(o, s.Key, def) } } return out, nil } // Update validates and writes changed settings. It keeps a copy of the previous file as .bak. func (f *File) Update(changes map[string]any) error { f.mu.Lock() defer f.mu.Unlock() m, err := f.load() if err != nil { return err } o := section(m, "OtherSettings") for key, raw := range changes { var def *Setting for i := range Settings { if Settings[i].Key == key { def = &Settings[i] } } if def == nil { return fmt.Errorf("%s can't be changed here.", key) } switch def.Type { case "bool": v, ok := raw.(bool) if !ok { return fmt.Errorf("%s must be on or off.", def.Label) } o[key] = v default: fv, ok := raw.(float64) if !ok || fv != float64(int(fv)) { return fmt.Errorf("%s must be a whole number.", def.Label) } v := int(fv) if v < def.Min { return fmt.Errorf("%s must be at least %d.", def.Label, def.Min) } if def.Type == "select" { valid := false for _, opt := range def.Options { valid = valid || opt.Value == v } if !valid { return fmt.Errorf("%s has no option %d.", def.Label, v) } } o[key] = v } } m["OtherSettings"] = o old, err := os.ReadFile(f.path) if err != nil { return err } var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetIndent("", " ") enc.SetEscapeHTML(false) if err := enc.Encode(m); err != nil { return err } if err := os.WriteFile(f.path+".bak", old, 0o600); err != nil { return fmt.Errorf("back up %s: %w", f.path, err) } // Write in place rather than rename, so the file keeps its owner (the game server's user). return os.WriteFile(f.path, buf.Bytes(), 0o644) }