From 11d2736d86b03fb5f1244d7c8993ec618eaaf504 Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Mon, 19 Oct 2026 10:00:00 +0200 Subject: [PATCH] Support file-backed username/credential for external TURN servers (#4784) turn_servers already accepts secret_file for the dynamic (static-auth-secret) mechanism, but the static username/credential pair could only be written inline in the config, forcing deployments that mount secrets as files to inline them. Add username_file and credential_file, resolved through a single helper shared with secret_file: the inline value wins when both are set, file contents are trimmed, an empty file fails startup, and credential_file must not be readable by others. username_file carries no permission requirement because the username is not a secret. Co-authored-by: Ora Agent --- config-sample.yaml | 7 +++++ pkg/config/config.go | 48 ++++++++++++++++++++++++++--- pkg/config/config_test.go | 65 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/config-sample.yaml b/config-sample.yaml index c22716ca5..78410e84e 100644 --- a/config-sample.yaml +++ b/config-sample.yaml @@ -110,6 +110,13 @@ rtc: # # Insecure username/password authentication # username: "" # credential: "" + # # Alternatively, load the static username/credential pair from one YAML file: + # # username: myuser + # # credential: mycredential + # # The file must have no permissions for others (for example, chmod 640). + # # Inline username/credential take precedence; the pair is never mixed with + # # file values. Quoted YAML preserves whitespace in passwords. + # credentials_file: "/run/credentials/livekit/turn.yaml" # # allows LiveKit to monitor congestion when sending streams and automatically # # manage bandwidth utilization to avoid congestion/loss. Enabled by default # congestion_control: diff --git a/pkg/config/config.go b/pkg/config/config.go index 872f780d1..f404b9939 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -45,6 +45,10 @@ import ( ) const ( + turnCredentialsFileReadError = "reading turn credentials file %q: %w" + turnCredentialsFileParseError = "invalid YAML in turn credentials file %q" + turnCredentialsInlineWarning = "both static credentials and credentials_file are set for TURN server, the inline values will be used" + generatedCLIFlagUsage = "generated" // TURNMaxTTLSeconds is the operational maximum for TURN credential TTLs (24 hours). @@ -68,11 +72,12 @@ const ( ) var ( - ErrKeyFileIncorrectPermission = errors.New("key file others permissions must be set to 0") - ErrTURNSecretFileIncorrectPermission = errors.New("turn secret file others permissions must be set to 0") - ErrKeysNotSet = errors.New("one of key-file or keys must be provided") - ErrTURNSecretEmpty = errors.New("turn secret is empty") - ErrTURNServerNoCredentials = errors.New("turn server has no usable credentials: set a non-empty secret/secret_file for dynamic auth, or username and credential for static auth") + ErrKeyFileIncorrectPermission = errors.New("key file others permissions must be set to 0") + ErrTURNCredentialFileIncorrectPermission = errors.New("turn credentials file others permissions must be set to 0") + ErrTURNSecretFileIncorrectPermission = errors.New("turn secret file others permissions must be set to 0") + ErrKeysNotSet = errors.New("one of key-file or keys must be provided") + ErrTURNSecretEmpty = errors.New("turn secret is empty") + ErrTURNServerNoCredentials = errors.New("turn server has no usable credentials: set a non-empty secret/secret_file for dynamic auth, or username and credential for static auth") ) type Config struct { @@ -204,6 +209,8 @@ type TURNServer struct { Protocol string `yaml:"protocol,omitempty"` Username string `yaml:"username,omitempty"` Credential string `yaml:"credential,omitempty"` + // YAML file containing username and credential. Inline static credentials take precedence. + CredentialsFile string `yaml:"credentials_file,omitempty"` // Secret is used for TURN static auth secrets mechanism. When provided, // dynamic credentials are generated using HMAC-SHA1 instead of static Username/Credential Secret string `yaml:"secret,omitempty"` @@ -885,6 +892,37 @@ func (conf *Config) LoadTURNSecrets() error { conf.RTC.TURNServers[i].Secret = inlineSecret } + // Keep the static pair together; never combine one inline value with a + // credential from a different source. + if s.CredentialsFile != "" { + if s.Username != "" || s.Credential != "" { + logger.Warnw(turnCredentialsInlineWarning, nil, + "host", s.Host, "port", s.Port) + } else { + st, err := os.Stat(s.CredentialsFile) + if err != nil { + return err + } + if st.Mode().Perm()&otherFilter != 0o000 { + return ErrTURNCredentialFileIncorrectPermission + } + data, err := os.ReadFile(s.CredentialsFile) + if err != nil { + return fmt.Errorf(turnCredentialsFileReadError, s.CredentialsFile, err) + } + var credentials struct { + Username string `yaml:"username"` + Credential string `yaml:"credential"` + } + if err := yaml.Unmarshal(data, &credentials); err != nil { + // YAML type errors may contain scalar values from the secret file. + return fmt.Errorf(turnCredentialsFileParseError, s.CredentialsFile) + } + conf.RTC.TURNServers[i].Username = credentials.Username + conf.RTC.TURNServers[i].Credential = credentials.Credential + } + } + // validate credential mode as an explicit union rather than inferring it from a // post-load empty secret (which would silently fall back to static credentials) s := conf.RTC.TURNServers[i] diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 244bf84a7..2f44afdc4 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -16,6 +16,7 @@ package config import ( "context" + "fmt" "os" "path/filepath" "testing" @@ -325,3 +326,67 @@ func TestLoadTURNSecrets(t *testing.T) { require.Equal(t, "fromfile", conf.RTC.TURNServers[0].Secret) }) } + +// Exercise the operator-facing YAML path so this test also runs on the upstream +// branch, where credentials_file is not recognized yet. +func TestTURNCredentialsFile(t *testing.T) { + const ( + fileName = "turn-credentials.yaml" + username = "turn-user" + credential = " turn-password " + validCredentials = "username: turn-user\ncredential: ' turn-password '\n" + privateValue = "do-not-log-my-password" + redactedPrefix = "do-not-" + configFormat = "rtc:\n turn_servers:\n - host: turn.example.com\n credentials_file: %q\n%s" + inlineCredentials = " username: inline-user\n credential: inline-password\n" + ) + cases := []struct { + name string + data string + mode os.FileMode + inline string + missing bool + directory bool + wantError bool + wantUser string + wantCredential string + }{ + {name: "pair loaded from one file", data: validCredentials, mode: 0o600, wantUser: username, wantCredential: credential}, + {name: "group-readable file accepted", data: validCredentials, mode: 0o640, wantUser: username, wantCredential: credential}, + {name: "inline pair wins without reading missing file", missing: true, inline: inlineCredentials, wantUser: "inline-user", wantCredential: "inline-password"}, + {name: "inline whitespace preserved", missing: true, inline: " username: ' u '\n credential: ' c '\n", wantUser: " u ", wantCredential: " c "}, + {name: "partial inline username is not mixed with file", data: validCredentials, mode: 0o600, inline: " username: inline-user\n", wantError: true}, + {name: "partial inline password is not mixed with file", data: validCredentials, mode: 0o600, inline: " credential: inline-password\n", wantError: true}, + {name: "missing file", missing: true, wantError: true}, + {name: "directory cannot be read", directory: true, wantError: true}, + {name: "others permissions rejected", data: validCredentials, mode: 0o604, wantError: true}, + {name: "scalar credentials do not leak in errors", data: privateValue, mode: 0o600, wantError: true}, + {name: "invalid YAML", data: "username: [", mode: 0o600, wantError: true}, + {name: "empty file", mode: 0o600, wantError: true}, + {name: "whitespace file", data: " \n\t", mode: 0o600, wantError: true}, + {name: "missing username", data: "credential: password\n", mode: 0o600, wantError: true}, + {name: "missing password", data: "username: user\n", mode: 0o600, wantError: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), fileName) + switch { + case tc.directory: + require.NoError(t, os.Mkdir(path, 0o700)) + case !tc.missing: + require.NoError(t, os.WriteFile(path, []byte(tc.data), tc.mode)) + } + conf, err := NewConfig(fmt.Sprintf(configFormat, path, tc.inline), true, nil, nil) + require.NoError(t, err) + err = conf.LoadTURNSecrets() + if tc.wantError { + require.Error(t, err) + require.NotContains(t, err.Error(), redactedPrefix) + return + } + require.NoError(t, err) + require.Equal(t, tc.wantUser, conf.RTC.TURNServers[0].Username) + require.Equal(t, tc.wantCredential, conf.RTC.TURNServers[0].Credential) + }) + } +}