package common import ( "bytes" "encoding/json" "fmt" "net" "net/url" "os" "path/filepath" "strconv" "strings" ) // RuntimeConfig is shared by both server processes. Deployment values belong // in configuration rather than in handlers or executable entry points. type RuntimeConfig struct { ActivitySchedule ActivityScheduleConfig `json:"activity_schedule"` AccountName string `json:"account_name"` Srtool SrtoolConfig `json:"srtool"` PlayerUID uint32 `json:"player_uid"` BattleDiagnostics BattleDiagnosticsConfig `json:"battle_diagnostics"` ClientVersion string `json:"client_version"` GameHost string `json:"game_host"` GamePort uint16 `json:"game_port"` SDKHost string `json:"sdk_host"` SDKPort uint16 `json:"sdk_port"` PublicGameHost string `json:"public_game_host"` PublicSDKURL string `json:"public_sdk_url"` } // The schedule interval is an explicit operator policy shared by SDK, gateway, // and launcher. A missing interval must never silently advertise expired CW. type ActivityScheduleConfig struct { BeginTime *int64 `json:"begin_time"` EndTime *int64 `json:"end_time"` } // The obfuscated protobuf class name is version-specific and comes from the // client's stack trace. Empty disables the read-only client wire probe. type BattleDiagnosticsConfig struct { ClientBattleType string `json:"client_battle_type"` LiveReportStaleSeconds uint16 `json:"live_report_stale_seconds"` } func LoadRuntimeConfig() (RuntimeConfig, error) { path := os.Getenv("GAC_CONFIG") if path == "" { path = "server.json" } var cfg RuntimeConfig for i, filename := range []string{path, filepath.Join(filepath.Dir(path), strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))+".local.json")} { raw, err := os.ReadFile(filename) if i == 1 && os.IsNotExist(err) { continue } if err != nil { return cfg, fmt.Errorf("read runtime config %s: %w", filename, err) } if err := json.Unmarshal(bytes.TrimPrefix(raw, []byte{0xef, 0xbb, 0xbf}), &cfg); err != nil { return cfg, fmt.Errorf("parse runtime config %s: %w", filename, err) } } for name, dest := range map[string]*string{ "GAC_ACCOUNT_NAME": &cfg.AccountName, "GAC_CLIENT_VERSION": &cfg.ClientVersion, "GAC_GAME_HOST": &cfg.GameHost, "GAC_SDK_HOST": &cfg.SDKHost, "GAC_PUBLIC_GAME_HOST": &cfg.PublicGameHost, "GAC_PUBLIC_SDK_URL": &cfg.PublicSDKURL, } { if value, ok := os.LookupEnv(name); ok { *dest = value } } for name, dest := range map[string]*uint16{"GAC_GAME_PORT": &cfg.GamePort, "GAC_SDK_PORT": &cfg.SDKPort} { if value, ok := os.LookupEnv(name); ok { n, err := strconv.ParseUint(value, 10, 16) if err != nil || n == 0 { return cfg, fmt.Errorf("%s must be a port between 1 and 65535", name) } *dest = uint16(n) } } if value, ok := os.LookupEnv("GAC_LIVE_BATTLE_STALE_SECONDS"); ok { n, err := strconv.ParseUint(value, 10, 16) if err != nil || n == 0 { return cfg, fmt.Errorf("GAC_LIVE_BATTLE_STALE_SECONDS must be an integer between 1 and 65535") } cfg.BattleDiagnostics.LiveReportStaleSeconds = uint16(n) } if value, ok := os.LookupEnv("GAC_PLAYER_UID"); ok { n, err := strconv.ParseUint(value, 10, 32) if err != nil || n == 0 { return cfg, fmt.Errorf("GAC_PLAYER_UID must be an integer between 1 and 4294967295") } cfg.PlayerUID = uint32(n) } for name, dest := range map[string]**int64{ "GAC_ACTIVITY_BEGIN_TIME": &cfg.ActivitySchedule.BeginTime, "GAC_ACTIVITY_END_TIME": &cfg.ActivitySchedule.EndTime, } { if value, ok := os.LookupEnv(name); ok { n, err := strconv.ParseInt(value, 10, 64) if err != nil { return cfg, fmt.Errorf("%s must be an int64 Unix timestamp", name) } *dest = &n } } if cfg.ActivitySchedule.BeginTime == nil || cfg.ActivitySchedule.EndTime == nil || *cfg.ActivitySchedule.BeginTime < 0 || *cfg.ActivitySchedule.EndTime <= *cfg.ActivitySchedule.BeginTime { return cfg, fmt.Errorf("activity_schedule requires nonnegative begin_time and end_time greater than begin_time") } if strings.TrimSpace(cfg.AccountName) == "" { return cfg, fmt.Errorf("runtime config requires account_name") } if strings.TrimSpace(cfg.Srtool.LeaseFile) == "" || cfg.Srtool.LeaseSeconds == 0 { return cfg, fmt.Errorf("runtime config requires srtool.lease_file and nonzero srtool.lease_seconds") } if cfg.PlayerUID == 0 { return cfg, fmt.Errorf("runtime config requires a nonzero player_uid") } if cfg.GamePort == 0 || cfg.SDKPort == 0 || strings.TrimSpace(cfg.ClientVersion) == "" { return cfg, fmt.Errorf("runtime config requires client_version, game_port and sdk_port") } if cfg.BattleDiagnostics.LiveReportStaleSeconds == 0 { return cfg, fmt.Errorf("runtime config requires battle_diagnostics.live_report_stale_seconds") } for name, host := range map[string]string{"game_host": cfg.GameHost, "sdk_host": cfg.SDKHost, "public_game_host": cfg.PublicGameHost} { if strings.TrimSpace(host) == "" || strings.ContainsAny(host, "/\\ \t\r\n") { return cfg, fmt.Errorf("invalid %s", name) } } u, err := url.Parse(cfg.PublicSDKURL) if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.RawQuery != "" || u.Fragment != "" || u.User != nil { return cfg, fmt.Errorf("public_sdk_url must be an HTTP(S) base URL") } cfg.PublicSDKURL = strings.TrimRight(cfg.PublicSDKURL, "/") return cfg, nil } func (c RuntimeConfig) SDKAddress() string { return net.JoinHostPort(c.SDKHost, strconv.Itoa(int(c.SDKPort))) }