Files
funknetz/station/config.go
T
konradandCursor 87e366e36c Keep a later config flag when extra words are present.
A non-flag word no longer stops parsing, and a missing home directory refuses to create or rewrite a config instead of writing an empty log path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:42:01 +02:00

341 lines
8.1 KiB
Go

package station
import (
"errors"
"fmt"
"io/fs"
"math"
"os"
"path/filepath"
"regexp"
"strings"
"encoding/json/jsontext"
jsonv2 "encoding/json/v2"
)
type loaded struct {
host string
port int
callsign string
authKey string
talkGroup int
enableLogging bool
logPath string
logNotice string
}
type fileShape struct {
Host jsontext.Value `json:"host"`
Port jsontext.Value `json:"port"`
Callsign jsontext.Value `json:"callsign"`
AuthKey jsontext.Value `json:"authKey"`
TalkGroup jsontext.Value `json:"talkGroup"`
EnableLogging jsontext.Value `json:"enableLogging"`
LogFile jsontext.Value `json:"logFile"`
}
type templateFile struct {
Host string `json:"host"`
Port int `json:"port"`
Callsign string `json:"callsign"`
AuthKey string `json:"authKey"`
TalkGroup int `json:"talkGroup"`
EnableLogging bool `json:"enableLogging"`
LogFile string `json:"logFile"`
}
var callsignPattern = regexp.MustCompile(`^[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z](?:-[A-Z0-9]{1,3})?$`)
func loadConfig(path string) (loaded, error) {
body, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
if err := writeTemplate(path); err != nil {
return loaded{}, err
}
return loaded{}, fmt.Errorf("created %s", path)
}
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
}
return parseConfig(path, body)
}
func writeTemplate(path string) error {
logFile, err := defaultLogPath()
if err != nil {
return err
}
body, err := jsonv2.Marshal(templateFile{LogFile: logFile})
if err != nil {
return err
}
body = append(body, '\n')
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
_, werr := f.Write(body)
cerr := f.Close()
if werr != nil {
return werr
}
if cerr != nil {
return cerr
}
return os.Chmod(path, 0o600)
}
func parseConfig(path string, body []byte) (loaded, error) {
var shape fileShape
if err := jsonv2.Unmarshal(body, &shape); err != nil {
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
}
if block, notice := blockedLog(shape); block {
cfg, err := parseOperational(shape)
if err != nil {
return loaded{}, err
}
cfg.logNotice = notice
return cfg, nil
}
if missingLog(shape) {
if !operationalKindsOK(shape) {
return parseOperational(shape)
}
var err error
shape, err = withLogDefaults(shape)
if err != nil {
return loaded{}, err
}
if err := rewriteConfig(path, shape); err != nil {
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
}
}
cfg, err := parseOperational(shape)
if err != nil {
return loaded{}, err
}
return applyLog(cfg, shape), nil
}
func parseOperational(shape fileShape) (loaded, error) {
host, err := stringField("host", shape.Host)
if err != nil {
return loaded{}, err
}
port, err := intField("port", shape.Port, 1, 65535)
if err != nil {
return loaded{}, err
}
call, err := stringField("callsign", shape.Callsign)
if err != nil {
return loaded{}, err
}
key, err := authField(shape.AuthKey)
if err != nil {
return loaded{}, err
}
tg, err := intField("talkGroup", shape.TalkGroup, 1, int(math.MaxUint32))
if err != nil {
return loaded{}, err
}
host = strings.TrimSpace(host)
if host == "" {
return loaded{}, fmt.Errorf("host is empty")
}
call = strings.ToUpper(strings.TrimSpace(call))
if call == "" {
return loaded{}, fmt.Errorf("callsign is empty")
}
if !callsignPattern.MatchString(call) {
return loaded{}, fmt.Errorf("callsign is invalid")
}
return loaded{host: host, port: port, callsign: call, authKey: key, talkGroup: tg}, nil
}
func stringField(name string, v jsontext.Value) (string, error) {
if len(v) == 0 {
return "", nil
}
if v.Kind() != jsontext.KindString {
return "", fmt.Errorf("%s has the wrong type", name)
}
var s string
if err := jsonv2.Unmarshal(v, &s); err != nil {
return "", fmt.Errorf("%s has the wrong type", name)
}
return s, nil
}
func authField(v jsontext.Value) (string, error) {
s, err := stringField("authKey", v)
if err != nil {
return "", err
}
if strings.TrimSpace(s) == "" {
return "", fmt.Errorf("authKey is empty")
}
return s, nil
}
func intField(name string, v jsontext.Value, min, max int) (int, error) {
if len(v) == 0 {
return 0, fmt.Errorf("%s is invalid", name)
}
if v.Kind() != jsontext.KindNumber {
return 0, fmt.Errorf("%s has the wrong type", name)
}
var n int
if err := jsonv2.Unmarshal(v, &n); err != nil {
return 0, fmt.Errorf("%s is invalid", name)
}
if n < min || n > max {
return 0, fmt.Errorf("%s is invalid", name)
}
return n, nil
}
var userHomeDir = os.UserHomeDir
func defaultLogPath() (string, error) {
home, err := userHomeDir()
if err != nil {
return "", fmt.Errorf("config path: %w", err)
}
return filepath.Join(home, "funknetz.log"), nil
}
func present(v jsontext.Value) bool { return len(v) > 0 }
func isBool(v jsontext.Value) bool {
return v.Kind() == jsontext.KindTrue || v.Kind() == jsontext.KindFalse
}
func missingLog(shape fileShape) bool {
return !present(shape.EnableLogging) || !present(shape.LogFile)
}
func operationalKindsOK(shape fileShape) bool {
if present(shape.Host) && shape.Host.Kind() != jsontext.KindString {
return false
}
if present(shape.Callsign) && shape.Callsign.Kind() != jsontext.KindString {
return false
}
if present(shape.AuthKey) && shape.AuthKey.Kind() != jsontext.KindString {
return false
}
if present(shape.Port) && shape.Port.Kind() != jsontext.KindNumber {
return false
}
if present(shape.TalkGroup) && shape.TalkGroup.Kind() != jsontext.KindNumber {
return false
}
return true
}
// blockedLog reports that a log value has the wrong JSON type, so the file
// must not be rewritten. notice is empty when logging is not switched on.
func blockedLog(shape fileShape) (block bool, notice string) {
enBad := present(shape.EnableLogging) && !isBool(shape.EnableLogging)
fileBad := present(shape.LogFile) && shape.LogFile.Kind() != jsontext.KindString
if !enBad && !fileBad {
return false, ""
}
if enBad {
return true, "enableLogging has the wrong type"
}
if shape.EnableLogging.Kind() == jsontext.KindTrue {
return true, "logFile has the wrong type"
}
return true, ""
}
func withLogDefaults(shape fileShape) (fileShape, error) {
shape.Host = rawOr(shape.Host, `""`)
shape.Port = rawOr(shape.Port, "0")
shape.Callsign = rawOr(shape.Callsign, `""`)
shape.AuthKey = rawOr(shape.AuthKey, `""`)
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
if !present(shape.LogFile) {
logFile, err := defaultLogPath()
if err != nil {
return fileShape{}, err
}
raw, err := jsonv2.Marshal(logFile)
if err != nil {
return fileShape{}, err
}
shape.LogFile = jsontext.Value(raw)
}
return shape, nil
}
func rawOr(v jsontext.Value, fallback string) jsontext.Value {
if len(v) == 0 {
return jsontext.Value(fallback)
}
return v
}
func rewriteConfig(path string, shape fileShape) error {
info, err := os.Stat(path)
if err != nil {
return err
}
body, err := jsonv2.Marshal(shape)
if err != nil {
return err
}
body = append(body, '\n')
tmp, err := os.CreateTemp(filepath.Dir(path), ".funknetz-*.json")
if err != nil {
return err
}
tmpName := tmp.Name()
_, werr := tmp.Write(body)
cerr := tmp.Chmod(info.Mode().Perm())
clerr := tmp.Close()
if werr != nil || cerr != nil || clerr != nil {
os.Remove(tmpName)
if werr != nil {
return werr
}
if cerr != nil {
return cerr
}
return clerr
}
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return err
}
return nil
}
func applyLog(cfg loaded, shape fileShape) loaded {
if shape.EnableLogging.Kind() != jsontext.KindTrue {
return cfg
}
var path string
if err := jsonv2.Unmarshal(shape.LogFile, &path); err != nil {
cfg.logNotice = "logFile has the wrong type"
return cfg
}
path = strings.TrimSpace(path)
if path == "" {
cfg.logNotice = "Cannot write logFile"
return cfg
}
if !filepath.IsAbs(path) {
cfg.logNotice = "Cannot write " + path
return cfg
}
cfg.enableLogging = true
cfg.logPath = path
return cfg
}