diff --git a/cmd/invertergui/config.go b/cmd/invertergui/config.go index 9cdad85..c6bbee8 100644 --- a/cmd/invertergui/config.go +++ b/cmd/invertergui/config.go @@ -1,6 +1,10 @@ package main import ( + "fmt" + "os" + "strings" + "github.com/jessevdk/go-flags" ) @@ -15,12 +19,13 @@ type config struct { Enabled bool `long:"cli.enabled" env:"CLI_ENABLED" description:"Enable CLI output."` } MQTT struct { - Enabled bool `long:"mqtt.enabled" env:"MQTT_ENABLED" description:"Enable MQTT publishing."` - Broker string `long:"mqtt.broker" env:"MQTT_BROKER" default:"tcp://localhost:1883" description:"Set the host port and scheme of the MQTT broker."` - ClientID string `long:"mqtt.client_id" env:"MQTT_CLIENT_ID" default:"interter-gui" description:"Set the client ID for the MQTT connection."` - Topic string `long:"mqtt.topic" env:"MQTT_TOPIC" default:"invertergui/updates" description:"Set the MQTT topic updates published to."` - Username string `long:"mqtt.username" env:"MQTT_USERNAME" default:"" description:"Set the MQTT username"` - Password string `long:"mqtt.password" env:"MQTT_PASSWORD" default:"" description:"Set the MQTT password"` + Enabled bool `long:"mqtt.enabled" env:"MQTT_ENABLED" description:"Enable MQTT publishing."` + Broker string `long:"mqtt.broker" env:"MQTT_BROKER" default:"tcp://localhost:1883" description:"Set the host port and scheme of the MQTT broker."` + ClientID string `long:"mqtt.client_id" env:"MQTT_CLIENT_ID" default:"interter-gui" description:"Set the client ID for the MQTT connection."` + Topic string `long:"mqtt.topic" env:"MQTT_TOPIC" default:"invertergui/updates" description:"Set the MQTT topic updates published to."` + Username string `long:"mqtt.username" env:"MQTT_USERNAME" default:"" description:"Set the MQTT username"` + Password string `long:"mqtt.password" env:"MQTT_PASSWORD" default:"" description:"Set the MQTT password"` + PasswordFile string `long:"mqtt.password-file" env:"MQTT_PASSWORD_FILE" default:"" description:"Path to a file containing the MQTT password"` } Loglevel string `long:"loglevel" env:"LOGLEVEL" default:"info" description:"The log level to generate logs at. (\"panic\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", \"trace\")"` } @@ -31,5 +36,30 @@ func parseConfig() (*config, error) { if _, err := parser.Parse(); err != nil { return nil, err } + if err := resolvePasswordFile(conf); err != nil { + return nil, err + } return conf, nil } + +func resolvePasswordFile(conf *config) error { + if conf.MQTT.PasswordFile != "" && conf.MQTT.Password != "" { + return fmt.Errorf("mqtt.password and mqtt.password-file are mutually exclusive") + } + if conf.MQTT.PasswordFile != "" { + password, err := readPasswordFile(conf.MQTT.PasswordFile) + if err != nil { + return err + } + conf.MQTT.Password = password + } + return nil +} + +func readPasswordFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("could not read MQTT password file: %w", err) + } + return strings.TrimRight(string(data), "\n\r"), nil +} diff --git a/cmd/invertergui/config_test.go b/cmd/invertergui/config_test.go new file mode 100644 index 0000000..f7a8658 --- /dev/null +++ b/cmd/invertergui/config_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +const testInlineSecret = "inline-secret" + +func TestReadPasswordFile(t *testing.T) { + tests := []struct { + name string + content string + expected string + }{ + { + name: "plain password", + content: "secret", + expected: "secret", + }, + { + name: "password with trailing newline", + content: "secret\n", + expected: "secret", + }, + { + name: "password with trailing carriage return and newline", + content: "secret\r\n", + expected: "secret", + }, + { + name: "empty file", + content: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "password") + if err := os.WriteFile(path, []byte(tt.content), 0o600); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + got, err := readPasswordFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.expected { + t.Errorf("got %q, want %q", got, tt.expected) + } + }) + } +} + +func TestReadPasswordFile_NotFound(t *testing.T) { + _, err := readPasswordFile("/nonexistent/path/password") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestResolvePassword_MutuallyExclusive(t *testing.T) { + path := filepath.Join(t.TempDir(), "password") + if err := os.WriteFile(path, []byte("secret"), 0o600); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + conf := &config{} + conf.MQTT.Password = testInlineSecret + conf.MQTT.PasswordFile = path + + err := resolvePasswordFile(conf) + if err == nil { + t.Fatal("expected error when both mqtt.password and mqtt.password-file are set, got nil") + } +} + +func TestResolvePassword_FromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "password") + if err := os.WriteFile(path, []byte("file-secret\n"), 0o600); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + conf := &config{} + conf.MQTT.PasswordFile = path + + if err := resolvePasswordFile(conf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conf.MQTT.Password != "file-secret" { + t.Errorf("got %q, want %q", conf.MQTT.Password, "file-secret") + } +} + +func TestResolvePassword_NoFile(t *testing.T) { + conf := &config{} + conf.MQTT.Password = testInlineSecret + + if err := resolvePasswordFile(conf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conf.MQTT.Password != testInlineSecret { + t.Errorf("got %q, want %q", conf.MQTT.Password, testInlineSecret) + } +}