- Add Groups map[string]string field to rawConfig struct - Add applyGroupOverrides function for [groups] reassignment with stderr warning on unknown class - Add groupOrder canonical slice for section header ordering - Refactor PrintConfig to emit group headers (Infrastructure, Web, Mail, Remote Access, File Transfer, Database, Discovery, VoIP, Unknown) - Classes within each group sorted by ascending BaseHz - User-defined classes emitted under '# User-defined' section - Non-canonical group names (user-invented) emitted in sorted order after canonical groups
482 lines
16 KiB
Go
482 lines
16 KiB
Go
// Package config loads, validates, and merges a TOML override file over the
|
|
// default synth.ClassFreqConfigs map. The single public entry point is Load.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
|
|
"github.com/netsynth/netsynth/classify"
|
|
"github.com/netsynth/netsynth/synth"
|
|
)
|
|
|
|
// SoundOverride holds optional per-class sound parameters decoded from TOML.
|
|
// Pointer fields: nil = not set by user (keep default), non-nil = user override.
|
|
type SoundOverride struct {
|
|
Frequency *float64 `toml:"frequency"`
|
|
Waveform *string `toml:"waveform"`
|
|
}
|
|
|
|
// RawRule holds a user-defined classification rule as decoded from TOML.
|
|
// Port is a pointer so we can distinguish "not set" (nil, matches any port) from port=0.
|
|
type RawRule struct {
|
|
Port *uint16 `toml:"port"`
|
|
Protocol string `toml:"protocol"`
|
|
Class string `toml:"class"`
|
|
}
|
|
|
|
// rawConfig is the top-level TOML decode target.
|
|
type rawConfig struct {
|
|
Sounds map[string]SoundOverride `toml:"sounds"`
|
|
Rules []RawRule `toml:"rules"`
|
|
Groups map[string]string `toml:"groups"`
|
|
}
|
|
|
|
// LoadResult is the return type from Load(). It carries the merged FreqConfig map,
|
|
// the user-defined classification rules (to be prepended before DefaultRules by the caller),
|
|
// the resolved config file path (empty string if no config was found),
|
|
// and the set of classes that were auto-assigned frequencies.
|
|
type LoadResult struct {
|
|
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
|
|
UserRules []classify.Rule
|
|
ConfigPath string
|
|
AutoClasses map[classify.TrafficClass]bool // classes with auto-assigned frequencies
|
|
}
|
|
|
|
// validWaveforms maps TOML waveform strings to WaveformType constants.
|
|
var validWaveforms = map[string]synth.WaveformType{
|
|
"sine": synth.WaveformSine,
|
|
"square": synth.WaveformSquare,
|
|
"sawtooth": synth.WaveformSawtooth,
|
|
"triangle": synth.WaveformTriangle,
|
|
}
|
|
|
|
// Load finds, parses, validates, and merges a TOML config file.
|
|
//
|
|
// configPath is the --config flag value; empty string triggers auto-discovery.
|
|
// Returns a LoadResult with the merged FreqConfig map, user-defined rules, and resolved path.
|
|
// Returns an error on: explicit file not found, parse errors, unknown keys,
|
|
// type mismatches, invalid waveform values, or invalid rule definitions.
|
|
// Returns no error (uses defaults) when no config is found during auto-discovery.
|
|
func Load(configPath string) (LoadResult, error) {
|
|
path, explicit, err := resolvePath(configPath)
|
|
if err != nil {
|
|
return LoadResult{}, err
|
|
}
|
|
if path == "" {
|
|
// No config found during auto-discovery — use defaults silently (CFG-02)
|
|
return LoadResult{
|
|
FreqCfgs: copyDefaults(),
|
|
UserRules: []classify.Rule{},
|
|
ConfigPath: "",
|
|
AutoClasses: map[classify.TrafficClass]bool{},
|
|
}, nil
|
|
}
|
|
|
|
raw, err := parseFile(path)
|
|
if err != nil {
|
|
if explicit && errors.Is(err, fs.ErrNotExist) {
|
|
return LoadResult{}, fmt.Errorf("config file not found: %s", configPath)
|
|
}
|
|
return LoadResult{}, err
|
|
}
|
|
|
|
if err := validate(raw); err != nil {
|
|
return LoadResult{}, err
|
|
}
|
|
|
|
userRules := convertRules(raw.Rules)
|
|
freqCfgs := copyDefaults()
|
|
autoClasses := map[classify.TrafficClass]bool{}
|
|
// Add auto-freq entries BEFORE merge so that [sounds.X] overrides for user classes apply.
|
|
addAutoFreqEntries(freqCfgs, userRules, autoClasses)
|
|
merge(freqCfgs, raw.Sounds)
|
|
applyGroupOverrides(freqCfgs, raw.Groups)
|
|
|
|
return LoadResult{
|
|
FreqCfgs: freqCfgs,
|
|
UserRules: userRules,
|
|
ConfigPath: path,
|
|
AutoClasses: autoClasses,
|
|
}, nil
|
|
}
|
|
|
|
// resolvePath resolves the config path from an explicit flag value or auto-discovery.
|
|
// Returns (path, explicit, error) where explicit=true means the user specified a path.
|
|
func resolvePath(configPath string) (string, bool, error) {
|
|
if configPath != "" {
|
|
return configPath, true, nil
|
|
}
|
|
return discoverPath(), false, nil
|
|
}
|
|
|
|
// discoverPath probes the standard discovery locations in precedence order.
|
|
// Returns the first existing config path, or "" if none found.
|
|
// Discovery order: ./netsynth.toml > ~/.config/netsynth/config.toml
|
|
func discoverPath() string {
|
|
// 1. Working directory
|
|
if _, err := os.Stat("netsynth.toml"); err == nil {
|
|
return "netsynth.toml"
|
|
}
|
|
// 2. XDG config dir (~/.config/netsynth/config.toml or $XDG_CONFIG_HOME/netsynth/config.toml)
|
|
dir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
p := filepath.Join(dir, "netsynth", "config.toml")
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// parseFile decodes the TOML file at path and checks for unknown keys via Undecoded().
|
|
// Returns fs.ErrNotExist-wrapped error when the file does not exist.
|
|
func parseFile(path string) (rawConfig, error) {
|
|
var raw rawConfig
|
|
md, err := toml.DecodeFile(path, &raw)
|
|
if err != nil {
|
|
// Preserve the fs.ErrNotExist sentinel so Load can distinguish explicit vs discovered.
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return raw, err
|
|
}
|
|
return raw, fmt.Errorf("config parse error: %w", err)
|
|
}
|
|
// Detect field-level typos within [sounds.<class>] blocks (CFG-05).
|
|
// Note: class-name typos in [sounds.<name>] are NOT caught here because all
|
|
// map keys are valid decode targets. Class validation happens in merge (D-09).
|
|
if undecoded := md.Undecoded(); len(undecoded) > 0 {
|
|
keyPath := strings.Join(undecoded[0], ".")
|
|
return raw, fmt.Errorf("config: unknown key %q — check spelling", keyPath)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// validate checks waveform strings and rules before merge so we fail fast at startup (D-11).
|
|
func validate(raw rawConfig) error {
|
|
for _, override := range raw.Sounds {
|
|
if override.Waveform != nil {
|
|
if _, err := parseWaveform(*override.Waveform); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return validateRules(raw.Rules)
|
|
}
|
|
|
|
// validateRules checks that each rule has a valid protocol and a non-empty class.
|
|
func validateRules(rules []RawRule) error {
|
|
validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true}
|
|
for i, r := range rules {
|
|
if r.Protocol == "" {
|
|
return fmt.Errorf("config: rules[%d]: protocol is required", i)
|
|
}
|
|
if !validProtocols[r.Protocol] {
|
|
return fmt.Errorf("config: rules[%d]: invalid protocol %q -- valid: tcp, udp, icmp", i, r.Protocol)
|
|
}
|
|
if r.Class == "" {
|
|
return fmt.Errorf("config: rules[%d]: class is required", i)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// convertRules converts a slice of RawRule (from TOML) into classify.Rule slice.
|
|
func convertRules(raw []RawRule) []classify.Rule {
|
|
result := make([]classify.Rule, len(raw))
|
|
for i, r := range raw {
|
|
var port uint16
|
|
if r.Port != nil {
|
|
port = *r.Port
|
|
}
|
|
result[i] = classify.Rule{
|
|
Protocol: r.Protocol,
|
|
DstPort: port,
|
|
Class: classify.TrafficClass(r.Class),
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// autoAssignFreq computes a deterministic frequency in [2500, 4000] Hz for a class name
|
|
// using FNV-32a hashing. Same input always produces the same output.
|
|
func autoAssignFreq(className string) float64 {
|
|
h := fnv.New32a()
|
|
h.Write([]byte(className))
|
|
const (
|
|
baseHz = 2500.0
|
|
stepHz = 50.0
|
|
numSteps = uint32(31) // [2500, 4000] Hz in 50 Hz steps
|
|
)
|
|
return baseHz + float64(h.Sum32()%numSteps)*stepHz
|
|
}
|
|
|
|
// addAutoFreqEntries adds a FreqConfig entry for each user-defined class that doesn't
|
|
// already have one in the map. Built-in classes that appear in user rules are skipped.
|
|
// Must be called AFTER merge() so that [sounds.X] overrides are already applied.
|
|
// autoClasses is populated with the class names that were auto-assigned.
|
|
func addAutoFreqEntries(cfgs map[classify.TrafficClass]synth.FreqConfig, userRules []classify.Rule, autoClasses map[classify.TrafficClass]bool) {
|
|
for _, rule := range userRules {
|
|
if _, exists := cfgs[rule.Class]; !exists {
|
|
baseHz := autoAssignFreq(string(rule.Class))
|
|
cfgs[rule.Class] = synth.FreqConfig{
|
|
BaseHz: baseHz,
|
|
WaveformType: synth.WaveformSine,
|
|
Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate),
|
|
Pan: 0.0,
|
|
}
|
|
autoClasses[rule.Class] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseWaveform converts a TOML waveform string to a WaveformType.
|
|
func parseWaveform(s string) (synth.WaveformType, error) {
|
|
if wt, ok := validWaveforms[s]; ok {
|
|
return wt, nil
|
|
}
|
|
valid := []string{"sine", "square", "sawtooth", "triangle"}
|
|
return 0, fmt.Errorf("config: invalid waveform %q — valid values: %s", s, strings.Join(valid, ", "))
|
|
}
|
|
|
|
// waveformString converts a WaveformType back to its TOML string representation.
|
|
func waveformString(wt synth.WaveformType) string {
|
|
switch wt {
|
|
case synth.WaveformSine:
|
|
return "sine"
|
|
case synth.WaveformSquare:
|
|
return "square"
|
|
case synth.WaveformSawtooth:
|
|
return "sawtooth"
|
|
case synth.WaveformTriangle:
|
|
return "triangle"
|
|
default:
|
|
return "custom"
|
|
}
|
|
}
|
|
|
|
// applyGroupOverrides overlays [groups] reassignments onto freqCfgs.Group in-place.
|
|
// Unknown class names produce a warning to stderr (D-09).
|
|
// Unknown group names are silently accepted -- users can invent custom groups (D-09).
|
|
func applyGroupOverrides(cfgs map[classify.TrafficClass]synth.FreqConfig, groups map[string]string) {
|
|
for className, groupName := range groups {
|
|
cls := classify.TrafficClass(className)
|
|
cfg, known := cfgs[cls]
|
|
if !known {
|
|
fmt.Fprintf(os.Stderr, "Warning: config: [groups]: unknown class %q (ignored)\n", className)
|
|
continue
|
|
}
|
|
cfg.Group = groupName
|
|
cfgs[cls] = cfg
|
|
}
|
|
}
|
|
|
|
// groupOrder defines the canonical display order for --print-config section headers (D-04).
|
|
var groupOrder = []string{
|
|
"Infrastructure", "Web", "Mail", "Remote Access",
|
|
"File Transfer", "Database", "Discovery", "VoIP", "Unknown",
|
|
}
|
|
|
|
// PrintConfig returns the effective configuration as commented TOML output.
|
|
// The output includes a header with source path and generation date, an optional
|
|
// [[rules]] section for user-defined rules, and a [sounds.*] section for all
|
|
// traffic classes in deterministic order (built-ins first, then user-defined sorted).
|
|
// Each sound entry is annotated as (default), (override), or (auto-assigned).
|
|
func PrintConfig(result LoadResult) string {
|
|
var sb strings.Builder
|
|
|
|
// Header
|
|
fmt.Fprintf(&sb, "# NetSynth effective configuration\n")
|
|
if result.ConfigPath != "" {
|
|
fmt.Fprintf(&sb, "# Config source: %s\n", result.ConfigPath)
|
|
} else {
|
|
fmt.Fprintf(&sb, "# Config source: none (using defaults)\n")
|
|
}
|
|
fmt.Fprintf(&sb, "# Generated: %s\n", time.Now().UTC().Format("2006-01-02T15:04:05Z"))
|
|
fmt.Fprintf(&sb, "\n")
|
|
|
|
// [[rules]] section (if any user rules exist)
|
|
if len(result.UserRules) > 0 {
|
|
fmt.Fprintf(&sb, "# Classification rules (user-defined, prepended before built-in rules)\n")
|
|
for _, rule := range result.UserRules {
|
|
fmt.Fprintf(&sb, "[[rules]]\n")
|
|
if rule.DstPort != 0 {
|
|
fmt.Fprintf(&sb, "port = %d\n", rule.DstPort)
|
|
}
|
|
fmt.Fprintf(&sb, "protocol = %q\n", rule.Protocol)
|
|
fmt.Fprintf(&sb, "class = %q\n", string(rule.Class))
|
|
fmt.Fprintf(&sb, "\n")
|
|
}
|
|
}
|
|
|
|
// [sounds.*] section — built-in classes grouped by family, then user-defined
|
|
|
|
// Build group -> []TrafficClass index from AllClasses()
|
|
builtinByGroup := map[string][]classify.TrafficClass{}
|
|
builtinSet := map[classify.TrafficClass]bool{}
|
|
for _, cls := range classify.AllClasses() {
|
|
builtinSet[cls] = true
|
|
cfg := result.FreqCfgs[cls]
|
|
grp := cfg.Group
|
|
builtinByGroup[grp] = append(builtinByGroup[grp], cls)
|
|
}
|
|
|
|
// Sort each group by ascending BaseHz (D-05) using result.FreqCfgs (effective Hz, not defaults)
|
|
for grp := range builtinByGroup {
|
|
classes := builtinByGroup[grp]
|
|
sort.Slice(classes, func(i, j int) bool {
|
|
return result.FreqCfgs[classes[i]].BaseHz < result.FreqCfgs[classes[j]].BaseHz
|
|
})
|
|
}
|
|
|
|
// Emit built-in classes grouped with headers (D-04)
|
|
for _, grp := range groupOrder {
|
|
classes, ok := builtinByGroup[grp]
|
|
if !ok || len(classes) == 0 {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&sb, "# %s\n\n", grp)
|
|
for _, cls := range classes {
|
|
cfg := result.FreqCfgs[cls]
|
|
annotation := classAnnotation(cls, cfg, result.AutoClasses)
|
|
fmt.Fprintf(&sb, "# %s -- %.1f Hz (%s)\n", string(cls), cfg.BaseHz, annotation)
|
|
fmt.Fprintf(&sb, "[sounds.%s]\n", string(cls))
|
|
fmt.Fprintf(&sb, "frequency = %.1f\n", cfg.BaseHz)
|
|
fmt.Fprintf(&sb, "waveform = %q\n", waveformString(cfg.WaveformType))
|
|
fmt.Fprintf(&sb, "\n")
|
|
}
|
|
}
|
|
|
|
// Check for custom groups (from [groups] reassignment) that are not in groupOrder.
|
|
// For non-canonical group names (user-invented), collect and emit separately.
|
|
customGroups := map[string][]classify.TrafficClass{}
|
|
for _, cls := range classify.AllClasses() {
|
|
cfg := result.FreqCfgs[cls]
|
|
grp := cfg.Group
|
|
isCanonical := false
|
|
for _, cg := range groupOrder {
|
|
if grp == cg {
|
|
isCanonical = true
|
|
break
|
|
}
|
|
}
|
|
if !isCanonical {
|
|
customGroups[grp] = append(customGroups[grp], cls)
|
|
}
|
|
}
|
|
// Sort and emit custom group sections
|
|
var customGroupNames []string
|
|
for grp := range customGroups {
|
|
customGroupNames = append(customGroupNames, grp)
|
|
}
|
|
sort.Strings(customGroupNames)
|
|
for _, grp := range customGroupNames {
|
|
classes := customGroups[grp]
|
|
sort.Slice(classes, func(i, j int) bool {
|
|
return result.FreqCfgs[classes[i]].BaseHz < result.FreqCfgs[classes[j]].BaseHz
|
|
})
|
|
fmt.Fprintf(&sb, "# %s\n\n", grp)
|
|
for _, cls := range classes {
|
|
cfg := result.FreqCfgs[cls]
|
|
annotation := classAnnotation(cls, cfg, result.AutoClasses)
|
|
fmt.Fprintf(&sb, "# %s -- %.1f Hz (%s)\n", string(cls), cfg.BaseHz, annotation)
|
|
fmt.Fprintf(&sb, "[sounds.%s]\n", string(cls))
|
|
fmt.Fprintf(&sb, "frequency = %.1f\n", cfg.BaseHz)
|
|
fmt.Fprintf(&sb, "waveform = %q\n", waveformString(cfg.WaveformType))
|
|
fmt.Fprintf(&sb, "\n")
|
|
}
|
|
}
|
|
|
|
// Emit user-defined classes (in FreqCfgs but not in AllClasses) under "# User-defined" (D-06)
|
|
var userClasses []string
|
|
for cls := range result.FreqCfgs {
|
|
if !builtinSet[cls] {
|
|
userClasses = append(userClasses, string(cls))
|
|
}
|
|
}
|
|
sort.Strings(userClasses)
|
|
if len(userClasses) > 0 {
|
|
fmt.Fprintf(&sb, "# User-defined\n\n")
|
|
for _, clsStr := range userClasses {
|
|
cls := classify.TrafficClass(clsStr)
|
|
cfg := result.FreqCfgs[cls]
|
|
annotation := classAnnotation(cls, cfg, result.AutoClasses)
|
|
fmt.Fprintf(&sb, "# %s -- %.1f Hz (%s)\n", clsStr, cfg.BaseHz, annotation)
|
|
fmt.Fprintf(&sb, "[sounds.%s]\n", clsStr)
|
|
fmt.Fprintf(&sb, "frequency = %.1f\n", cfg.BaseHz)
|
|
fmt.Fprintf(&sb, "waveform = %q\n", waveformString(cfg.WaveformType))
|
|
fmt.Fprintf(&sb, "\n")
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// classAnnotation returns the annotation string for a traffic class entry.
|
|
// Returns "default", "override", or "auto-assigned".
|
|
func classAnnotation(cls classify.TrafficClass, cfg synth.FreqConfig, autoClasses map[classify.TrafficClass]bool) string {
|
|
if autoClasses[cls] {
|
|
return "auto-assigned"
|
|
}
|
|
defaultCfg, isBuiltin := synth.ClassFreqConfigs[cls]
|
|
if !isBuiltin {
|
|
// User-defined class that was manually specified in [sounds.*] (not auto-assigned)
|
|
return "override"
|
|
}
|
|
if cfg.BaseHz == defaultCfg.BaseHz && cfg.WaveformType == defaultCfg.WaveformType {
|
|
return "default"
|
|
}
|
|
return "override"
|
|
}
|
|
|
|
// copyDefaults returns a shallow copy of synth.ClassFreqConfigs.
|
|
// Shallow copy is safe because merge assigns fresh Harmonics slices from
|
|
// WaveformPresetHarmonics, never mutating the original default slice.
|
|
func copyDefaults() map[classify.TrafficClass]synth.FreqConfig {
|
|
result := make(map[classify.TrafficClass]synth.FreqConfig, len(synth.ClassFreqConfigs))
|
|
for k, v := range synth.ClassFreqConfigs {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
// merge overlays per-class overrides onto the defaults map in-place.
|
|
// Only non-nil pointer fields in each SoundOverride are applied.
|
|
func merge(
|
|
defaults map[classify.TrafficClass]synth.FreqConfig,
|
|
overrides map[string]SoundOverride,
|
|
) map[classify.TrafficClass]synth.FreqConfig {
|
|
for className, override := range overrides {
|
|
class := classify.TrafficClass(className)
|
|
cfg, known := defaults[class]
|
|
if !known {
|
|
// D-09: unknown class name = warning (not error), in case Phase 7 defines it
|
|
fmt.Fprintf(os.Stderr, "Warning: config: unknown class %q (ignored)\n", className)
|
|
continue
|
|
}
|
|
if override.Frequency != nil {
|
|
cfg.BaseHz = *override.Frequency
|
|
// Regenerate harmonics when a waveform preset is active (Pitfall 3)
|
|
if cfg.WaveformType != synth.WaveformCustom {
|
|
cfg.Harmonics = synth.WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, synth.SampleRate)
|
|
}
|
|
}
|
|
if override.Waveform != nil {
|
|
wt, _ := parseWaveform(*override.Waveform) // already validated above
|
|
cfg.WaveformType = wt
|
|
cfg.Harmonics = synth.WaveformPresetHarmonics(wt, cfg.BaseHz, synth.SampleRate)
|
|
}
|
|
defaults[class] = cfg
|
|
}
|
|
return defaults
|
|
}
|