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 <noreply@oratelecom.net>
This commit is contained in:
Ben YounesandOra Agent authored and GitHub committed 2026-10-19 16:00:00 +08:00
1 parent 412d00a479
commit 11d2736d86
3 files changed
+115 -5

No files matched your search

+7
View File
@@ -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:
+43 -5
View File
@@ -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]
+65
View File
@@ -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)
})
}
}