package config import ( "flag" "fmt" "net/url" "os" "strings" "time" "github.com/knadh/koanf/parsers/toml/v2" "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" ) type Config struct { MQTT struct { URL string `koanf:"url"` ClientID string `koanf:"client_id"` Username string `koanf:"username"` Password string `koanf:"password"` DeviceID string `koanf:"device_id"` } `koanf:"mqtt"` App struct { ConnectTimeout string `koanf:"connect_timeout"` } `koanf:"app"` } // Keep duration input as text; validate/parse it explicitly after merging. func (c Config) ConnectTimeout() time.Duration { d, _ := time.ParseDuration(c.App.ConnectTimeout) // Load already validated it. return d } func Load(args []string) (Config, error) { var result Config flags := flag.NewFlagSet("mqtt5-learning", flag.ContinueOnError) filename := flags.String("config", "configs/lab.toml", "TOML file") broker := flags.String("broker", "", "broker URL override") clientID := flags.String("client-id", "", "client ID override") username := flags.String("username", "", "lab username override") deviceID := flags.String("device-id", "", "device identity override") timeout := flags.String("connect-timeout", "", "startup deadline override") if err := flags.Parse(args); err != nil { return result, err } if flags.NArg() != 0 { return result, fmt.Errorf("unexpected positional arguments") } k := koanf.New(".") defaults := map[string]any{ "mqtt.url": "mqtt://127.0.0.1:18883", "mqtt.client_id": "lab-backend", "mqtt.username": "", "mqtt.password": "", "mqtt.device_id": "device-001", "app.connect_timeout": "10s", } if err := k.Load(confmap.Provider(defaults, "."), nil); err != nil { return result, fmt.Errorf("defaults: %w", err) } // A requested/missing config file is an error, not a silent fallback. if err := k.Load(file.Provider(*filename), toml.Parser()); err != nil { return result, fmt.Errorf("TOML: %w", err) } // Explicit mapping preserves underscores inside names such as client_id. mapping := map[string]string{ "TEST_MQTT5_MQTT_URL": "mqtt.url", "TEST_MQTT5_MQTT_CLIENT_ID": "mqtt.client_id", "TEST_MQTT5_MQTT_USERNAME": "mqtt.username", "TEST_MQTT5_MQTT_DEVICE_ID": "mqtt.device_id", "TEST_MQTT5_APP_CONNECT_TIMEOUT": "app.connect_timeout", } for name, key := range mapping { if value, exists := os.LookupEnv(name); exists { if err := k.Set(key, value); err != nil { return result, err } } } overrides := map[string]string{} flags.Visit(func(f *flag.Flag) { switch f.Name { case "broker": overrides["mqtt.url"] = *broker case "client-id": overrides["mqtt.client_id"] = *clientID case "username": overrides["mqtt.username"] = *username case "device-id": overrides["mqtt.device_id"] = *deviceID case "connect-timeout": overrides["app.connect_timeout"] = *timeout } }) for key, value := range overrides { if err := k.Set(key, value); err != nil { return result, err } } if err := k.Unmarshal("", &result); err != nil { return result, fmt.Errorf("decode configuration: %w", err) } if err := result.Validate(); err != nil { return result, err } return result, nil } func (c Config) Validate() error { u, err := url.Parse(c.MQTT.URL) if err != nil || u == nil || u.Hostname() == "" || u.Port() == "" { return fmt.Errorf("mqtt.url must contain a valid host and explicit port") } // Early lab only. TLS configuration is deliberately introduced later. if u.Scheme != "mqtt" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" { return fmt.Errorf("early lab requires mqtt://host:port without credentials or extra URL components") } if u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" { return fmt.Errorf("plaintext learning broker must be on loopback") } if strings.TrimSpace(c.MQTT.ClientID) == "" { return fmt.Errorf("mqtt.client_id is required") } if strings.TrimSpace(c.MQTT.Username) == "" { return fmt.Errorf("mqtt.username is required") } if strings.TrimSpace(c.MQTT.Password) == "" { return fmt.Errorf("mqtt.password is required in the TOML configuration") } if !validDeviceID(c.MQTT.DeviceID) { return fmt.Errorf("device ID must be 1-128 ASCII letters, digits, dots, underscores, or hyphens") } d, err := time.ParseDuration(c.App.ConnectTimeout) if err != nil || d <= 0 || d > time.Minute { return fmt.Errorf("connect timeout must be a duration greater than zero and at most 1m") } return nil } // validDeviceID mirrors the topic contract's single-segment ID rules so // configuration fails before the process tries to build MQTT topics. func validDeviceID(value string) bool { if value == "" || len(value) > 128 { return false } for _, r := range value { if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._-", r)) { return false } } return true }