From 3ed22110b39ee85b5a07333eec23f86d25504494 Mon Sep 17 00:00:00 2001 From: Evan Goode Date: Sat, 5 Apr 2025 22:51:46 -0400 Subject: [PATCH] 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. --- config.go | 338 ++++++++++++++++++++++++++++--------------- config_test.go | 199 +++++++++++++------------ doc/configuration.md | 14 +- util.go | 4 + 4 files changed, 334 insertions(+), 221 deletions(-) diff --git a/config.go b/config.go index 24e17b5..3ea05fd 100644 --- a/config.go +++ b/config.go @@ -15,6 +15,7 @@ import ( "os" "path" "path/filepath" + "reflect" "strings" ) @@ -28,14 +29,36 @@ type bodyLimitConfig struct { SizeLimitKiB int } +type rawFallbackAPIServerConfig struct { + Nickname *string + SessionURL *string + AccountURL *string + ServicesURL *string + SkinDomains *[]string + CacheTTLSeconds *int + DenyUnknownUsers *bool + EnableAuthentication *bool +} + type FallbackAPIServerConfig struct { - Nickname string - SessionURL string - AccountURL string - ServicesURL string - SkinDomains []string - CacheTTLSeconds int - DenyUnknownUsers bool + Nickname string + SessionURL string + AccountURL string + ServicesURL string + SkinDomains []string + CacheTTLSeconds int + DenyUnknownUsers bool + EnableAuthentication bool +} + +type rawRegistrationOIDCConfig struct { + Name *string + Issuer *string + ClientID *string + ClientSecret *string + PKCE *bool + RequireInvite *bool + AllowChoosingPlayerName *bool } type RegistrationOIDCConfig struct { @@ -92,7 +115,7 @@ type importExistingPlayerConfig struct { RequireSkinVerification bool } -type Config struct { +type BaseConfig struct { AllowCapes bool AllowChangingPlayerName bool AllowMultipleAccessTokens bool @@ -114,14 +137,12 @@ type Config struct { EnableBackgroundEffect bool EnableFooter bool EnableWebFrontEnd bool - FallbackAPIServers []FallbackAPIServerConfig ForwardSkins bool InstanceName string ImportExistingPlayer importExistingPlayerConfig ListenAddress string LogRequests bool MinPasswordLength int - RegistrationOIDC []RegistrationOIDCConfig PreMigrationBackups bool RateLimit rateLimitConfig RegistrationExistingPlayer registrationExistingPlayerConfig @@ -137,6 +158,18 @@ type Config struct { ValidPlayerNameRegex string } +type Config struct { + BaseConfig + FallbackAPIServers []FallbackAPIServerConfig + RegistrationOIDC []RegistrationOIDCConfig +} + +type RawConfig struct { + BaseConfig + FallbackAPIServers []rawFallbackAPIServerConfig + RegistrationOIDC []rawRegistrationOIDCConfig +} + var defaultRateLimitConfig = rateLimitConfig{ Enable: true, RequestsPerSecond: 5, @@ -153,63 +186,118 @@ var DefaultRistrettoConfig = &ristretto.Config{ BufferItems: 64, } +func DefaultRawConfig() RawConfig { + return RawConfig{ + BaseConfig: BaseConfig{ + AllowCapes: true, + AllowChangingPlayerName: true, + AllowPasswordLogin: true, + AllowSkins: true, + AllowTextureFromURL: false, + AllowAddingDeletingPlayers: false, + ApplicationName: "Drasl", + ApplicationOwner: "Anonymous", + BaseURL: "", + BodyLimit: defaultBodyLimitConfig, + CORSAllowOrigins: []string{}, + CreateNewPlayer: createNewPlayerConfig{ + Allow: true, + AllowChoosingUUID: false, + }, + DataDirectory: GetDefaultDataDirectory(), + DefaultAdmins: []string{}, + DefaultPreferredLanguage: "en", + DefaultMaxPlayerCount: 1, + Domain: "", + EnableBackgroundEffect: true, + EnableFooter: true, + EnableWebFrontEnd: true, + ForwardSkins: true, + ImportExistingPlayer: importExistingPlayerConfig{ + Allow: false, + }, + InstanceName: "Drasl", + ListenAddress: "0.0.0.0:25585", + LogRequests: true, + MinPasswordLength: 8, + OfflineSkins: true, + PreMigrationBackups: true, + RateLimit: defaultRateLimitConfig, + RegistrationExistingPlayer: registrationExistingPlayerConfig{ + Allow: false, + }, + RegistrationNewPlayer: registrationNewPlayerConfig{ + Allow: true, + RequireInvite: false, + }, + RequestCache: *DefaultRistrettoConfig, + SignPublicKeys: true, + SkinSizeLimit: 64, + StateDirectory: GetDefaultStateDirectory(), + TokenExpireSec: 0, + TokenStaleSec: 0, + TransientUsers: transientUsersConfig{ + Allow: false, + }, + ValidPlayerNameRegex: "^[a-zA-Z0-9_]+$", + }, + FallbackAPIServers: []rawFallbackAPIServerConfig{}, + RegistrationOIDC: []rawRegistrationOIDCConfig{}, + } +} + func DefaultConfig() Config { return Config{ - AllowCapes: true, - AllowChangingPlayerName: true, - AllowPasswordLogin: true, - AllowSkins: true, - AllowTextureFromURL: false, - AllowAddingDeletingPlayers: false, - ApplicationName: "Drasl", - ApplicationOwner: "Anonymous", - BaseURL: "", - BodyLimit: defaultBodyLimitConfig, - CORSAllowOrigins: []string{}, - CreateNewPlayer: createNewPlayerConfig{ - Allow: true, - AllowChoosingUUID: false, - }, - DataDirectory: GetDefaultDataDirectory(), - DefaultAdmins: []string{}, - DefaultPreferredLanguage: "en", - DefaultMaxPlayerCount: 1, - Domain: "", - EnableBackgroundEffect: true, - EnableFooter: true, - EnableWebFrontEnd: true, - ForwardSkins: true, - ImportExistingPlayer: importExistingPlayerConfig{ - Allow: false, - }, - InstanceName: "Drasl", - ListenAddress: "0.0.0.0:25585", - LogRequests: true, - MinPasswordLength: 8, - RegistrationOIDC: []RegistrationOIDCConfig{}, - OfflineSkins: true, - PreMigrationBackups: true, - RateLimit: defaultRateLimitConfig, - RegistrationExistingPlayer: registrationExistingPlayerConfig{ - Allow: false, - }, - RegistrationNewPlayer: registrationNewPlayerConfig{ - Allow: true, - RequireInvite: false, - }, - RequestCache: *DefaultRistrettoConfig, - SignPublicKeys: true, - SkinSizeLimit: 64, - StateDirectory: GetDefaultStateDirectory(), - TokenExpireSec: 0, - TokenStaleSec: 0, - TransientUsers: transientUsersConfig{ - Allow: false, - }, - ValidPlayerNameRegex: "^[a-zA-Z0-9_]+$", + 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) { if urlString == "" { if example, ok := required.Get(); ok { @@ -250,19 +338,22 @@ func cleanDomain(key string, required mo.Option[string], domain string) (string, return punycoded, nil } -func CleanConfig(config *Config) error { +func CleanConfig(rawConfig *RawConfig) (Config, error) { + config := Config{} + config.BaseConfig = rawConfig.BaseConfig + var err error config.BaseURL, err = cleanURL("BaseURL", mo.Some("https://drasl.example.com"), config.BaseURL, true) if err != nil { - return err + return Config{}, err } if !IsValidPreferredLanguage(config.DefaultPreferredLanguage) { - return fmt.Errorf("Invalid DefaultPreferredLanguage %s", config.DefaultPreferredLanguage) + return Config{}, fmt.Errorf("Invalid DefaultPreferredLanguage %s", config.DefaultPreferredLanguage) } 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( @@ -271,40 +362,40 @@ func CleanConfig(config *Config) error { config.Domain, ) if err != nil { - return err + return Config{}, err } if config.InstanceName == "" { - return errors.New("InstanceName must be set") + return Config{}, errors.New("InstanceName must be set") } 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 { - 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.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.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 == "" { - 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 == "" { - 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 == "" { - 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.Nickname == "" { - return errors.New("ImportExistingPlayer.Nickname must be set") + return Config{}, errors.New("ImportExistingPlayer.Nickname must be set") } config.ImportExistingPlayer.SessionURL, err = cleanURL( @@ -313,7 +404,7 @@ func CleanConfig(config *Config) error { config.ImportExistingPlayer.SessionURL, true, ) if err != nil { - return err + return Config{}, err } config.ImportExistingPlayer.AccountURL, err = cleanURL( @@ -322,7 +413,7 @@ func CleanConfig(config *Config) error { config.ImportExistingPlayer.AccountURL, true, ) if err != nil { - return err + return Config{}, err } config.ImportExistingPlayer.SetSkinURL, err = cleanURL( @@ -331,66 +422,72 @@ func CleanConfig(config *Config) error { config.ImportExistingPlayer.SetSkinURL, true, ) if err != nil { - return err + return Config{}, err } } fallbackAPIServerNames := mapset.NewSet[string]() - for _, fallbackAPIServer := range PtrSlice(config.FallbackAPIServers) { - if fallbackAPIServer.Nickname == "" { - 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) + for _, rawFallbackAPIServer := range PtrSlice(rawConfig.FallbackAPIServers) { + fallbackAPIServerConfig := AssignConfig(DefaultFallbackAPIServer(), *rawFallbackAPIServer) - fallbackAPIServer.SessionURL, err = cleanURL( - fmt.Sprintf("FallbackAPIServer %s SessionURL", fallbackAPIServer.Nickname), + if fallbackAPIServerConfig.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"), - fallbackAPIServer.SessionURL, true, + fallbackAPIServerConfig.SessionURL, true, ) if err != nil { - return err + return Config{}, err } - fallbackAPIServer.AccountURL, err = cleanURL( - fmt.Sprintf("FallbackAPIServer %s AccountURL", fallbackAPIServer.Nickname), + fallbackAPIServerConfig.AccountURL, err = cleanURL( + fmt.Sprintf("FallbackAPIServer %s AccountURL", fallbackAPIServerConfig.Nickname), mo.Some("https://api.mojang.com"), - fallbackAPIServer.AccountURL, true, + fallbackAPIServerConfig.AccountURL, true, ) if err != nil { - return err + return Config{}, err } - fallbackAPIServer.ServicesURL, err = cleanURL( - fmt.Sprintf("FallbackAPIServer %s ServicesURL", fallbackAPIServer.Nickname), + fallbackAPIServerConfig.ServicesURL, err = cleanURL( + fmt.Sprintf("FallbackAPIServer %s ServicesURL", fallbackAPIServerConfig.Nickname), mo.Some("https://api.minecraftservices.com"), - fallbackAPIServer.ServicesURL, true, + fallbackAPIServerConfig.ServicesURL, true, ) if err != nil { - return err + return Config{}, err } - for _, skinDomain := range PtrSlice(fallbackAPIServer.SkinDomains) { + for _, skinDomain := range PtrSlice(fallbackAPIServerConfig.SkinDomains) { *skinDomain, err = cleanDomain( - fmt.Sprintf("FallbackAPIServer %s SkinDomain", fallbackAPIServer.Nickname), + fmt.Sprintf("FallbackAPIServer %s SkinDomain", fallbackAPIServerConfig.Nickname), mo.Some("textures.minecraft.net"), *skinDomain, ) if err != nil { - return err + return Config{}, err } } + + config.FallbackAPIServers = append(config.FallbackAPIServers, fallbackAPIServerConfig) } oidcNames := mapset.NewSet[string]() - for _, oidcConfig := range PtrSlice(config.RegistrationOIDC) { + for _, rawOIDCConfig := range PtrSlice(rawConfig.RegistrationOIDC) { + oidcConfig := AssignConfig(DefaultRegistrationOIDC(), *rawOIDCConfig) + 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) { - return fmt.Errorf("Duplicate RegistrationOIDC Name: %s", oidcConfig.Name) + return Config{}, fmt.Errorf("Duplicate RegistrationOIDC Name: %s", oidcConfig.Name) } oidcNames.Add(oidcConfig.Name) oidcConfig.Issuer, err = cleanURL( @@ -400,10 +497,12 @@ func CleanConfig(config *Config) error { false, ) 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 @@ -422,7 +521,8 @@ Allow = 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) 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")) deprecatedPaths = append(deprecatedPaths, path_) if !metadata.IsDefined("CreateNewPlayer", "AllowChoosingUUID") { - config.CreateNewPlayer.AllowChoosingUUID = config.RegistrationNewPlayer.AllowChoosingUUID + rawConfig.CreateNewPlayer.AllowChoosingUUID = rawConfig.RegistrationNewPlayer.AllowChoosingUUID } } 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")) deprecatedPaths = append(deprecatedPaths, path_) if !metadata.IsDefined("ImportExistingPlayer", "Nickname") { - config.ImportExistingPlayer.Nickname = config.RegistrationExistingPlayer.Nickname + rawConfig.ImportExistingPlayer.Nickname = rawConfig.RegistrationExistingPlayer.Nickname } } 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")) deprecatedPaths = append(deprecatedPaths, path_) if !metadata.IsDefined("ImportExistingPlayer", "SessionURL") { - config.ImportExistingPlayer.SessionURL = config.RegistrationExistingPlayer.SessionURL + rawConfig.ImportExistingPlayer.SessionURL = rawConfig.RegistrationExistingPlayer.SessionURL } } 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")) deprecatedPaths = append(deprecatedPaths, path_) if !metadata.IsDefined("ImportExistingPlayer", "AccountURL") { - config.ImportExistingPlayer.AccountURL = config.RegistrationExistingPlayer.AccountURL + rawConfig.ImportExistingPlayer.AccountURL = rawConfig.RegistrationExistingPlayer.AccountURL } } 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")) deprecatedPaths = append(deprecatedPaths, path_) if !metadata.IsDefined("ImportExistingPlayer", "SetSkinURL") { - config.ImportExistingPlayer.SetSkinURL = config.RegistrationExistingPlayer.SetSkinURL + rawConfig.ImportExistingPlayer.SetSkinURL = rawConfig.RegistrationExistingPlayer.SetSkinURL } } 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")) deprecatedPaths = append(deprecatedPaths, path_) 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) { - config := DefaultConfig() + rawConfig := DefaultRawConfig() _, err := os.Stat(path) if err != nil { @@ -501,15 +601,15 @@ func ReadConfig(path string, createIfNotExists bool) (Config, [][]string, error) } LogInfo("Loading config from", path) - metadata, err := toml.DecodeFile(path, &config) + metadata, err := toml.DecodeFile(path, &rawConfig) Check(err) for _, key := range metadata.Undecoded() { LogInfo("Warning: unknown config option", strings.Join(key, ".")) } - deprecations := HandleDeprecations(config, &metadata) - err = CleanConfig(&config) + rawConfig, deprecations := HandleDeprecations(&rawConfig, &metadata) + config, err := CleanConfig(&rawConfig) if err != nil { return Config{}, nil, err } diff --git a/config_test.go b/config_test.go index 0754557..32c068f 100644 --- a/config_test.go +++ b/config_test.go @@ -7,11 +7,13 @@ import ( "testing" ) -func configTestConfig(stateDirectory string) *Config { - config := testConfig() - config.StateDirectory = stateDirectory - config.DataDirectory = "." - return config +func configTestRawConfig(stateDirectory string) RawConfig { + rawConfig := RawConfig{ + BaseConfig: testConfig().BaseConfig, + } + rawConfig.StateDirectory = stateDirectory + rawConfig.DataDirectory = "." + return rawConfig } func TestConfig(t *testing.T) { @@ -19,136 +21,138 @@ func TestConfig(t *testing.T) { sd := Unwrap(os.MkdirTemp("", "tmp")) defer os.RemoveAll(sd) - config := configTestConfig(sd) - assert.Nil(t, CleanConfig(config)) + rawConfig := configTestRawConfig(sd) + assert.Nil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - config.BaseURL = "https://δρασλ.example.com/" - config.Domain = "δρασλ.example.com" - assert.Nil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.BaseURL = "https://δρασλ.example.com/" + rawConfig.Domain = "δρασλ.example.com" + config, err := CleanConfig(&rawConfig) + assert.Nil(t, err) assert.Equal(t, "https://xn--mxafwwl.example.com", config.BaseURL) assert.Equal(t, "xn--mxafwwl.example.com", config.Domain) - config = configTestConfig(sd) - config.BaseURL = "" - assert.NotNil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.BaseURL = "" + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - config.BaseURL = ":an invalid URL" - assert.NotNil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.BaseURL = ":an invalid URL" + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - config.DefaultPreferredLanguage = "xx" - assert.NotNil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.DefaultPreferredLanguage = "xx" + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - config.Domain = "" - assert.NotNil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.Domain = "" + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - config.InstanceName = "" - assert.NotNil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.InstanceName = "" + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - config.ListenAddress = "" - assert.NotNil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.ListenAddress = "" + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - config.DefaultMaxPlayerCount = Constants.MaxPlayerCountUseDefault - assert.NotNil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.DefaultMaxPlayerCount = Constants.MaxPlayerCountUseDefault + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - config.DefaultMaxPlayerCount = Constants.MaxPlayerCountUnlimited - assert.Nil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.DefaultMaxPlayerCount = Constants.MaxPlayerCountUnlimited + assert.Nil(t, UnwrapError(CleanConfig(&rawConfig))) // Missing state directory should be ignored - config = configTestConfig(sd) - config.StateDirectory = "/tmp/DraslInvalidStateDirectoryNothingHere" - assert.Nil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.StateDirectory = "/tmp/DraslInvalidStateDirectoryNothingHere" + assert.Nil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - config.RegistrationExistingPlayer.Allow = true - config.ImportExistingPlayer.Allow = true - config.ImportExistingPlayer.Nickname = "Example" - config.ImportExistingPlayer.SessionURL = "https://δρασλ.example.com/" - config.ImportExistingPlayer.AccountURL = "https://drasl.example.com/" - assert.Nil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.RegistrationExistingPlayer.Allow = true + rawConfig.ImportExistingPlayer.Allow = true + rawConfig.ImportExistingPlayer.Nickname = "Example" + rawConfig.ImportExistingPlayer.SessionURL = "https://δρασλ.example.com/" + rawConfig.ImportExistingPlayer.AccountURL = "https://drasl.example.com/" + config, err = CleanConfig(&rawConfig) + assert.Nil(t, err) assert.Equal(t, "https://xn--mxafwwl.example.com", config.ImportExistingPlayer.SessionURL) assert.Equal(t, "https://drasl.example.com", config.ImportExistingPlayer.AccountURL) - config = configTestConfig(sd) - config.RegistrationExistingPlayer.Allow = true - config.ImportExistingPlayer.Nickname = "" - assert.NotNil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.RegistrationExistingPlayer.Allow = true + rawConfig.ImportExistingPlayer.Nickname = "" + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - config.RegistrationExistingPlayer.Allow = true - config.ImportExistingPlayer.SessionURL = "" - assert.NotNil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.RegistrationExistingPlayer.Allow = true + rawConfig.ImportExistingPlayer.SessionURL = "" + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - config.RegistrationExistingPlayer.Allow = true - config.ImportExistingPlayer.AccountURL = "" - assert.NotNil(t, CleanConfig(config)) + rawConfig = configTestRawConfig(sd) + rawConfig.RegistrationExistingPlayer.Allow = true + rawConfig.ImportExistingPlayer.AccountURL = "" + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) - config = configTestConfig(sd) - testFallbackAPIServer := FallbackAPIServerConfig{ - Nickname: "Nickname", - SessionURL: "https://δρασλ.example.com/", - AccountURL: "https://δρασλ.example.com/", - ServicesURL: "https://δρασλ.example.com/", - SkinDomains: []string{"δρασλ.example.com"}, + rawConfig = configTestRawConfig(sd) + testFallbackAPIServer := rawFallbackAPIServerConfig{ + Nickname: Ptr("Nickname"), + SessionURL: Ptr("https://δρασλ.example.com/"), + AccountURL: Ptr("https://δρασλ.example.com/"), + ServicesURL: Ptr("https://δρασλ.example.com/"), + SkinDomains: Ptr([]string{"δρασλ.example.com"}), } fb := testFallbackAPIServer - config.FallbackAPIServers = []FallbackAPIServerConfig{fb} - assert.Nil(t, CleanConfig(config)) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} + config, err = CleanConfig(&rawConfig) + assert.Nil(t, err) - assert.Equal(t, []FallbackAPIServerConfig{{ - Nickname: fb.Nickname, - SessionURL: "https://xn--mxafwwl.example.com", - AccountURL: "https://xn--mxafwwl.example.com", - ServicesURL: "https://xn--mxafwwl.example.com", - SkinDomains: []string{"xn--mxafwwl.example.com"}, - }}, config.FallbackAPIServers) + assert.Equal(t, 1, len(config.FallbackAPIServers)) + assert.Equal(t, *fb.Nickname, config.FallbackAPIServers[0].Nickname) + assert.Equal(t, "https://xn--mxafwwl.example.com", config.FallbackAPIServers[0].SessionURL) + assert.Equal(t, "https://xn--mxafwwl.example.com", config.FallbackAPIServers[0].AccountURL) + assert.Equal(t, "https://xn--mxafwwl.example.com", config.FallbackAPIServers[0].ServicesURL) + assert.Equal(t, []string{"xn--mxafwwl.example.com"}, config.FallbackAPIServers[0].SkinDomains) fb = testFallbackAPIServer - fb.Nickname = "" - config.FallbackAPIServers = []FallbackAPIServerConfig{fb} - assert.NotNil(t, CleanConfig(config)) + fb.Nickname = Ptr("") + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) fb = testFallbackAPIServer - fb.SessionURL = "" - config.FallbackAPIServers = []FallbackAPIServerConfig{fb} - assert.NotNil(t, CleanConfig(config)) + fb.SessionURL = Ptr("") + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) fb = testFallbackAPIServer - fb.SessionURL = ":invalid URL" - config.FallbackAPIServers = []FallbackAPIServerConfig{fb} - assert.NotNil(t, CleanConfig(config)) + fb.SessionURL = Ptr(":invalid URL") + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) fb = testFallbackAPIServer - fb.AccountURL = "" - config.FallbackAPIServers = []FallbackAPIServerConfig{fb} - assert.NotNil(t, CleanConfig(config)) + fb.AccountURL = Ptr("") + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) fb = testFallbackAPIServer - fb.AccountURL = ":invalid URL" - config.FallbackAPIServers = []FallbackAPIServerConfig{fb} - assert.NotNil(t, CleanConfig(config)) + fb.AccountURL = Ptr(":invalid URL") + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) fb = testFallbackAPIServer - fb.ServicesURL = "" - config.FallbackAPIServers = []FallbackAPIServerConfig{fb} - assert.NotNil(t, CleanConfig(config)) + fb.ServicesURL = Ptr("") + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) fb = testFallbackAPIServer - fb.ServicesURL = ":invalid URL" - config.FallbackAPIServers = []FallbackAPIServerConfig{fb} - assert.NotNil(t, CleanConfig(config)) + fb.ServicesURL = Ptr(":invalid URL") + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} + assert.NotNil(t, UnwrapError(CleanConfig(&rawConfig))) // Test that TEMPLATE_CONFIG_FILE is valid var templateConfig Config - _, err := toml.Decode(TEMPLATE_CONFIG_FILE, &templateConfig) + _, err = toml.Decode(TEMPLATE_CONFIG_FILE, &templateConfig) assert.Nil(t, err) // 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") assert.Nil(t, err) assert.Equal(t, correctBytes, configBytes) + + // Test AssignConfig + defaults := DefaultFallbackAPIServer() + assigned := AssignConfig(defaults, rawFallbackAPIServerConfig{}) + assert.Equal(t, defaults, assigned) } diff --git a/doc/configuration.md b/doc/configuration.md index 0fde89b..7b59aaf 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -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. - 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"`. - `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"`. - - `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: ``` @@ -50,11 +50,11 @@ Other available options: 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`. - - `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`. @@ -89,9 +89,9 @@ Other available options: - `Issuer`: OIDC issuer URL. String. Example value: `"https://idm.example.com/oauth2/openid/drasl"`. - `ClientID`: OIDC client ID. String. Example value: `"drasl"`. - `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`. - - `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. - - `AllowChoosingPlayerName`: Whether to allow choosing a player name other than the OIDC user's `preferredUsername` during registration. Boolean. + - `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. Default value: `false`. + - `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). diff --git a/util.go b/util.go index a9d5f6c..8452ed8 100644 --- a/util.go +++ b/util.go @@ -36,6 +36,10 @@ func Unwrap[T any](value T, e error) T { return value } +func UnwrapError[T any](value T, e error) error { + return e +} + func Ptr[T any](value T) *T { return &value }