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" "strings" ) type rateLimitConfig struct { Enable bool RequestsPerSecond float64 } type bodyLimitConfig struct { Enable bool SizeLimitKiB int } type FallbackAPIServer struct { Nickname string SessionURL string AccountURL string ServicesURL string SkinDomains []string CacheTTLSeconds int DenyUnknownUsers 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 Config 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 FallbackAPIServers []FallbackAPIServer ForwardSkins bool InstanceName string ImportExistingPlayer importExistingPlayerConfig ListenAddress string LogRequests bool MinPasswordLength int RegistrationOIDC []RegistrationOIDCConfig 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 } var defaultRateLimitConfig = rateLimitConfig{ Enable: true, RequestsPerSecond: 5, } var defaultBodyLimitConfig = bodyLimitConfig{ Enable: true, SizeLimitKiB: 8192, } 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: ristretto.Config{ // Defaults from https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config NumCounters: 1e7, MaxCost: 1 << 30, // 1 GiB BufferItems: 64, }, SignPublicKeys: true, SkinSizeLimit: 128, StateDirectory: GetDefaultStateDirectory(), TokenExpireSec: 0, TokenStaleSec: 0, TransientUsers: transientUsersConfig{ Allow: false, }, ValidPlayerNameRegex: "^[a-zA-Z0-9_]+$", } } 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(config *Config) error { var err error config.BaseURL, err = cleanURL("BaseURL", mo.Some("https://drasl.example.com"), config.BaseURL, true) if err != nil { return err } if !IsValidPreferredLanguage(config.DefaultPreferredLanguage) { return fmt.Errorf("Invalid DefaultPreferredLanguage %s", config.DefaultPreferredLanguage) } if config.Domain == "" { return 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 err } if config.InstanceName == "" { return errors.New("InstanceName must be set") } if config.ListenAddress == "" { return 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) } if config.RegistrationNewPlayer.Allow { if !config.CreateNewPlayer.Allow { return 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.") } if config.ImportExistingPlayer.Nickname == "" { return 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") } if config.ImportExistingPlayer.AccountURL == "" { return 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") } config.ImportExistingPlayer.SessionURL, err = cleanURL( "ImportExistingPlayer.SessionURL", mo.Some("https://sessionserver.mojang.com"), config.ImportExistingPlayer.SessionURL, true, ) if err != nil { return err } config.ImportExistingPlayer.AccountURL, err = cleanURL( "ImportExistingPlayer.AccountURL", mo.Some("https://api.mojang.com"), config.ImportExistingPlayer.AccountURL, true, ) if err != nil { return err } config.ImportExistingPlayer.SetSkinURL, err = cleanURL( "ImportExistingPlayer.SetSkinURL", mo.None[string](), config.ImportExistingPlayer.SetSkinURL, true, ) if err != nil { return 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) fallbackAPIServer.SessionURL, err = cleanURL( fmt.Sprintf("FallbackAPIServer %s SessionURL", fallbackAPIServer.Nickname), mo.Some("https://sessionserver.mojang.com"), fallbackAPIServer.SessionURL, true, ) if err != nil { return err } fallbackAPIServer.AccountURL, err = cleanURL( fmt.Sprintf("FallbackAPIServer %s AccountURL", fallbackAPIServer.Nickname), mo.Some("https://api.mojang.com"), fallbackAPIServer.AccountURL, true, ) if err != nil { return err } fallbackAPIServer.ServicesURL, err = cleanURL( fmt.Sprintf("FallbackAPIServer %s ServicesURL", fallbackAPIServer.Nickname), mo.Some("https://api.minecraftservices.com"), fallbackAPIServer.ServicesURL, true, ) if err != nil { return err } for _, skinDomain := range PtrSlice(fallbackAPIServer.SkinDomains) { *skinDomain, err = cleanDomain( fmt.Sprintf("FallbackAPIServer %s SkinDomain", fallbackAPIServer.Nickname), mo.Some("textures.minecraft.net"), *skinDomain, ) if err != nil { return err } } } oidcNames := mapset.NewSet[string]() for _, oidcConfig := range PtrSlice(config.RegistrationOIDC) { if oidcConfig.Name == "" { return errors.New("RegistrationOIDC Name must be set") } if oidcNames.Contains(oidcConfig.Name) { return 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 err } } return 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(config Config, metadata *toml.MetaData) [][]string { 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") { config.CreateNewPlayer.AllowChoosingUUID = config.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") { config.ImportExistingPlayer.Nickname = config.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") { config.ImportExistingPlayer.SessionURL = config.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") { config.ImportExistingPlayer.AccountURL = config.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") { config.ImportExistingPlayer.SetSkinURL = config.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") { config.ImportExistingPlayer.RequireSkinVerification = config.RegistrationExistingPlayer.RequireSkinVerification } } return deprecatedPaths } func ReadConfig(path string, createIfNotExists bool) (Config, [][]string, error) { config := DefaultConfig() _, 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, &config) Check(err) for _, key := range metadata.Undecoded() { LogInfo("Warning: unknown config option", strings.Join(key, ".")) } deprecations := HandleDeprecations(config, &metadata) err = CleanConfig(&config) 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 } }