Defaults for array-of-tables config settings

Due to https://github.com/BurntSushi/toml/issues/169, we can't simply
use IsDefined to check whether a user supplied a config value in an
array of tables, as in:

[[FallbackAPIServers]]
    CacheTTLSeconds = 123

We can work around this by using *T instead of T in the config
file schema. If the TOML key is not set, it will be parsed as nil.
This commit is contained in:
Evan Goode 2025-04-05 22:51:46 -04:00
parent 30ba03adf4
commit 3ed22110b3
4 changed files with 334 additions and 221 deletions

228
config.go
View File

@ -15,6 +15,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
) )
@ -28,6 +29,17 @@ type bodyLimitConfig struct {
SizeLimitKiB int SizeLimitKiB int
} }
type rawFallbackAPIServerConfig struct {
Nickname *string
SessionURL *string
AccountURL *string
ServicesURL *string
SkinDomains *[]string
CacheTTLSeconds *int
DenyUnknownUsers *bool
EnableAuthentication *bool
}
type FallbackAPIServerConfig struct { type FallbackAPIServerConfig struct {
Nickname string Nickname string
SessionURL string SessionURL string
@ -36,6 +48,17 @@ type FallbackAPIServerConfig struct {
SkinDomains []string SkinDomains []string
CacheTTLSeconds int CacheTTLSeconds int
DenyUnknownUsers bool DenyUnknownUsers bool
EnableAuthentication bool
}
type rawRegistrationOIDCConfig struct {
Name *string
Issuer *string
ClientID *string
ClientSecret *string
PKCE *bool
RequireInvite *bool
AllowChoosingPlayerName *bool
} }
type RegistrationOIDCConfig struct { type RegistrationOIDCConfig struct {
@ -92,7 +115,7 @@ type importExistingPlayerConfig struct {
RequireSkinVerification bool RequireSkinVerification bool
} }
type Config struct { type BaseConfig struct {
AllowCapes bool AllowCapes bool
AllowChangingPlayerName bool AllowChangingPlayerName bool
AllowMultipleAccessTokens bool AllowMultipleAccessTokens bool
@ -114,14 +137,12 @@ type Config struct {
EnableBackgroundEffect bool EnableBackgroundEffect bool
EnableFooter bool EnableFooter bool
EnableWebFrontEnd bool EnableWebFrontEnd bool
FallbackAPIServers []FallbackAPIServerConfig
ForwardSkins bool ForwardSkins bool
InstanceName string InstanceName string
ImportExistingPlayer importExistingPlayerConfig ImportExistingPlayer importExistingPlayerConfig
ListenAddress string ListenAddress string
LogRequests bool LogRequests bool
MinPasswordLength int MinPasswordLength int
RegistrationOIDC []RegistrationOIDCConfig
PreMigrationBackups bool PreMigrationBackups bool
RateLimit rateLimitConfig RateLimit rateLimitConfig
RegistrationExistingPlayer registrationExistingPlayerConfig RegistrationExistingPlayer registrationExistingPlayerConfig
@ -137,6 +158,18 @@ type Config struct {
ValidPlayerNameRegex string ValidPlayerNameRegex string
} }
type Config struct {
BaseConfig
FallbackAPIServers []FallbackAPIServerConfig
RegistrationOIDC []RegistrationOIDCConfig
}
type RawConfig struct {
BaseConfig
FallbackAPIServers []rawFallbackAPIServerConfig
RegistrationOIDC []rawRegistrationOIDCConfig
}
var defaultRateLimitConfig = rateLimitConfig{ var defaultRateLimitConfig = rateLimitConfig{
Enable: true, Enable: true,
RequestsPerSecond: 5, RequestsPerSecond: 5,
@ -153,8 +186,9 @@ var DefaultRistrettoConfig = &ristretto.Config{
BufferItems: 64, BufferItems: 64,
} }
func DefaultConfig() Config { func DefaultRawConfig() RawConfig {
return Config{ return RawConfig{
BaseConfig: BaseConfig{
AllowCapes: true, AllowCapes: true,
AllowChangingPlayerName: true, AllowChangingPlayerName: true,
AllowPasswordLogin: true, AllowPasswordLogin: true,
@ -186,7 +220,6 @@ func DefaultConfig() Config {
ListenAddress: "0.0.0.0:25585", ListenAddress: "0.0.0.0:25585",
LogRequests: true, LogRequests: true,
MinPasswordLength: 8, MinPasswordLength: 8,
RegistrationOIDC: []RegistrationOIDCConfig{},
OfflineSkins: true, OfflineSkins: true,
PreMigrationBackups: true, PreMigrationBackups: true,
RateLimit: defaultRateLimitConfig, RateLimit: defaultRateLimitConfig,
@ -207,9 +240,64 @@ func DefaultConfig() Config {
Allow: false, Allow: false,
}, },
ValidPlayerNameRegex: "^[a-zA-Z0-9_]+$", ValidPlayerNameRegex: "^[a-zA-Z0-9_]+$",
},
FallbackAPIServers: []rawFallbackAPIServerConfig{},
RegistrationOIDC: []rawRegistrationOIDCConfig{},
} }
} }
func DefaultConfig() Config {
return Config{
BaseConfig: DefaultRawConfig().BaseConfig,
}
}
func DefaultFallbackAPIServer() FallbackAPIServerConfig {
return FallbackAPIServerConfig{
CacheTTLSeconds: 600,
DenyUnknownUsers: false,
EnableAuthentication: true,
SkinDomains: []string{},
}
}
func DefaultRegistrationOIDC() RegistrationOIDCConfig {
return RegistrationOIDCConfig{
AllowChoosingPlayerName: true,
PKCE: true,
RequireInvite: false,
}
}
func AssignConfig[Res, Raw any](defaults Res, raw Raw) Res {
configType := reflect.TypeOf(defaults)
rawValue := reflect.ValueOf(raw)
defaultsValue := reflect.ValueOf(defaults)
out := new(Res)
outValue := reflect.ValueOf(out).Elem()
for i := 0; i < configType.NumField(); i += 1 {
key := configType.Field(i).Name
rawField := rawValue.FieldByName(key)
if rawField == (reflect.Value{}) {
continue
}
outField := outValue.FieldByName(key)
if rawField.IsNil() {
outField.Set(defaultsValue.FieldByName(key))
} else {
rawField := rawValue.FieldByName(key).Elem()
outField.Set(rawField)
}
}
return *out
}
func cleanURL(key string, required mo.Option[string], urlString string, trimTrailingSlash bool) (string, error) { func cleanURL(key string, required mo.Option[string], urlString string, trimTrailingSlash bool) (string, error) {
if urlString == "" { if urlString == "" {
if example, ok := required.Get(); ok { if example, ok := required.Get(); ok {
@ -250,19 +338,22 @@ func cleanDomain(key string, required mo.Option[string], domain string) (string,
return punycoded, nil return punycoded, nil
} }
func CleanConfig(config *Config) error { func CleanConfig(rawConfig *RawConfig) (Config, error) {
config := Config{}
config.BaseConfig = rawConfig.BaseConfig
var err error var err error
config.BaseURL, err = cleanURL("BaseURL", mo.Some("https://drasl.example.com"), config.BaseURL, true) config.BaseURL, err = cleanURL("BaseURL", mo.Some("https://drasl.example.com"), config.BaseURL, true)
if err != nil { if err != nil {
return err return Config{}, err
} }
if !IsValidPreferredLanguage(config.DefaultPreferredLanguage) { if !IsValidPreferredLanguage(config.DefaultPreferredLanguage) {
return fmt.Errorf("Invalid DefaultPreferredLanguage %s", config.DefaultPreferredLanguage) return Config{}, fmt.Errorf("Invalid DefaultPreferredLanguage %s", config.DefaultPreferredLanguage)
} }
if config.Domain == "" { if config.Domain == "" {
return errors.New("Domain must be set to a valid fully qualified domain name") return Config{}, errors.New("Domain must be set to a valid fully qualified domain name")
} }
config.Domain, err = cleanDomain( config.Domain, err = cleanDomain(
@ -271,40 +362,40 @@ func CleanConfig(config *Config) error {
config.Domain, config.Domain,
) )
if err != nil { if err != nil {
return err return Config{}, err
} }
if config.InstanceName == "" { if config.InstanceName == "" {
return errors.New("InstanceName must be set") return Config{}, errors.New("InstanceName must be set")
} }
if config.ListenAddress == "" { if config.ListenAddress == "" {
return errors.New("ListenAddress must be set. Example: 0.0.0.0:25585") return Config{}, errors.New("ListenAddress must be set. Example: 0.0.0.0:25585")
} }
if config.DefaultMaxPlayerCount < 0 && config.DefaultMaxPlayerCount != Constants.MaxPlayerCountUnlimited { if config.DefaultMaxPlayerCount < 0 && config.DefaultMaxPlayerCount != Constants.MaxPlayerCountUnlimited {
return fmt.Errorf("DefaultMaxPlayerCount must be >= 0, or %d to indicate unlimited players", Constants.MaxPlayerCountUnlimited) return Config{}, fmt.Errorf("DefaultMaxPlayerCount must be >= 0, or %d to indicate unlimited players", Constants.MaxPlayerCountUnlimited)
} }
if config.RegistrationNewPlayer.Allow { if config.RegistrationNewPlayer.Allow {
if !config.CreateNewPlayer.Allow { if !config.CreateNewPlayer.Allow {
return errors.New("If RegisterNewPlayer is allowed, CreateNewPlayer must be allowed.") return Config{}, errors.New("If RegisterNewPlayer is allowed, CreateNewPlayer must be allowed.")
} }
} }
if config.RegistrationExistingPlayer.Allow { if config.RegistrationExistingPlayer.Allow {
if !config.ImportExistingPlayer.Allow { if !config.ImportExistingPlayer.Allow {
return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer must be allowed.") return Config{}, errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer must be allowed.")
} }
if config.ImportExistingPlayer.Nickname == "" { if config.ImportExistingPlayer.Nickname == "" {
return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.Nickname must be set") return Config{}, errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.Nickname must be set")
} }
if config.ImportExistingPlayer.SessionURL == "" { if config.ImportExistingPlayer.SessionURL == "" {
return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.SessionURL must be set. Example: https://sessionserver.mojang.com") return Config{}, errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.SessionURL must be set. Example: https://sessionserver.mojang.com")
} }
if config.ImportExistingPlayer.AccountURL == "" { if config.ImportExistingPlayer.AccountURL == "" {
return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.AccountURL must be set. Example: https://api.mojang.com") return Config{}, errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.AccountURL must be set. Example: https://api.mojang.com")
} }
} }
if config.ImportExistingPlayer.Allow { if config.ImportExistingPlayer.Allow {
if config.ImportExistingPlayer.Nickname == "" { if config.ImportExistingPlayer.Nickname == "" {
return errors.New("ImportExistingPlayer.Nickname must be set") return Config{}, errors.New("ImportExistingPlayer.Nickname must be set")
} }
config.ImportExistingPlayer.SessionURL, err = cleanURL( config.ImportExistingPlayer.SessionURL, err = cleanURL(
@ -313,7 +404,7 @@ func CleanConfig(config *Config) error {
config.ImportExistingPlayer.SessionURL, true, config.ImportExistingPlayer.SessionURL, true,
) )
if err != nil { if err != nil {
return err return Config{}, err
} }
config.ImportExistingPlayer.AccountURL, err = cleanURL( config.ImportExistingPlayer.AccountURL, err = cleanURL(
@ -322,7 +413,7 @@ func CleanConfig(config *Config) error {
config.ImportExistingPlayer.AccountURL, true, config.ImportExistingPlayer.AccountURL, true,
) )
if err != nil { if err != nil {
return err return Config{}, err
} }
config.ImportExistingPlayer.SetSkinURL, err = cleanURL( config.ImportExistingPlayer.SetSkinURL, err = cleanURL(
@ -331,66 +422,72 @@ func CleanConfig(config *Config) error {
config.ImportExistingPlayer.SetSkinURL, true, config.ImportExistingPlayer.SetSkinURL, true,
) )
if err != nil { if err != nil {
return err return Config{}, err
} }
} }
fallbackAPIServerNames := mapset.NewSet[string]() fallbackAPIServerNames := mapset.NewSet[string]()
for _, fallbackAPIServer := range PtrSlice(config.FallbackAPIServers) { for _, rawFallbackAPIServer := range PtrSlice(rawConfig.FallbackAPIServers) {
if fallbackAPIServer.Nickname == "" { fallbackAPIServerConfig := AssignConfig(DefaultFallbackAPIServer(), *rawFallbackAPIServer)
return errors.New("FallbackAPIServer Nickname must be set")
}
if fallbackAPIServerNames.Contains(fallbackAPIServer.Nickname) {
return fmt.Errorf("Duplicate FallbackAPIServer Nickname: %s", fallbackAPIServer.Nickname)
}
fallbackAPIServerNames.Add(fallbackAPIServer.Nickname)
fallbackAPIServer.SessionURL, err = cleanURL( if fallbackAPIServerConfig.Nickname == "" {
fmt.Sprintf("FallbackAPIServer %s SessionURL", fallbackAPIServer.Nickname), return Config{}, errors.New("FallbackAPIServer Nickname must be set")
}
if fallbackAPIServerNames.Contains(fallbackAPIServerConfig.Nickname) {
return Config{}, fmt.Errorf("Duplicate FallbackAPIServer Nickname: %s", fallbackAPIServerConfig.Nickname)
}
fallbackAPIServerNames.Add(fallbackAPIServerConfig.Nickname)
fallbackAPIServerConfig.SessionURL, err = cleanURL(
fmt.Sprintf("FallbackAPIServer %s SessionURL", fallbackAPIServerConfig.Nickname),
mo.Some("https://sessionserver.mojang.com"), mo.Some("https://sessionserver.mojang.com"),
fallbackAPIServer.SessionURL, true, fallbackAPIServerConfig.SessionURL, true,
) )
if err != nil { if err != nil {
return err return Config{}, err
} }
fallbackAPIServer.AccountURL, err = cleanURL( fallbackAPIServerConfig.AccountURL, err = cleanURL(
fmt.Sprintf("FallbackAPIServer %s AccountURL", fallbackAPIServer.Nickname), fmt.Sprintf("FallbackAPIServer %s AccountURL", fallbackAPIServerConfig.Nickname),
mo.Some("https://api.mojang.com"), mo.Some("https://api.mojang.com"),
fallbackAPIServer.AccountURL, true, fallbackAPIServerConfig.AccountURL, true,
) )
if err != nil { if err != nil {
return err return Config{}, err
} }
fallbackAPIServer.ServicesURL, err = cleanURL( fallbackAPIServerConfig.ServicesURL, err = cleanURL(
fmt.Sprintf("FallbackAPIServer %s ServicesURL", fallbackAPIServer.Nickname), fmt.Sprintf("FallbackAPIServer %s ServicesURL", fallbackAPIServerConfig.Nickname),
mo.Some("https://api.minecraftservices.com"), mo.Some("https://api.minecraftservices.com"),
fallbackAPIServer.ServicesURL, true, fallbackAPIServerConfig.ServicesURL, true,
) )
if err != nil { if err != nil {
return err return Config{}, err
} }
for _, skinDomain := range PtrSlice(fallbackAPIServer.SkinDomains) { for _, skinDomain := range PtrSlice(fallbackAPIServerConfig.SkinDomains) {
*skinDomain, err = cleanDomain( *skinDomain, err = cleanDomain(
fmt.Sprintf("FallbackAPIServer %s SkinDomain", fallbackAPIServer.Nickname), fmt.Sprintf("FallbackAPIServer %s SkinDomain", fallbackAPIServerConfig.Nickname),
mo.Some("textures.minecraft.net"), mo.Some("textures.minecraft.net"),
*skinDomain, *skinDomain,
) )
if err != nil { if err != nil {
return err return Config{}, err
}
} }
} }
config.FallbackAPIServers = append(config.FallbackAPIServers, fallbackAPIServerConfig)
}
oidcNames := mapset.NewSet[string]() oidcNames := mapset.NewSet[string]()
for _, oidcConfig := range PtrSlice(config.RegistrationOIDC) { for _, rawOIDCConfig := range PtrSlice(rawConfig.RegistrationOIDC) {
oidcConfig := AssignConfig(DefaultRegistrationOIDC(), *rawOIDCConfig)
if oidcConfig.Name == "" { if oidcConfig.Name == "" {
return errors.New("RegistrationOIDC Name must be set") return Config{}, errors.New("RegistrationOIDC Name must be set")
} }
if oidcNames.Contains(oidcConfig.Name) { if oidcNames.Contains(oidcConfig.Name) {
return fmt.Errorf("Duplicate RegistrationOIDC Name: %s", oidcConfig.Name) return Config{}, fmt.Errorf("Duplicate RegistrationOIDC Name: %s", oidcConfig.Name)
} }
oidcNames.Add(oidcConfig.Name) oidcNames.Add(oidcConfig.Name)
oidcConfig.Issuer, err = cleanURL( oidcConfig.Issuer, err = cleanURL(
@ -400,10 +497,12 @@ func CleanConfig(config *Config) error {
false, false,
) )
if err != nil { if err != nil {
return err return Config{}, err
} }
config.RegistrationOIDC = append(config.RegistrationOIDC, oidcConfig)
} }
return nil return config, nil
} }
const TEMPLATE_CONFIG_FILE = `# Drasl default config file const TEMPLATE_CONFIG_FILE = `# Drasl default config file
@ -422,7 +521,8 @@ Allow = true
RequireInvite = true RequireInvite = true
` `
func HandleDeprecations(config Config, metadata *toml.MetaData) [][]string { func HandleDeprecations(oldRawConfig *RawConfig, metadata *toml.MetaData) (RawConfig, [][]string) {
rawConfig := *oldRawConfig
deprecatedPaths := make([][]string, 0) deprecatedPaths := make([][]string, 0)
warningTemplate := "Warning: config option %s is deprecated and will be removed in a future version. Use %s instead." warningTemplate := "Warning: config option %s is deprecated and will be removed in a future version. Use %s instead."
@ -432,7 +532,7 @@ func HandleDeprecations(config Config, metadata *toml.MetaData) [][]string {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "CreateNewPlayer.AllowChoosingUUID")) LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "CreateNewPlayer.AllowChoosingUUID"))
deprecatedPaths = append(deprecatedPaths, path_) deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("CreateNewPlayer", "AllowChoosingUUID") { if !metadata.IsDefined("CreateNewPlayer", "AllowChoosingUUID") {
config.CreateNewPlayer.AllowChoosingUUID = config.RegistrationNewPlayer.AllowChoosingUUID rawConfig.CreateNewPlayer.AllowChoosingUUID = rawConfig.RegistrationNewPlayer.AllowChoosingUUID
} }
} }
path_ = []string{"RegistrationExistingPlayer", "Nickname"} path_ = []string{"RegistrationExistingPlayer", "Nickname"}
@ -440,7 +540,7 @@ func HandleDeprecations(config Config, metadata *toml.MetaData) [][]string {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.Nickname")) LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.Nickname"))
deprecatedPaths = append(deprecatedPaths, path_) deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("ImportExistingPlayer", "Nickname") { if !metadata.IsDefined("ImportExistingPlayer", "Nickname") {
config.ImportExistingPlayer.Nickname = config.RegistrationExistingPlayer.Nickname rawConfig.ImportExistingPlayer.Nickname = rawConfig.RegistrationExistingPlayer.Nickname
} }
} }
path_ = []string{"RegistrationExistingPlayer", "SessionURL"} path_ = []string{"RegistrationExistingPlayer", "SessionURL"}
@ -448,7 +548,7 @@ func HandleDeprecations(config Config, metadata *toml.MetaData) [][]string {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.SessionURL")) LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.SessionURL"))
deprecatedPaths = append(deprecatedPaths, path_) deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("ImportExistingPlayer", "SessionURL") { if !metadata.IsDefined("ImportExistingPlayer", "SessionURL") {
config.ImportExistingPlayer.SessionURL = config.RegistrationExistingPlayer.SessionURL rawConfig.ImportExistingPlayer.SessionURL = rawConfig.RegistrationExistingPlayer.SessionURL
} }
} }
path_ = []string{"RegistrationExistingPlayer", "AccountURL"} path_ = []string{"RegistrationExistingPlayer", "AccountURL"}
@ -456,7 +556,7 @@ func HandleDeprecations(config Config, metadata *toml.MetaData) [][]string {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.AccountURL")) LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.AccountURL"))
deprecatedPaths = append(deprecatedPaths, path_) deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("ImportExistingPlayer", "AccountURL") { if !metadata.IsDefined("ImportExistingPlayer", "AccountURL") {
config.ImportExistingPlayer.AccountURL = config.RegistrationExistingPlayer.AccountURL rawConfig.ImportExistingPlayer.AccountURL = rawConfig.RegistrationExistingPlayer.AccountURL
} }
} }
path_ = []string{"RegistrationExistingPlayer", "SetSkinURL"} path_ = []string{"RegistrationExistingPlayer", "SetSkinURL"}
@ -464,7 +564,7 @@ func HandleDeprecations(config Config, metadata *toml.MetaData) [][]string {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.SetSkinURL")) LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.SetSkinURL"))
deprecatedPaths = append(deprecatedPaths, path_) deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("ImportExistingPlayer", "SetSkinURL") { if !metadata.IsDefined("ImportExistingPlayer", "SetSkinURL") {
config.ImportExistingPlayer.SetSkinURL = config.RegistrationExistingPlayer.SetSkinURL rawConfig.ImportExistingPlayer.SetSkinURL = rawConfig.RegistrationExistingPlayer.SetSkinURL
} }
} }
path_ = []string{"RegistrationExistingPlayer", "RequireSkinVerification"} path_ = []string{"RegistrationExistingPlayer", "RequireSkinVerification"}
@ -472,15 +572,15 @@ func HandleDeprecations(config Config, metadata *toml.MetaData) [][]string {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.RequireSkinVerification")) LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.RequireSkinVerification"))
deprecatedPaths = append(deprecatedPaths, path_) deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("ImportExistingPlayer", "RequireSkinVerification") { if !metadata.IsDefined("ImportExistingPlayer", "RequireSkinVerification") {
config.ImportExistingPlayer.RequireSkinVerification = config.RegistrationExistingPlayer.RequireSkinVerification rawConfig.ImportExistingPlayer.RequireSkinVerification = rawConfig.RegistrationExistingPlayer.RequireSkinVerification
} }
} }
return deprecatedPaths return rawConfig, deprecatedPaths
} }
func ReadConfig(path string, createIfNotExists bool) (Config, [][]string, error) { func ReadConfig(path string, createIfNotExists bool) (Config, [][]string, error) {
config := DefaultConfig() rawConfig := DefaultRawConfig()
_, err := os.Stat(path) _, err := os.Stat(path)
if err != nil { if err != nil {
@ -501,15 +601,15 @@ func ReadConfig(path string, createIfNotExists bool) (Config, [][]string, error)
} }
LogInfo("Loading config from", path) LogInfo("Loading config from", path)
metadata, err := toml.DecodeFile(path, &config) metadata, err := toml.DecodeFile(path, &rawConfig)
Check(err) Check(err)
for _, key := range metadata.Undecoded() { for _, key := range metadata.Undecoded() {
LogInfo("Warning: unknown config option", strings.Join(key, ".")) LogInfo("Warning: unknown config option", strings.Join(key, "."))
} }
deprecations := HandleDeprecations(config, &metadata) rawConfig, deprecations := HandleDeprecations(&rawConfig, &metadata)
err = CleanConfig(&config) config, err := CleanConfig(&rawConfig)
if err != nil { if err != nil {
return Config{}, nil, err return Config{}, nil, err
} }

View File

@ -7,11 +7,13 @@ import (
"testing" "testing"
) )
func configTestConfig(stateDirectory string) *Config { func configTestRawConfig(stateDirectory string) RawConfig {
config := testConfig() rawConfig := RawConfig{
config.StateDirectory = stateDirectory BaseConfig: testConfig().BaseConfig,
config.DataDirectory = "." }
return config rawConfig.StateDirectory = stateDirectory
rawConfig.DataDirectory = "."
return rawConfig
} }
func TestConfig(t *testing.T) { func TestConfig(t *testing.T) {
@ -19,136 +21,138 @@ func TestConfig(t *testing.T) {
sd := Unwrap(os.MkdirTemp("", "tmp")) sd := Unwrap(os.MkdirTemp("", "tmp"))
defer os.RemoveAll(sd) defer os.RemoveAll(sd)
config := configTestConfig(sd) rawConfig := configTestRawConfig(sd)
assert.Nil(t, CleanConfig(config)) assert.Nil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.BaseURL = "https://δρασλ.example.com/" rawConfig.BaseURL = "https://δρασλ.example.com/"
config.Domain = "δρασλ.example.com" rawConfig.Domain = "δρασλ.example.com"
assert.Nil(t, CleanConfig(config)) config, err := CleanConfig(&rawConfig)
assert.Nil(t, err)
assert.Equal(t, "https://xn--mxafwwl.example.com", config.BaseURL) assert.Equal(t, "https://xn--mxafwwl.example.com", config.BaseURL)
assert.Equal(t, "xn--mxafwwl.example.com", config.Domain) assert.Equal(t, "xn--mxafwwl.example.com", config.Domain)
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.BaseURL = "" rawConfig.BaseURL = ""
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.BaseURL = ":an invalid URL" rawConfig.BaseURL = ":an invalid URL"
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.DefaultPreferredLanguage = "xx" rawConfig.DefaultPreferredLanguage = "xx"
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.Domain = "" rawConfig.Domain = ""
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.InstanceName = "" rawConfig.InstanceName = ""
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.ListenAddress = "" rawConfig.ListenAddress = ""
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.DefaultMaxPlayerCount = Constants.MaxPlayerCountUseDefault rawConfig.DefaultMaxPlayerCount = Constants.MaxPlayerCountUseDefault
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.DefaultMaxPlayerCount = Constants.MaxPlayerCountUnlimited rawConfig.DefaultMaxPlayerCount = Constants.MaxPlayerCountUnlimited
assert.Nil(t, CleanConfig(config)) assert.Nil(t, UnwrapError(CleanConfig(&rawConfig)))
// Missing state directory should be ignored // Missing state directory should be ignored
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.StateDirectory = "/tmp/DraslInvalidStateDirectoryNothingHere" rawConfig.StateDirectory = "/tmp/DraslInvalidStateDirectoryNothingHere"
assert.Nil(t, CleanConfig(config)) assert.Nil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.RegistrationExistingPlayer.Allow = true rawConfig.RegistrationExistingPlayer.Allow = true
config.ImportExistingPlayer.Allow = true rawConfig.ImportExistingPlayer.Allow = true
config.ImportExistingPlayer.Nickname = "Example" rawConfig.ImportExistingPlayer.Nickname = "Example"
config.ImportExistingPlayer.SessionURL = "https://δρασλ.example.com/" rawConfig.ImportExistingPlayer.SessionURL = "https://δρασλ.example.com/"
config.ImportExistingPlayer.AccountURL = "https://drasl.example.com/" rawConfig.ImportExistingPlayer.AccountURL = "https://drasl.example.com/"
assert.Nil(t, CleanConfig(config)) config, err = CleanConfig(&rawConfig)
assert.Nil(t, err)
assert.Equal(t, "https://xn--mxafwwl.example.com", config.ImportExistingPlayer.SessionURL) assert.Equal(t, "https://xn--mxafwwl.example.com", config.ImportExistingPlayer.SessionURL)
assert.Equal(t, "https://drasl.example.com", config.ImportExistingPlayer.AccountURL) assert.Equal(t, "https://drasl.example.com", config.ImportExistingPlayer.AccountURL)
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.RegistrationExistingPlayer.Allow = true rawConfig.RegistrationExistingPlayer.Allow = true
config.ImportExistingPlayer.Nickname = "" rawConfig.ImportExistingPlayer.Nickname = ""
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.RegistrationExistingPlayer.Allow = true rawConfig.RegistrationExistingPlayer.Allow = true
config.ImportExistingPlayer.SessionURL = "" rawConfig.ImportExistingPlayer.SessionURL = ""
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
config.RegistrationExistingPlayer.Allow = true rawConfig.RegistrationExistingPlayer.Allow = true
config.ImportExistingPlayer.AccountURL = "" rawConfig.ImportExistingPlayer.AccountURL = ""
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
config = configTestConfig(sd) rawConfig = configTestRawConfig(sd)
testFallbackAPIServer := FallbackAPIServerConfig{ testFallbackAPIServer := rawFallbackAPIServerConfig{
Nickname: "Nickname", Nickname: Ptr("Nickname"),
SessionURL: "https://δρασλ.example.com/", SessionURL: Ptr("https://δρασλ.example.com/"),
AccountURL: "https://δρασλ.example.com/", AccountURL: Ptr("https://δρασλ.example.com/"),
ServicesURL: "https://δρασλ.example.com/", ServicesURL: Ptr("https://δρασλ.example.com/"),
SkinDomains: []string{"δρασλ.example.com"}, SkinDomains: Ptr([]string{"δρασλ.example.com"}),
} }
fb := testFallbackAPIServer fb := testFallbackAPIServer
config.FallbackAPIServers = []FallbackAPIServerConfig{fb} rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb}
assert.Nil(t, CleanConfig(config)) config, err = CleanConfig(&rawConfig)
assert.Nil(t, err)
assert.Equal(t, []FallbackAPIServerConfig{{ assert.Equal(t, 1, len(config.FallbackAPIServers))
Nickname: fb.Nickname, assert.Equal(t, *fb.Nickname, config.FallbackAPIServers[0].Nickname)
SessionURL: "https://xn--mxafwwl.example.com", assert.Equal(t, "https://xn--mxafwwl.example.com", config.FallbackAPIServers[0].SessionURL)
AccountURL: "https://xn--mxafwwl.example.com", assert.Equal(t, "https://xn--mxafwwl.example.com", config.FallbackAPIServers[0].AccountURL)
ServicesURL: "https://xn--mxafwwl.example.com", assert.Equal(t, "https://xn--mxafwwl.example.com", config.FallbackAPIServers[0].ServicesURL)
SkinDomains: []string{"xn--mxafwwl.example.com"}, assert.Equal(t, []string{"xn--mxafwwl.example.com"}, config.FallbackAPIServers[0].SkinDomains)
}}, config.FallbackAPIServers)
fb = testFallbackAPIServer fb = testFallbackAPIServer
fb.Nickname = "" fb.Nickname = Ptr("")
config.FallbackAPIServers = []FallbackAPIServerConfig{fb} rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb}
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
fb = testFallbackAPIServer fb = testFallbackAPIServer
fb.SessionURL = "" fb.SessionURL = Ptr("")
config.FallbackAPIServers = []FallbackAPIServerConfig{fb} rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb}
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
fb = testFallbackAPIServer fb = testFallbackAPIServer
fb.SessionURL = ":invalid URL" fb.SessionURL = Ptr(":invalid URL")
config.FallbackAPIServers = []FallbackAPIServerConfig{fb} rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb}
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
fb = testFallbackAPIServer fb = testFallbackAPIServer
fb.AccountURL = "" fb.AccountURL = Ptr("")
config.FallbackAPIServers = []FallbackAPIServerConfig{fb} rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb}
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
fb = testFallbackAPIServer fb = testFallbackAPIServer
fb.AccountURL = ":invalid URL" fb.AccountURL = Ptr(":invalid URL")
config.FallbackAPIServers = []FallbackAPIServerConfig{fb} rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb}
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
fb = testFallbackAPIServer fb = testFallbackAPIServer
fb.ServicesURL = "" fb.ServicesURL = Ptr("")
config.FallbackAPIServers = []FallbackAPIServerConfig{fb} rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb}
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
fb = testFallbackAPIServer fb = testFallbackAPIServer
fb.ServicesURL = ":invalid URL" fb.ServicesURL = Ptr(":invalid URL")
config.FallbackAPIServers = []FallbackAPIServerConfig{fb} rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb}
assert.NotNil(t, CleanConfig(config)) assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig)))
// Test that TEMPLATE_CONFIG_FILE is valid // Test that TEMPLATE_CONFIG_FILE is valid
var templateConfig Config var templateConfig Config
_, err := toml.Decode(TEMPLATE_CONFIG_FILE, &templateConfig) _, err = toml.Decode(TEMPLATE_CONFIG_FILE, &templateConfig)
assert.Nil(t, err) assert.Nil(t, err)
// Test that the example configs are valid // Test that the example configs are valid
@ -167,4 +171,9 @@ func TestConfig(t *testing.T) {
configBytes, err = os.ReadFile("example/docker-caddy/config/config.toml") configBytes, err = os.ReadFile("example/docker-caddy/config/config.toml")
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, correctBytes, configBytes) assert.Equal(t, correctBytes, configBytes)
// Test AssignConfig
defaults := DefaultFallbackAPIServer()
assigned := AssignConfig(defaults, rawFallbackAPIServerConfig{})
assert.Equal(t, defaults, assigned)
} }

View File

@ -37,11 +37,11 @@ Other available options:
- `[[FallbackAPIServers]]`: Allows players to authenticate using other API servers. For example, say you had a Minecraft server configured to authenticate players with your Drasl instance. You could configure Mojang's API as a fallback, and a player signed in with either a Drasl account or a Mojang account could play on your server. Does not work with Minecraft servers that have `enforce-secure-profile=true` in server.properties. See [recipes.md](recipes.md) for example configurations. - `[[FallbackAPIServers]]`: Allows players to authenticate using other API servers. For example, say you had a Minecraft server configured to authenticate players with your Drasl instance. You could configure Mojang's API as a fallback, and a player signed in with either a Drasl account or a Mojang account could play on your server. Does not work with Minecraft servers that have `enforce-secure-profile=true` in server.properties. See [recipes.md](recipes.md) for example configurations.
- You can configure any number of fallback API servers, and they will be tried in sequence, in the order they appear in the config file. By default, none are configured. - You can configure any number of fallback API servers, and they will be tried in sequence, in the order they appear in the config file. By default, none are configured.
- `Nickname`: A name for the API server - `Nickname`: A name for the API server. String. Example value: `"Mojang"`.
- `AccountURL`: The URL of the "account" server. String. Example value: `"https://api.mojang.com"`. - `AccountURL`: The URL of the "account" server. String. Example value: `"https://api.mojang.com"`.
- `SessionURL`: The URL of the "session" server. String. Example value: `"https://sessionserver.mojang.com"`. - `SessionURL`: The URL of the "session" server. String. Example value: `"https://sessionserver.mojang.com"`.
- `ServicesURL`: The URL of the "services" server. String. Example value: `"https://api.minecraftservices.com"`. - `ServicesURL`: The URL of the "services" server. String. Example value: `"https://api.minecraftservices.com"`.
- `SkinDomains`: Array of domains where skins are hosted. For authlib-injector-compatible API servers, the correct value should be returned by the root of the API, e.g. go to [https://example.com/yggdrasil](https://example.com/yggdrasil) and look for the `skinDomains` field. Array of strings. Example value: `["textures.minecraft.net"]` - `SkinDomains`: Array of domains where skins are hosted. For authlib-injector-compatible API servers, the correct value should be returned by the root of the API, e.g. go to [https://example.com/yggdrasil](https://example.com/yggdrasil) and look for the `skinDomains` field. Array of strings. Example value: `["textures.minecraft.net"]`.
- Note: API servers set up for authlib-injector may only give you one URL---if their API URL is e.g. `https://example.com/yggdrasil`, then you would use the following settings: - Note: API servers set up for authlib-injector may only give you one URL---if their API URL is e.g. `https://example.com/yggdrasil`, then you would use the following settings:
``` ```
@ -50,11 +50,11 @@ Other available options:
ServicesURL = https://example.com/yggdrasil/minecraftservices ServicesURL = https://example.com/yggdrasil/minecraftservices
``` ```
- `CacheTTLSec`: Time in seconds to cache API server responses. This option is set to `0` by default, which disables caching. For authentication servers like Mojang which may rate-limit, it's recommended to at least set it to something small like `60`. Integer. Default value: `0`. - `CacheTTLSec`: Time in seconds to cache API server responses. This option is set to `0` by default, which disables caching. For authentication servers like Mojang which may rate-limit, it's recommended to at least set it to something small like `60`. Integer. Default value: `600` (10 minutes).
- `DenyUnknownUsers`: Don't allow clients using this authentication server to log in to a Minecraft server using Drasl unless there is a Drasl user with the client's player name. This option effectively allows you to use Drasl as a whitelist for your Minecraft server. You could allow users to authenticate using, for example, Mojang's authentication server, but only if they are also registered on Drasl. Boolean. Default value: `false`. - `DenyUnknownUsers`: Don't allow clients using this authentication server to log in to a Minecraft server using Drasl unless there is a Drasl user with the client's player name. This option effectively allows you to use Drasl as a whitelist for your Minecraft server. You could allow users to authenticate using, for example, Mojang's authentication server, but only if they are also registered on Drasl. Boolean. Default value: `false`.
- `OfflineSkins`: Try to resolve skins for "offline" UUIDs. When `online-mode` is set to `false` in `server.properties` (sometimes called "offline mode"), players' UUIDs are computed deterministically from their player names instead of being managed by the authentication server. If this option is enabled and a skin for an unknown UUID is requested, Drasl will search for a matching player by offline UUID. This option is required to see other players' skins on offline servers. Boolean. Default value: `true`. - `OfflineSkins`: Try to resolve skins for "offline" UUIDs. When `online-mode` is set to `false` in `server.properties` (sometimes called "offline mode"), players' UUIDs are computed deterministically from their player names instead of being managed by the authentication server. If this option is enabled and a skin for an unknown UUID is requested, Drasl will search for a matching player by offline UUID. This option is required to see other players' skins on offline servers. Boolean. Default value: `true`.
<!-- - `[TransientLogin]`: Allow certain usernames to authenticate with a shared password, without registering. Useful for supporting bot accounts. --> <!-- - `[TransientLogin]`: Allow certain usernames to authenticate with a shared password, without registering. Useful for supporting bot accounts. -->
<!-- - `Allow`: Boolean. Default value: `false`. --> <!-- - `Allow`: Boolean. Default value: `false`. -->
@ -89,9 +89,9 @@ Other available options:
- `Issuer`: OIDC issuer URL. String. Example value: `"https://idm.example.com/oauth2/openid/drasl"`. - `Issuer`: OIDC issuer URL. String. Example value: `"https://idm.example.com/oauth2/openid/drasl"`.
- `ClientID`: OIDC client ID. String. Example value: `"drasl"`. - `ClientID`: OIDC client ID. String. Example value: `"drasl"`.
- `ClientSecret`: OIDC client secret. String. Example value: `"yfUfeFuUI6YiTU23ngJtq8ioYq75FxQid8ls3RdNf0qWSiBO"`. - `ClientSecret`: OIDC client secret. String. Example value: `"yfUfeFuUI6YiTU23ngJtq8ioYq75FxQid8ls3RdNf0qWSiBO"`.
- `PKCE`: Whether to use [PKCE](https://datatracker.ietf.org/doc/html/rfc7636). Recommended, but must be supported by the OIDC provider. Boolean. Default value: `false`. - `PKCE`: Whether to use [PKCE](https://datatracker.ietf.org/doc/html/rfc7636). Recommended, but must be supported by the OIDC provider. Boolean. Default value: `true`.
- `RequireInvite`: Whether registration via this OIDC provider requires an invite. If enabled, users will only be able to create a new account via this OIDC provider if they use an invite link generated by an admin (see `DefaultAdmins`). Boolean. - `RequireInvite`: Whether registration via this OIDC provider requires an invite. If enabled, users will only be able to create a new account via this OIDC provider if they use an invite link generated by an admin (see `DefaultAdmins`). Boolean. Default value: `false`.
- `AllowChoosingPlayerName`: Whether to allow choosing a player name other than the OIDC user's `preferredUsername` during registration. Boolean. - `AllowChoosingPlayerName`: Whether to allow choosing a player name other than the OIDC user's `preferredUsername` during registration. Boolean. Default value: `true`.
- `[RequestCache]`: Settings for the cache used for `FallbackAPIServers`. You probably don't need to change these settings. Modify `[[FallbackAPIServers]].CacheTTLSec` instead if you want to disable caching. See [https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config](https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config). - `[RequestCache]`: Settings for the cache used for `FallbackAPIServers`. You probably don't need to change these settings. Modify `[[FallbackAPIServers]].CacheTTLSec` instead if you want to disable caching. See [https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config](https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config).

View File

@ -36,6 +36,10 @@ func Unwrap[T any](value T, e error) T {
return value return value
} }
func UnwrapError[T any](value T, e error) error {
return e
}
func Ptr[T any](value T) *T { func Ptr[T any](value T) *T {
return &value return &value
} }