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 certificateDir string certFatal string 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"` CertificateDir jsontext.Value `json:"certificateDir"` 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"` CertificateDir string `json:"certificateDir"` 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})?$`) // configLoad is the result of reading a config path. err from loadConfig is // only a failure that still exits before the window, such as an unknown home // directory. A missing or invalid file is reported in detail with loaded false. type configLoad struct { cfg loaded form ConfigForm loaded bool detail string } func loadConfig(path string) (configLoad, error) { body, err := os.ReadFile(path) if err != nil { form, ferr := templateForm() if ferr != nil { return configLoad{}, ferr } if errors.Is(err, fs.ErrNotExist) { return configLoad{form: form}, nil } return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil } return parseConfig(path, body, true) } // ConfigForm is the eight fields the Config window edits. type ConfigForm struct { Host string `json:"host"` Port int `json:"port"` Callsign string `json:"callsign"` AuthKey string `json:"authKey"` TalkGroup int `json:"talkGroup"` CertificateDir string `json:"certificateDir"` EnableLogging bool `json:"enableLogging"` LogFile string `json:"logFile"` } // ConfigState is what the Config window shows. type ConfigState struct { Open bool `json:"open"` NeedsConfig bool `json:"needsConfig"` Error string `json:"error"` Form ConfigForm `json:"form"` } func templateForm() (ConfigForm, error) { logFile, err := defaultLogPath() if err != nil { return ConfigForm{}, err } return ConfigForm{CertificateDir: "./funknetz", LogFile: logFile}, nil } func parseConfig(path string, body []byte, rewrite bool) (configLoad, error) { var shape fileShape if err := jsonv2.Unmarshal(body, &shape); err != nil { form, ferr := templateForm() if ferr != nil { return configLoad{}, ferr } return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil } certBad := present(shape.CertificateDir) && shape.CertificateDir.Kind() != jsontext.KindString if block, notice := blockedLog(shape); block || certBad { cfg, err := parseOperational(shape) if err != nil { return refused(shape, err) } cfg.logNotice = notice if !block { cfg = applyLog(cfg, shape) } return finish(shape, applyCertificate(cfg, shape, path)) } if missingCertificate(shape) || missingLog(shape) { if !operationalKindsOK(shape) { _, err := parseOperational(shape) if err != nil { return refused(shape, err) } } var err error shape, err = withLogDefaults(shape) if err != nil { return configLoad{}, err } if rewrite { if err := rewriteConfig(path, shape); err != nil { return configLoad{}, fmt.Errorf("the file could not be read: %w", err) } } } cfg, err := parseOperational(shape) if err != nil { return refused(shape, err) } return finish(shape, applyLog(applyCertificate(cfg, shape, path), shape)) } func finish(shape fileShape, cfg loaded) (configLoad, error) { form := formFromShape(shape) if cfg.certFatal != "" { return configLoad{cfg: cfg, form: form, loaded: true, detail: cfg.certFatal}, nil } return configLoad{cfg: cfg, form: form, loaded: true, detail: cfg.logNotice}, nil } func refused(shape fileShape, cause error) (configLoad, error) { return configLoad{form: formFromShape(shape), detail: cause.Error()}, nil } func formFromShape(shape fileShape) ConfigForm { fallback, err := templateForm() if err != nil { fallback = ConfigForm{CertificateDir: "./funknetz"} } return mergeSalvage(shape, fallback) } func mergeSalvage(shape fileShape, fallback ConfigForm) ConfigForm { if s, err := stringField("host", shape.Host); err == nil && present(shape.Host) { fallback.Host = s } if present(shape.Port) && shape.Port.Kind() == jsontext.KindNumber { var n int if jsonv2.Unmarshal(shape.Port, &n) == nil { fallback.Port = n } } if s, err := stringField("callsign", shape.Callsign); err == nil && present(shape.Callsign) { fallback.Callsign = s } if s, err := stringField("authKey", shape.AuthKey); err == nil && present(shape.AuthKey) { fallback.AuthKey = s } if present(shape.TalkGroup) && shape.TalkGroup.Kind() == jsontext.KindNumber { var n int if jsonv2.Unmarshal(shape.TalkGroup, &n) == nil { fallback.TalkGroup = n } } if s, err := stringField("certificateDir", shape.CertificateDir); err == nil && present(shape.CertificateDir) { fallback.CertificateDir = s } if present(shape.EnableLogging) && isBool(shape.EnableLogging) { fallback.EnableLogging = shape.EnableLogging.Kind() == jsontext.KindTrue } if s, err := stringField("logFile", shape.LogFile); err == nil && present(shape.LogFile) { fallback.LogFile = s } return fallback } 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 missingCertificate(shape fileShape) bool { return !present(shape.CertificateDir) } 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.CertificateDir = rawOr(shape.CertificateDir, `"./funknetz"`) 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') return rewriteBytes(path, body, info.Mode().Perm()) } func rewriteBytes(path string, body []byte, mode fs.FileMode) error { tmp, err := os.CreateTemp(filepath.Dir(path), ".funknetz-*.json") if err != nil { return err } tmpName := tmp.Name() _, werr := tmp.Write(body) cerr := tmp.Chmod(mode) 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 applyCertificate(cfg loaded, shape fileShape, configPath string) loaded { if !present(shape.CertificateDir) { cfg.certificateDir = resolveCertDir(configPath, "./funknetz") return cfg } dir, err := stringField("certificateDir", shape.CertificateDir) if err != nil { cfg.certFatal = err.Error() return cfg } if strings.TrimSpace(dir) == "" { cfg.certFatal = "certificateDir is empty" return cfg } cfg.certificateDir = resolveCertDir(configPath, dir) return cfg } func resolveCertDir(configPath, dir string) string { dir = strings.TrimSpace(dir) if filepath.IsAbs(dir) { return filepath.Clean(dir) } return filepath.Clean(filepath.Join(filepath.Dir(configPath), dir)) } 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 }