Files

206 lines
8.1 KiB
Go

package common
import (
"bytes"
"crypto/md5"
"encoding/binary"
"encoding/hex"
"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"`
LuaOverlay LuaOverlayConfig `json:"lua_overlay"`
GameClock GameClockConfig `json:"game_clock"`
}
type GameClockConfig struct {
StateFile string `json:"state_file"`
}
// Optional Client Lua update mirror. Machine-specific directories must be
// supplied by the local config or environment; empty means disabled.
type LuaOverlayConfig struct {
Enabled bool `json:"enabled"`
StageDir string `json:"stage_dir"`
HotfixDir string `json:"hotfix_dir"`
StreamingDir string `json:"streaming_dir"`
}
// 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,
"GAC_LUA_OVERLAY_STAGE_DIR": &cfg.LuaOverlay.StageDir,
"GAC_LUA_OVERLAY_HOTFIX_DIR": &cfg.LuaOverlay.HotfixDir,
"GAC_LUA_OVERLAY_STREAMING_DIR": &cfg.LuaOverlay.StreamingDir,
"GAC_GAME_CLOCK_STATE_FILE": &cfg.GameClock.StateFile,
} {
if value, ok := os.LookupEnv(name); ok {
*dest = value
}
}
if value, ok := os.LookupEnv("GAC_LUA_OVERLAY_ENABLED"); ok {
enabled, err := strconv.ParseBool(value)
if err != nil {
return cfg, fmt.Errorf("GAC_LUA_OVERLAY_ENABLED must be a boolean")
}
cfg.LuaOverlay.Enabled = enabled
}
if cfg.GameClock.StateFile != "" {
if strings.TrimSpace(cfg.GameClock.StateFile) == "" {
return cfg, fmt.Errorf("game_clock.state_file must be a file path")
}
if info, err := os.Stat(cfg.GameClock.StateFile); err == nil && info.IsDir() {
return cfg, fmt.Errorf("game_clock.state_file must not be a directory")
}
}
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.LuaOverlay.Enabled {
for name, directory := range map[string]string{"stage_dir": cfg.LuaOverlay.StageDir, "hotfix_dir": cfg.LuaOverlay.HotfixDir, "streaming_dir": cfg.LuaOverlay.StreamingDir} {
if strings.TrimSpace(directory) == "" {
return cfg, fmt.Errorf("lua_overlay.%s is required when enabled", name)
}
info, err := os.Stat(directory)
if err != nil || !info.IsDir() {
return cfg, fmt.Errorf("lua_overlay.%s must be an existing directory", name)
}
}
selector, err := os.ReadFile(filepath.Join(cfg.LuaOverlay.StageDir, "M_LuaV.bytes"))
if err != nil || len(selector) != 66 || string(selector[:4]) != "SRMI" {
return cfg, fmt.Errorf("lua_overlay stage selector is invalid")
}
var indexDigest [16]byte
for i := 0; i < 4; i++ {
for j := 0; j < 4; j++ {
indexDigest[i*4+j] = selector[28+i*4+3-j]
}
}
index, err := os.ReadFile(filepath.Join(cfg.LuaOverlay.StageDir, "LuaV_"+hex.EncodeToString(indexDigest[:])+".bytes"))
if err != nil || len(index) != int(binary.LittleEndian.Uint32(selector[44:48])) || md5.Sum(index) != indexDigest {
return cfg, fmt.Errorf("lua_overlay staged index is missing or invalid")
}
}
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)))
}