Interpolate ${ENV_VAR} in config.toml string values
Lets users keep secrets (Navidrome password, Plex/Jellyfin tokens, ytmusic client_secret, plugin API keys) out of the config file by referencing environment variables. Whole-value-only matching preserves literal $ characters in passwords without an escape syntax.
This commit is contained in:
+62
-23
@@ -24,6 +24,45 @@ func configPath() (string, error) {
|
||||
return filepath.Join(dir, "config.toml"), nil
|
||||
}
|
||||
|
||||
// parseString trims surrounding quotes from a TOML string value and, if the
|
||||
// result is exactly $NAME or ${NAME}, replaces it with the value of that
|
||||
// environment variable (or "" when unset). Mixed values containing other
|
||||
// characters are left untouched, so literal '$' in passwords is preserved.
|
||||
func parseString(s string) string {
|
||||
s = strings.Trim(s, `"'`)
|
||||
if len(s) < 2 || s[0] != '$' {
|
||||
return s
|
||||
}
|
||||
name := s[1:]
|
||||
if name[0] == '{' {
|
||||
if name[len(name)-1] != '}' {
|
||||
return s
|
||||
}
|
||||
name = name[1 : len(name)-1]
|
||||
}
|
||||
if !isEnvName(name) {
|
||||
return s
|
||||
}
|
||||
return os.Getenv(name)
|
||||
}
|
||||
|
||||
func isEnvName(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
switch {
|
||||
case r == '_':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case i > 0 && r >= '0' && r <= '9':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NavidromeConfig holds credentials for a Navidrome/Subsonic server.
|
||||
// All three fields must be non-empty for a client to be constructed.
|
||||
type NavidromeConfig struct {
|
||||
@@ -239,13 +278,13 @@ func Load() (Config, error) {
|
||||
case "navidrome":
|
||||
switch key {
|
||||
case "url":
|
||||
cfg.Navidrome.URL = strings.Trim(val, `"'`)
|
||||
cfg.Navidrome.URL = parseString(val)
|
||||
case "user":
|
||||
cfg.Navidrome.User = strings.Trim(val, `"'`)
|
||||
cfg.Navidrome.User = parseString(val)
|
||||
case "password":
|
||||
cfg.Navidrome.Password = strings.Trim(val, `"'`)
|
||||
cfg.Navidrome.Password = parseString(val)
|
||||
case "browse_sort":
|
||||
cfg.Navidrome.BrowseSort = strings.Trim(val, `"'`)
|
||||
cfg.Navidrome.BrowseSort = parseString(val)
|
||||
case "scrobble":
|
||||
// Opt-out: only mark disabled when the value is explicitly "false".
|
||||
cfg.Navidrome.ScrobbleDisabled = strings.ToLower(val) == "false"
|
||||
@@ -255,7 +294,7 @@ func Load() (Config, error) {
|
||||
case "enabled":
|
||||
cfg.Spotify.Disabled = strings.ToLower(val) == "false"
|
||||
case "client_id":
|
||||
cfg.Spotify.ClientID = strings.Trim(val, `"'`)
|
||||
cfg.Spotify.ClientID = parseString(val)
|
||||
case "bitrate":
|
||||
if v, err := strconv.Atoi(val); err == nil {
|
||||
cfg.Spotify.Bitrate = v
|
||||
@@ -266,31 +305,31 @@ func Load() (Config, error) {
|
||||
case "enabled":
|
||||
cfg.YouTubeMusic.Disabled = strings.ToLower(val) == "false"
|
||||
case "client_id":
|
||||
cfg.YouTubeMusic.ClientID = strings.Trim(val, `"'`)
|
||||
cfg.YouTubeMusic.ClientID = parseString(val)
|
||||
case "client_secret":
|
||||
cfg.YouTubeMusic.ClientSecret = strings.Trim(val, `"'`)
|
||||
cfg.YouTubeMusic.ClientSecret = parseString(val)
|
||||
case "cookies_from":
|
||||
cfg.YouTubeMusic.CookiesFrom = strings.Trim(val, `"'`)
|
||||
cfg.YouTubeMusic.CookiesFrom = parseString(val)
|
||||
}
|
||||
case "plex":
|
||||
switch key {
|
||||
case "url":
|
||||
cfg.Plex.URL = strings.Trim(val, `"'`)
|
||||
cfg.Plex.URL = parseString(val)
|
||||
case "token":
|
||||
cfg.Plex.Token = strings.Trim(val, `"'`)
|
||||
cfg.Plex.Token = parseString(val)
|
||||
}
|
||||
case "jellyfin":
|
||||
switch key {
|
||||
case "url":
|
||||
cfg.Jellyfin.URL = strings.Trim(val, `"'`)
|
||||
cfg.Jellyfin.URL = parseString(val)
|
||||
case "token":
|
||||
cfg.Jellyfin.Token = strings.Trim(val, `"'`)
|
||||
cfg.Jellyfin.Token = parseString(val)
|
||||
case "user":
|
||||
cfg.Jellyfin.User = strings.Trim(val, `"'`)
|
||||
cfg.Jellyfin.User = parseString(val)
|
||||
case "password":
|
||||
cfg.Jellyfin.Password = strings.Trim(val, `"'`)
|
||||
cfg.Jellyfin.Password = parseString(val)
|
||||
case "user_id":
|
||||
cfg.Jellyfin.UserID = strings.Trim(val, `"'`)
|
||||
cfg.Jellyfin.UserID = parseString(val)
|
||||
}
|
||||
default:
|
||||
// Handle [plugins] and [plugins.*] sections.
|
||||
@@ -301,7 +340,7 @@ func Load() (Config, error) {
|
||||
}
|
||||
if cfg.Plugins != nil {
|
||||
if m, ok := cfg.Plugins[pluginName]; ok {
|
||||
m[key] = strings.Trim(val, `"'`)
|
||||
m[key] = parseString(val)
|
||||
}
|
||||
}
|
||||
continue
|
||||
@@ -312,7 +351,7 @@ func Load() (Config, error) {
|
||||
cfg.Volume = v
|
||||
}
|
||||
case "repeat":
|
||||
val = strings.Trim(val, `"'`)
|
||||
val = parseString(val)
|
||||
switch strings.ToLower(val) {
|
||||
case "all", "one", "off":
|
||||
cfg.Repeat = strings.ToLower(val)
|
||||
@@ -330,13 +369,13 @@ func Load() (Config, error) {
|
||||
case "eq":
|
||||
cfg.EQ = parseEQ(val)
|
||||
case "eq_preset":
|
||||
cfg.EQPreset = strings.Trim(val, `"'`)
|
||||
cfg.EQPreset = parseString(val)
|
||||
case "theme":
|
||||
cfg.Theme = strings.Trim(val, `"'`)
|
||||
cfg.Theme = parseString(val)
|
||||
case "provider":
|
||||
cfg.Provider = strings.ToLower(strings.Trim(val, `"'`))
|
||||
cfg.Provider = strings.ToLower(parseString(val))
|
||||
case "visualizer":
|
||||
cfg.Visualizer = strings.Trim(val, `"'`)
|
||||
cfg.Visualizer = parseString(val)
|
||||
case "sample_rate":
|
||||
if v, err := strconv.Atoi(val); err == nil {
|
||||
cfg.SampleRate = v
|
||||
@@ -360,7 +399,7 @@ func Load() (Config, error) {
|
||||
case "compact":
|
||||
cfg.Compact = val == "true"
|
||||
case "audio_device":
|
||||
cfg.AudioDevice = strings.Trim(val, `"'`)
|
||||
cfg.AudioDevice = parseString(val)
|
||||
case "padding_horizontal":
|
||||
if v, err := strconv.Atoi(val); err == nil {
|
||||
cfg.PaddingH = v
|
||||
@@ -370,7 +409,7 @@ func Load() (Config, error) {
|
||||
cfg.PaddingV = v
|
||||
}
|
||||
case "log_level":
|
||||
lvl := strings.ToLower(strings.Trim(val, `"'`))
|
||||
lvl := strings.ToLower(parseString(val))
|
||||
switch lvl {
|
||||
case "debug", "info", "warn", "warning", "error":
|
||||
cfg.LogLevel = lvl
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseStringEnvInterpolation(t *testing.T) {
|
||||
t.Setenv("CLIAMP_TEST_VAR", "from-env")
|
||||
t.Setenv("CLIAMP_TEST_EMPTY", "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"plain quoted string", `"hello"`, "hello"},
|
||||
{"plain single-quoted", `'hello'`, "hello"},
|
||||
{"unquoted plain", `hello`, "hello"},
|
||||
{"dollar braces set", `"${CLIAMP_TEST_VAR}"`, "from-env"},
|
||||
{"dollar bare set", `"$CLIAMP_TEST_VAR"`, "from-env"},
|
||||
{"unquoted dollar braces", `${CLIAMP_TEST_VAR}`, "from-env"},
|
||||
{"unset var returns empty", `"${CLIAMP_NOT_SET_XYZ}"`, ""},
|
||||
{"empty var returns empty", `"${CLIAMP_TEST_EMPTY}"`, ""},
|
||||
{"literal dollar in middle preserved", `"p@$$w0rd"`, "p@$$w0rd"},
|
||||
{"literal dollar at start with non-name", `"$1abc"`, "$1abc"},
|
||||
{"unmatched brace left alone", `"${UNCLOSED"`, "${UNCLOSED"},
|
||||
{"only dollar", `"$"`, "$"},
|
||||
{"interpolation only on whole value", `"prefix-$CLIAMP_TEST_VAR"`, "prefix-$CLIAMP_TEST_VAR"},
|
||||
{"underscore-leading name", `"$_CLIAMP_TEST"`, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseString(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("parseString(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInterpolatesSecretsFromEnv(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("CLIAMP_TEST_NAVI_PASS", "s3cret!")
|
||||
t.Setenv("CLIAMP_TEST_PLEX_TOKEN", "tok-abc")
|
||||
t.Setenv("CLIAMP_TEST_JELLY_TOKEN", "jelly-tok")
|
||||
t.Setenv("CLIAMP_TEST_YT_SECRET", "yt-secret")
|
||||
|
||||
path := filepath.Join(os.Getenv("HOME"), ".config", "cliamp", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
data := []byte(`
|
||||
[navidrome]
|
||||
url = "https://music.example.com"
|
||||
user = "alice"
|
||||
password = "${CLIAMP_TEST_NAVI_PASS}"
|
||||
|
||||
[plex]
|
||||
url = "http://plex.local:32400"
|
||||
token = "$CLIAMP_TEST_PLEX_TOKEN"
|
||||
|
||||
[jellyfin]
|
||||
url = "https://jelly.example.com"
|
||||
token = "${CLIAMP_TEST_JELLY_TOKEN}"
|
||||
|
||||
[ytmusic]
|
||||
client_id = "literal-id"
|
||||
client_secret = "${CLIAMP_TEST_YT_SECRET}"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Navidrome.Password != "s3cret!" {
|
||||
t.Errorf("Navidrome.Password = %q, want %q", cfg.Navidrome.Password, "s3cret!")
|
||||
}
|
||||
if cfg.Plex.Token != "tok-abc" {
|
||||
t.Errorf("Plex.Token = %q, want %q", cfg.Plex.Token, "tok-abc")
|
||||
}
|
||||
if cfg.Jellyfin.Token != "jelly-tok" {
|
||||
t.Errorf("Jellyfin.Token = %q, want %q", cfg.Jellyfin.Token, "jelly-tok")
|
||||
}
|
||||
if cfg.YouTubeMusic.ClientID != "literal-id" {
|
||||
t.Errorf("YouTubeMusic.ClientID = %q, want %q", cfg.YouTubeMusic.ClientID, "literal-id")
|
||||
}
|
||||
if cfg.YouTubeMusic.ClientSecret != "yt-secret" {
|
||||
t.Errorf("YouTubeMusic.ClientSecret = %q, want %q", cfg.YouTubeMusic.ClientSecret, "yt-secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPreservesLiteralDollarInPassword(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
path := filepath.Join(os.Getenv("HOME"), ".config", "cliamp", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
data := []byte(`
|
||||
[navidrome]
|
||||
url = "https://music.example.com"
|
||||
user = "alice"
|
||||
password = "p@$$w0rd"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.Navidrome.Password != "p@$$w0rd" {
|
||||
t.Errorf("Navidrome.Password = %q, want literal %q", cfg.Navidrome.Password, "p@$$w0rd")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInterpolatesPluginSecrets(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("CLIAMP_TEST_LASTFM_KEY", "lastfm-abc")
|
||||
|
||||
path := filepath.Join(os.Getenv("HOME"), ".config", "cliamp", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
data := []byte(`
|
||||
[plugins.lastfm]
|
||||
api_key = "${CLIAMP_TEST_LASTFM_KEY}"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
got := cfg.Plugins["lastfm"]["api_key"]
|
||||
if got != "lastfm-abc" {
|
||||
t.Errorf("plugins.lastfm.api_key = %q, want %q", got, "lastfm-abc")
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,36 @@ log_level = "info"
|
||||
|
||||
```
|
||||
|
||||
## Secrets from Environment Variables
|
||||
|
||||
Any string value in `config.toml` can be read from an environment variable by setting the value to `$VAR_NAME` or `${VAR_NAME}`. This keeps passwords, tokens, and client secrets out of the file itself.
|
||||
|
||||
```toml
|
||||
[navidrome]
|
||||
url = "https://music.example.com"
|
||||
user = "alice"
|
||||
password = "${NAVIDROME_PASSWORD}"
|
||||
|
||||
[plex]
|
||||
url = "http://plex.local:32400"
|
||||
token = "$PLEX_TOKEN"
|
||||
|
||||
[jellyfin]
|
||||
url = "https://jelly.example.com"
|
||||
token = "${JELLYFIN_TOKEN}"
|
||||
|
||||
[ytmusic]
|
||||
client_id = "${YTMUSIC_CLIENT_ID}"
|
||||
client_secret = "${YTMUSIC_CLIENT_SECRET}"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Interpolation only happens when the **entire** value is `$NAME` or `${NAME}`. Mixed values like `"p@$$word"` are kept literally — no escaping needed.
|
||||
- Variable names match `[A-Za-z_][A-Za-z0-9_]*`.
|
||||
- If the variable is unset, the value is empty (the same as if you had left it blank).
|
||||
- Works for any string field, including plugin config under `[plugins.<name>]`.
|
||||
|
||||
## Default Provider
|
||||
|
||||
Set which provider to start with:
|
||||
|
||||
@@ -1608,6 +1608,11 @@
|
||||
<div class="feature-name">Self-Update</div>
|
||||
<p>Run <code>--upgrade</code> to update to the latest release in-terminal.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">$</div>
|
||||
<div class="feature-name">Env-Interpolated Secrets</div>
|
||||
<p>Reference any string in <code>config.toml</code> from the environment with <code>${VAR}</code> or <code>$VAR</code>. Keep passwords and tokens out of the file.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">⌁</div>
|
||||
<div class="feature-name">Remote Control</div>
|
||||
|
||||
Reference in New Issue
Block a user