drasl/config.go
Evan Goode 4b1d9d420b 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.
2025-04-06 23:48:36 +00:00

638 lines
18 KiB
Go

package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"errors"
"fmt"
"github.com/BurntSushi/toml"
mapset "github.com/deckarep/golang-set/v2"
"github.com/dgraph-io/ristretto"
"github.com/samber/mo"
"golang.org/x/net/idna"
"net/url"
"os"
"path"
"path/filepath"
"reflect"
"strings"
)
type rateLimitConfig struct {
Enable bool
RequestsPerSecond float64
}
type bodyLimitConfig struct {
Enable bool
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
EnableAuthentication bool
}
type rawRegistrationOIDCConfig struct {
Name *string
Issuer *string
ClientID *string
ClientSecret *string
PKCE *bool
RequireInvite *bool
AllowChoosingPlayerName *bool
}
type RegistrationOIDCConfig struct {
Name string
Issuer string
ClientID string
ClientSecret string
PKCE bool
RequireInvite bool
AllowChoosingPlayerName bool
}
type transientUsersConfig struct {
Allow bool
UsernameRegex string
Password string
}
type v2RegistrationNewPlayerConfig struct {
AllowChoosingUUID bool
}
type registrationNewPlayerConfig struct {
v2RegistrationNewPlayerConfig
Allow bool
RequireInvite bool
}
type v2RegistrationExistingPlayerConfig struct {
Nickname string
SessionURL string
AccountURL string
SetSkinURL string
RequireSkinVerification bool
}
type registrationExistingPlayerConfig struct {
v2RegistrationExistingPlayerConfig
Allow bool
RequireInvite bool
}
type createNewPlayerConfig struct {
Allow bool
AllowChoosingUUID bool
}
type importExistingPlayerConfig struct {
Allow bool
Nickname string
SessionURL string
AccountURL string
SetSkinURL string
RequireSkinVerification bool
}
type BaseConfig struct {
AllowCapes bool
AllowChangingPlayerName bool
AllowMultipleAccessTokens bool
AllowPasswordLogin bool
AllowSkins bool
AllowTextureFromURL bool
AllowAddingDeletingPlayers bool
ApplicationOwner string
ApplicationName string
BaseURL string
BodyLimit bodyLimitConfig
CORSAllowOrigins []string
CreateNewPlayer createNewPlayerConfig
DataDirectory string
DefaultAdmins []string
DefaultPreferredLanguage string
DefaultMaxPlayerCount int
Domain string
EnableBackgroundEffect bool
EnableFooter bool
EnableWebFrontEnd bool
ForwardSkins bool
InstanceName string
ImportExistingPlayer importExistingPlayerConfig
ListenAddress string
LogRequests bool
MinPasswordLength int
PreMigrationBackups bool
RateLimit rateLimitConfig
RegistrationExistingPlayer registrationExistingPlayerConfig
RegistrationNewPlayer registrationNewPlayerConfig
RequestCache ristretto.Config
SignPublicKeys bool
SkinSizeLimit int
OfflineSkins bool
StateDirectory string
TokenExpireSec int
TokenStaleSec int
TransientUsers transientUsersConfig
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,
}
var defaultBodyLimitConfig = bodyLimitConfig{
Enable: true,
SizeLimitKiB: 8192,
}
var DefaultRistrettoConfig = &ristretto.Config{
// Defaults from https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config
NumCounters: 1e7,
MaxCost: 1 << 30, // 1 GiB
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{
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 {
return "", fmt.Errorf("%s must be set. Example: %s", key, example)
}
return urlString, nil
}
parsedURL, err := url.Parse(urlString)
if err != nil {
return "", fmt.Errorf("Invalid %s: %s", key, err)
}
punycodeHost, err := idna.ToASCII(parsedURL.Host)
if err != nil {
return "", fmt.Errorf("Invalid %s: %s", key, err)
}
parsedURL.Host = punycodeHost
if trimTrailingSlash {
parsedURL.Path = strings.TrimSuffix(parsedURL.Path, "/")
}
return parsedURL.String(), nil
}
func cleanDomain(key string, required mo.Option[string], domain string) (string, error) {
if domain == "" {
if example, ok := required.Get(); ok {
return "", fmt.Errorf("%s must be set. Example: %s", key, example)
}
return domain, nil
}
punycoded, err := idna.ToASCII(domain)
if err != nil {
return "", fmt.Errorf("Invalid %s: %s", key, err)
}
return punycoded, nil
}
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 Config{}, err
}
if !IsValidPreferredLanguage(config.DefaultPreferredLanguage) {
return Config{}, fmt.Errorf("Invalid DefaultPreferredLanguage %s", config.DefaultPreferredLanguage)
}
if config.Domain == "" {
return Config{}, errors.New("Domain must be set to a valid fully qualified domain name")
}
config.Domain, err = cleanDomain(
"Domain",
mo.Some("drasl.example.com"),
config.Domain,
)
if err != nil {
return Config{}, err
}
if config.InstanceName == "" {
return Config{}, errors.New("InstanceName must be set")
}
if config.ListenAddress == "" {
return Config{}, errors.New("ListenAddress must be set. Example: 0.0.0.0:25585")
}
if config.DefaultMaxPlayerCount < 0 && config.DefaultMaxPlayerCount != 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 Config{}, errors.New("If RegisterNewPlayer is allowed, CreateNewPlayer must be allowed.")
}
}
if config.RegistrationExistingPlayer.Allow {
if !config.ImportExistingPlayer.Allow {
return Config{}, errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer must be allowed.")
}
if config.ImportExistingPlayer.Nickname == "" {
return Config{}, errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.Nickname must be set")
}
if config.ImportExistingPlayer.SessionURL == "" {
return Config{}, errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.SessionURL must be set. Example: https://sessionserver.mojang.com")
}
if config.ImportExistingPlayer.AccountURL == "" {
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 Config{}, errors.New("ImportExistingPlayer.Nickname must be set")
}
config.ImportExistingPlayer.SessionURL, err = cleanURL(
"ImportExistingPlayer.SessionURL",
mo.Some("https://sessionserver.mojang.com"),
config.ImportExistingPlayer.SessionURL, true,
)
if err != nil {
return Config{}, err
}
config.ImportExistingPlayer.AccountURL, err = cleanURL(
"ImportExistingPlayer.AccountURL",
mo.Some("https://api.mojang.com"),
config.ImportExistingPlayer.AccountURL, true,
)
if err != nil {
return Config{}, err
}
config.ImportExistingPlayer.SetSkinURL, err = cleanURL(
"ImportExistingPlayer.SetSkinURL",
mo.None[string](),
config.ImportExistingPlayer.SetSkinURL, true,
)
if err != nil {
return Config{}, err
}
}
fallbackAPIServerNames := mapset.NewSet[string]()
for _, rawFallbackAPIServer := range PtrSlice(rawConfig.FallbackAPIServers) {
fallbackAPIServerConfig := AssignConfig(DefaultFallbackAPIServer(), *rawFallbackAPIServer)
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"),
fallbackAPIServerConfig.SessionURL, true,
)
if err != nil {
return Config{}, err
}
fallbackAPIServerConfig.AccountURL, err = cleanURL(
fmt.Sprintf("FallbackAPIServer %s AccountURL", fallbackAPIServerConfig.Nickname),
mo.Some("https://api.mojang.com"),
fallbackAPIServerConfig.AccountURL, true,
)
if err != nil {
return Config{}, err
}
fallbackAPIServerConfig.ServicesURL, err = cleanURL(
fmt.Sprintf("FallbackAPIServer %s ServicesURL", fallbackAPIServerConfig.Nickname),
mo.Some("https://api.minecraftservices.com"),
fallbackAPIServerConfig.ServicesURL, true,
)
if err != nil {
return Config{}, err
}
for _, skinDomain := range PtrSlice(fallbackAPIServerConfig.SkinDomains) {
*skinDomain, err = cleanDomain(
fmt.Sprintf("FallbackAPIServer %s SkinDomain", fallbackAPIServerConfig.Nickname),
mo.Some("textures.minecraft.net"),
*skinDomain,
)
if err != nil {
return Config{}, err
}
}
config.FallbackAPIServers = append(config.FallbackAPIServers, fallbackAPIServerConfig)
}
oidcNames := mapset.NewSet[string]()
for _, rawOIDCConfig := range PtrSlice(rawConfig.RegistrationOIDC) {
oidcConfig := AssignConfig(DefaultRegistrationOIDC(), *rawOIDCConfig)
if oidcConfig.Name == "" {
return Config{}, errors.New("RegistrationOIDC Name must be set")
}
if oidcNames.Contains(oidcConfig.Name) {
return Config{}, fmt.Errorf("Duplicate RegistrationOIDC Name: %s", oidcConfig.Name)
}
oidcNames.Add(oidcConfig.Name)
oidcConfig.Issuer, err = cleanURL(
fmt.Sprintf("RegistrationOIDC %s Issuer", oidcConfig.Name),
mo.Some("https://idm.example.com/oauth2/openid/drasl"),
oidcConfig.Issuer,
false,
)
if err != nil {
return Config{}, err
}
config.RegistrationOIDC = append(config.RegistrationOIDC, oidcConfig)
}
return config, nil
}
const TEMPLATE_CONFIG_FILE = `# Drasl default config file
# Example: drasl.example.com
Domain = ""
# Example: https://drasl.example.com
BaseURL = ""
# List of usernames who automatically become admins of the Drasl instance
DefaultAdmins = [""]
[RegistrationNewPlayer]
Allow = true
RequireInvite = true
`
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."
path_ := []string{"RegistrationNewPlayer", "AllowChoosingUUID"}
if metadata.IsDefined(path_...) {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "CreateNewPlayer.AllowChoosingUUID"))
deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("CreateNewPlayer", "AllowChoosingUUID") {
rawConfig.CreateNewPlayer.AllowChoosingUUID = rawConfig.RegistrationNewPlayer.AllowChoosingUUID
}
}
path_ = []string{"RegistrationExistingPlayer", "Nickname"}
if metadata.IsDefined(path_...) {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.Nickname"))
deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("ImportExistingPlayer", "Nickname") {
rawConfig.ImportExistingPlayer.Nickname = rawConfig.RegistrationExistingPlayer.Nickname
}
}
path_ = []string{"RegistrationExistingPlayer", "SessionURL"}
if metadata.IsDefined(path_...) {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.SessionURL"))
deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("ImportExistingPlayer", "SessionURL") {
rawConfig.ImportExistingPlayer.SessionURL = rawConfig.RegistrationExistingPlayer.SessionURL
}
}
path_ = []string{"RegistrationExistingPlayer", "AccountURL"}
if metadata.IsDefined(path_...) {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.AccountURL"))
deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("ImportExistingPlayer", "AccountURL") {
rawConfig.ImportExistingPlayer.AccountURL = rawConfig.RegistrationExistingPlayer.AccountURL
}
}
path_ = []string{"RegistrationExistingPlayer", "SetSkinURL"}
if metadata.IsDefined(path_...) {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.SetSkinURL"))
deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("ImportExistingPlayer", "SetSkinURL") {
rawConfig.ImportExistingPlayer.SetSkinURL = rawConfig.RegistrationExistingPlayer.SetSkinURL
}
}
path_ = []string{"RegistrationExistingPlayer", "RequireSkinVerification"}
if metadata.IsDefined(path_...) {
LogInfo(fmt.Sprintf(warningTemplate, strings.Join(path_, "."), "ImportExistingPlayer.RequireSkinVerification"))
deprecatedPaths = append(deprecatedPaths, path_)
if !metadata.IsDefined("ImportExistingPlayer", "RequireSkinVerification") {
rawConfig.ImportExistingPlayer.RequireSkinVerification = rawConfig.RegistrationExistingPlayer.RequireSkinVerification
}
}
return rawConfig, deprecatedPaths
}
func ReadConfig(path string, createIfNotExists bool) (Config, [][]string, error) {
rawConfig := DefaultRawConfig()
_, err := os.Stat(path)
if err != nil {
if !createIfNotExists {
return Config{}, nil, err
}
LogInfo("Config file at", path, "doesn't exist, creating it with template values.")
dir := filepath.Dir(path)
err := os.MkdirAll(dir, 0755)
Check(err)
f := Unwrap(os.Create(path))
defer f.Close()
_, err = f.Write([]byte(TEMPLATE_CONFIG_FILE))
Check(err)
}
LogInfo("Loading config from", path)
metadata, err := toml.DecodeFile(path, &rawConfig)
Check(err)
for _, key := range metadata.Undecoded() {
LogInfo("Warning: unknown config option", strings.Join(key, "."))
}
rawConfig, deprecations := HandleDeprecations(&rawConfig, &metadata)
config, err := CleanConfig(&rawConfig)
if err != nil {
return Config{}, nil, err
}
return config, deprecations, nil
}
func ReadOrCreateKey(config *Config) *rsa.PrivateKey {
path := path.Join(config.StateDirectory, "key.pkcs8")
der, err := os.ReadFile(path)
if err == nil {
key := Unwrap(x509.ParsePKCS8PrivateKey(der))
return key.(*rsa.PrivateKey)
} else {
key := Unwrap(rsa.GenerateKey(rand.Reader, 4096))
der := Unwrap(x509.MarshalPKCS8PrivateKey(key))
err = os.WriteFile(path, der, 0600)
Check(err)
return key
}
}