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:
Bjarne Øverli
2026-05-05 09:04:25 +02:00
parent a47e1b4427
commit 06d65bed74
4 changed files with 246 additions and 23 deletions
+62 -23
View File
@@ -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
+149
View File
@@ -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")
}
}
+30
View File
@@ -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:
+5
View File
@@ -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>