Merge pull request #47 from serhii-vasylkiv/mqtt-password-file
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled

Add support for reading MQTT password from file
This commit is contained in:
Nicholas Thompson
2026-02-17 08:23:37 +02:00
committed by GitHub
2 changed files with 143 additions and 6 deletions

View File

@@ -1,6 +1,10 @@
package main package main
import ( import (
"fmt"
"os"
"strings"
"github.com/jessevdk/go-flags" "github.com/jessevdk/go-flags"
) )
@@ -15,12 +19,13 @@ type config struct {
Enabled bool `long:"cli.enabled" env:"CLI_ENABLED" description:"Enable CLI output."` Enabled bool `long:"cli.enabled" env:"CLI_ENABLED" description:"Enable CLI output."`
} }
MQTT struct { MQTT struct {
Enabled bool `long:"mqtt.enabled" env:"MQTT_ENABLED" description:"Enable MQTT publishing."` 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."` 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."` 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."` 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"` 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"` 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\")"` 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 { if _, err := parser.Parse(); err != nil {
return nil, err return nil, err
} }
if err := resolvePasswordFile(conf); err != nil {
return nil, err
}
return conf, nil 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
}

View File

@@ -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)
}
}