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 4b1d9d420b
4 changed files with 334 additions and 221 deletions

338
config.go
View File

@ -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
}

View File

@ -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)
}

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.
- 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`.
<!-- - `[TransientLogin]`: Allow certain usernames to authenticate with a shared password, without registering. Useful for supporting bot accounts. -->
<!-- - `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"`.
- `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).

View File

@ -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
}