diff --git a/.idea/vcs.xml b/.idea/vcs.xml index c9c2a22..8e7f14d 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,7 @@ + diff --git a/backend b/backend new file mode 100644 index 0000000..c90f6d3 Binary files /dev/null and b/backend differ diff --git a/cmd/main.go b/cmd/backend/main.go similarity index 88% rename from cmd/main.go rename to cmd/backend/main.go index 6db2604..4625c00 100644 --- a/cmd/main.go +++ b/cmd/backend/main.go @@ -53,14 +53,12 @@ func run() error { OnConnectError: func(err error) { log.Printf("connect attempt failed: %v", err) }, - ClientConfig: paho.ClientConfig{ - ClientID: "lab-backend", - OnClientError: func(err error) { - log.Printf("client error: %v", err) - }, - OnServerDisconnect: func(packet *paho.Disconnect) { - log.Printf("server disconnected: reason=0x%02x", packet.ReasonCode) - }, + ClientID: "lab-backend", + OnClientError: func(err error) { + log.Printf("client error: %v", err) + }, + OnServerDisconnect: func(packet *paho.Disconnect) { + log.Printf("server disconnected: reason=0x%02x", packet.ReasonCode) }, } diff --git a/configs/lab.toml b/configs/lab.toml new file mode 100644 index 0000000..ef6a078 --- /dev/null +++ b/configs/lab.toml @@ -0,0 +1,8 @@ +[mqtt] +url = "mqtt://127.0.0.1:18883" +client_id = "lab-backend" +username = "backend" +device_id = "device-001" + +[app] +connect_timeout = "10s" \ No newline at end of file diff --git a/go.mod b/go.mod index 27aeada..9617084 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,17 @@ go 1.27.1 require github.com/eclipse/paho.golang v0.23.0 require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/knadh/koanf/parsers/toml/v2 v2.2.0 // indirect + github.com/knadh/koanf/providers/confmap v1.0.0 // indirect + github.com/knadh/koanf/providers/file v1.2.0 // indirect + github.com/knadh/koanf/v2 v2.3.7 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index ec10061..56a4ac6 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,30 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk= github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/toml/v2 v2.2.0 h1:2nV7tHYJ5OZy2BynQ4mOJ6k5bDqbbCzRERLUKBytz3A= +github.com/knadh/koanf/parsers/toml/v2 v2.2.0/go.mod h1:JpjTeK1Ge1hVX0wbof5DMCuDBriR8bWgeQP98eeOZpI= +github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE= +github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U= +github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.7 h1:amceufOeoQcq6VFKjm7/ggJ3t0Dkqaxy5fza4j3YgTA= +github.com/knadh/koanf/v2 v2.3.7/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -14,5 +34,7 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..49163b0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,138 @@ +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_PASSWORD": "mqtt.password", + "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") + } + for _, value := range []string{c.MQTT.ClientID, c.MQTT.Username, c.MQTT.Password} { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("client ID and credentials are required") + } + } + if c.MQTT.DeviceID == "" || strings.ContainsAny(c.MQTT.DeviceID, "/+# \t\r\n") { + return fmt.Errorf("device ID must be a nonempty single topic segment") + } + 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 +}