mirror of
https://github.com/livekit/livekit.git
synced 2026-10-23 10:52:46 +00:00
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:
1 parent
412d00a479
commit
11d2736d86
3 files changed
+115
-5
No files matched your search
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user