461 lines
12 KiB
Go
461 lines
12 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
|
|
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
|
|
}
|