feat(all): configure proxies, reuse native HTTP and stabilize recovery
Persist direct/manual proxy settings in Client Studio and apply them to resource, Unity and native requests, with loopback bypass and no automatic system proxy. Pool WinHTTP/curl connections, bound workers and deadlines, cancel safely, and log queue and transport timings without exposing sensitive paths. Return explicit relay failures, probe through native HTTP, retry interrupted requests and recognize completed home, field and pack scenes during recovery.
This commit is contained in:
@@ -33,6 +33,7 @@ type Options struct {
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
GameDirectory string `json:"game_directory"`
|
||||
ServerOrigin string `json:"server_origin"`
|
||||
CDNMode clientconfig.CDNMode `json:"cdn_mode"`
|
||||
@@ -41,7 +42,7 @@ type Request struct {
|
||||
}
|
||||
|
||||
func (r Request) settings() clientconfig.Settings {
|
||||
return clientconfig.Settings{ServerOrigin: r.ServerOrigin, CDNMode: r.CDNMode, LocalResourceDirectory: r.LocalResourceDirectory}
|
||||
return clientconfig.Settings{ServerOrigin: r.ServerOrigin, ProxyURL: r.ProxyURL, CDNMode: r.CDNMode, LocalResourceDirectory: r.LocalResourceDirectory}
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
@@ -51,6 +52,7 @@ type Response struct {
|
||||
}
|
||||
|
||||
type InitialState struct {
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
Platform string `json:"platform"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
GameVersion string `json:"game_version"`
|
||||
@@ -75,7 +77,7 @@ type Studio struct {
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
quitOnce sync.Once
|
||||
launch func(string) error
|
||||
launch func(string, string) error
|
||||
savePreferences func(string) error
|
||||
}
|
||||
|
||||
@@ -133,6 +135,7 @@ func (s *Studio) Initialize() InitialState {
|
||||
s.log().Warn("saved client connection settings are unavailable", "error", err)
|
||||
return state
|
||||
}
|
||||
state.ProxyURL = settings.ProxyURL
|
||||
state.ServerOrigin = settings.ServerOrigin
|
||||
state.CDNMode = settings.CDNMode
|
||||
state.LocalResourceDirectory = settings.LocalResourceDirectory
|
||||
@@ -295,7 +298,8 @@ func (s *Studio) Launch(input Request) (Response, error) {
|
||||
if _, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), s.options.Versions); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if _, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), s.options.Versions); err != nil {
|
||||
settings, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), s.options.Versions)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if err := s.savePreferences(input.GameDirectory); err != nil {
|
||||
@@ -314,7 +318,7 @@ func (s *Studio) Launch(input Request) (Response, error) {
|
||||
return Response{}, fmt.Errorf("install or update the client plugins before launching; %s is missing", name)
|
||||
}
|
||||
}
|
||||
if err := s.launch(installation.LaunchTarget()); err != nil {
|
||||
if err := s.launch(installation.LaunchTarget(), settings.ProxyURL); err != nil {
|
||||
if errors.Is(err, errGameAlreadyRunning) {
|
||||
return success("Brown Dust II is already running", nil), nil
|
||||
}
|
||||
|
||||
@@ -1,89 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
// Unity reads these variables before managed plugins can run.
|
||||
func gameProxyEnvironment(environment []string, systemProxy string) []string {
|
||||
result := append([]string(nil), environment...)
|
||||
lookup := func(name string) (int, string) {
|
||||
for index, entry := range result {
|
||||
key, value, found := strings.Cut(entry, "=")
|
||||
if found && strings.EqualFold(key, name) {
|
||||
return index, value
|
||||
}
|
||||
}
|
||||
return -1, ""
|
||||
func isGameProxyEnvironmentKey(key string) bool {
|
||||
switch strings.ToLower(key) {
|
||||
case "unity_proxyserver", "unity_noproxy", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "bd2_client_proxy_url":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if index, _ := lookup("UNITY_PROXYSERVER"); index < 0 && validUnityProxy(systemProxy) {
|
||||
result = append(result, "UNITY_PROXYSERVER="+systemProxy)
|
||||
}
|
||||
index, bypass := lookup("UNITY_NOPROXY")
|
||||
parts := strings.FieldsFunc(bypass, func(character rune) bool { return character == ',' || character == ';' })
|
||||
for _, local := range []string{"localhost", "127.0.0.1", "::1"} {
|
||||
present := false
|
||||
for _, part := range parts {
|
||||
if strings.EqualFold(strings.TrimSpace(part), local) {
|
||||
present = true
|
||||
}
|
||||
}
|
||||
if !present {
|
||||
parts = append(parts, local)
|
||||
}
|
||||
|
||||
// Player settings are authoritative; stale process and OS proxy values cannot win.
|
||||
func gameProxyEnvironment(environment []string, proxyURL string) []string {
|
||||
result := make([]string, 0, len(environment)+12)
|
||||
for _, entry := range environment {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
if !isGameProxyEnvironmentKey(key) {
|
||||
result = append(result, entry)
|
||||
}
|
||||
}
|
||||
entry := "UNITY_NOPROXY=" + strings.Join(parts, ",")
|
||||
if index >= 0 {
|
||||
result[index] = entry
|
||||
} else {
|
||||
result = append(result, entry)
|
||||
for _, key := range []string{"UNITY_PROXYSERVER", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "BD2_CLIENT_PROXY_URL"} {
|
||||
result = append(result, key+"="+proxyURL)
|
||||
}
|
||||
for _, key := range []string{"UNITY_NOPROXY", "NO_PROXY", "no_proxy"} {
|
||||
result = append(result, key+"=localhost,127.0.0.1,::1")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validUnityProxy(proxy string) bool {
|
||||
parsed, err := url.Parse(proxy)
|
||||
if err != nil || parsed.Scheme != "http" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" {
|
||||
return false
|
||||
// LaunchServices needs explicit empty values when the player selects direct.
|
||||
func gameOpenArguments(target string, environment []string, proxyURL string) []string {
|
||||
args := []string{target}
|
||||
for _, entry := range gameProxyEnvironment(environment, proxyURL) {
|
||||
key, _, found := strings.Cut(entry, "=")
|
||||
if found && isGameProxyEnvironmentKey(key) {
|
||||
args = append(args, "--env", entry)
|
||||
}
|
||||
}
|
||||
host, port, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil || host == "" || port == "" {
|
||||
return false
|
||||
}
|
||||
_, err = net.LookupPort("tcp", port)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Unity accepts one proxy, so per-scheme configurations must agree.
|
||||
func sharedWindowsProxy(raw string) string {
|
||||
if !strings.Contains(raw, "=") {
|
||||
proxy := "http://" + strings.TrimSpace(raw)
|
||||
if validUnityProxy(proxy) {
|
||||
return proxy
|
||||
}
|
||||
return ""
|
||||
}
|
||||
var httpProxy, httpsProxy string
|
||||
for _, entry := range strings.Split(raw, ";") {
|
||||
key, value, found := strings.Cut(strings.TrimSpace(entry), "=")
|
||||
if !found {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(key) {
|
||||
case "http":
|
||||
httpProxy = value
|
||||
case "https":
|
||||
httpsProxy = value
|
||||
}
|
||||
}
|
||||
if httpProxy == "" || !strings.EqualFold(httpProxy, httpsProxy) {
|
||||
return ""
|
||||
}
|
||||
proxy := "http://" + httpProxy
|
||||
if validUnityProxy(proxy) {
|
||||
return proxy
|
||||
}
|
||||
return ""
|
||||
args = append(args, "--args")
|
||||
return append(args, gameLaunchArguments()...)
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func systemGameProxy() string {
|
||||
output, err := exec.Command("/usr/sbin/scutil", "--proxy").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
values := map[string]string{}
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
key, value, found := strings.Cut(strings.TrimSpace(line), " : ")
|
||||
if found {
|
||||
values[key] = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
if values["ProxyAutoConfigEnable"] == "1" || values["ProxyAutoDiscoveryEnable"] == "1" ||
|
||||
values["HTTPEnable"] != "1" || values["HTTPSEnable"] != "1" ||
|
||||
values["HTTPProxy"] != values["HTTPSProxy"] || values["HTTPPort"] != values["HTTPSPort"] {
|
||||
return ""
|
||||
}
|
||||
port, err := strconv.Atoi(values["HTTPPort"])
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return ""
|
||||
}
|
||||
proxy := "http://" + net.JoinHostPort(values["HTTPProxy"], strconv.Itoa(port))
|
||||
if validUnityProxy(proxy) {
|
||||
return proxy
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -6,39 +6,66 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGameProxyEnvironmentPreservesExplicitAndIsIdempotent(t *testing.T) {
|
||||
input := []string{"PATH=kept", "UNITY_PROXYSERVER=http://explicit:8080", "UNITY_NOPROXY=example.org;localhost"}
|
||||
got := gameProxyEnvironment(input, "http://system:8888")
|
||||
if !reflect.DeepEqual(got, gameProxyEnvironment(got, "http://other:9999")) {
|
||||
t.Fatal("environment is not idempotent")
|
||||
}
|
||||
if got[1] != input[1] || input[2] != "UNITY_NOPROXY=example.org;localhost" {
|
||||
t.Fatal("explicit environment changed or input mutated")
|
||||
}
|
||||
if !strings.Contains(got[2], "127.0.0.1") || !strings.Contains(got[2], "::1") {
|
||||
t.Fatal("loopback bypass missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedWindowsProxy(t *testing.T) {
|
||||
for _, test := range []struct{ input, want string }{
|
||||
{"127.0.0.1:12451", "http://127.0.0.1:12451"},
|
||||
{"http=proxy:8080;https=proxy:8080", "http://proxy:8080"},
|
||||
{"http=proxy:8080;https=other:8080", ""},
|
||||
{"https=proxy:8080", ""}, {"user:password@proxy:8080", ""}, {"proxy:99999", ""},
|
||||
} {
|
||||
if got := sharedWindowsProxy(test.input); got != test.want {
|
||||
t.Errorf("proxy configuration result mismatch")
|
||||
func TestGameProxyEnvironmentAuthoritative(t *testing.T) {
|
||||
input := []string{"PATH=kept", "UNITY_PROXYSERVER=http://stale:8080", "uNiTy_NoPrOxY=*", "http_proxy=http://stale:8080", "HTTPS_PROXY=http://stale:8080", "All_Proxy=http://stale:8080", "NO_PROXY=*", "bd2_client_proxy_url=http://stale:8080"}
|
||||
original := append([]string(nil), input...)
|
||||
for _, proxy := range []string{"", "http://127.0.0.1:12451"} {
|
||||
got := gameProxyEnvironment(input, proxy)
|
||||
if !reflect.DeepEqual(got, gameProxyEnvironment(got, proxy)) {
|
||||
t.Fatal("environment is not idempotent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameProxyEnvironmentIgnoresInvalidSystemProxy(t *testing.T) {
|
||||
for _, proxy := range []string{"", "https://proxy:443", "http://user:password@proxy:8080", "http://proxy:8080/path"} {
|
||||
for _, entry := range gameProxyEnvironment(nil, proxy) {
|
||||
if strings.HasPrefix(entry, "UNITY_PROXYSERVER=") {
|
||||
t.Fatal("invalid system proxy accepted")
|
||||
if got[0] != "PATH=kept" || !reflect.DeepEqual(input, original) {
|
||||
t.Fatal("unrelated environment or input changed")
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, entry := range got[1:] {
|
||||
key, value, _ := strings.Cut(entry, "=")
|
||||
if !isGameProxyEnvironmentKey(key) || strings.Contains(value, "stale") {
|
||||
t.Fatal("stale proxy survived")
|
||||
}
|
||||
seen[key] = value
|
||||
}
|
||||
for _, key := range []string{"UNITY_PROXYSERVER", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "BD2_CLIENT_PROXY_URL"} {
|
||||
if value, ok := seen[key]; !ok || value != proxy {
|
||||
t.Errorf("missing authoritative %s override", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"UNITY_NOPROXY", "NO_PROXY", "no_proxy"} {
|
||||
if seen[key] != "localhost,127.0.0.1,::1" {
|
||||
t.Errorf("loopback bypass missing for %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameProxyEnvironmentKeys(t *testing.T) {
|
||||
for _, key := range []string{"UNITY_PROXYSERVER", "unity_noproxy", "http_proxy", "HTTPS_PROXY", "All_Proxy", "NO_PROXY", "bd2_client_proxy_url"} {
|
||||
if !isGameProxyEnvironmentKey(key) {
|
||||
t.Errorf("proxy key %q not recognized", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"PATH", "SECRET", "HTTP_PROXY_PASSWORD", "NO_PROXY_EXTRA"} {
|
||||
if isGameProxyEnvironmentKey(key) {
|
||||
t.Errorf("unrelated key %q recognized", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameOpenArgumentsOverridesLaunchServicesProxy(t *testing.T) {
|
||||
args := gameOpenArguments("/Applications/BrownDust II.app", []string{"HTTP_PROXY=http://stale:80", "PATH=private"}, "")
|
||||
overrides := map[string]bool{}
|
||||
for index := 1; index < len(args) && args[index] != "--args"; index += 2 {
|
||||
if args[index] != "--env" {
|
||||
t.Fatal("missing explicit LaunchServices environment flag")
|
||||
}
|
||||
overrides[args[index+1]] = true
|
||||
}
|
||||
for _, entry := range []string{"HTTP_PROXY=", "http_proxy=", "HTTPS_PROXY=", "ALL_PROXY=", "UNITY_PROXYSERVER=", "BD2_CLIENT_PROXY_URL=", "UNITY_NOPROXY=localhost,127.0.0.1,::1"} {
|
||||
if !overrides[entry] {
|
||||
t.Errorf("LaunchServices override missing: %s", entry)
|
||||
}
|
||||
}
|
||||
if overrides["PATH=private"] || overrides["HTTP_PROXY=http://stale:80"] {
|
||||
t.Fatal("unrelated or stale environment forwarded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func systemGameProxy() string {
|
||||
var config struct {
|
||||
AutoDetect int32
|
||||
AutoConfigURL, Proxy, Bypass *uint16
|
||||
}
|
||||
procedure := syscall.NewLazyDLL("winhttp.dll").NewProc("WinHttpGetIEProxyConfigForCurrentUser")
|
||||
globalFree := syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalFree")
|
||||
result, _, _ := procedure.Call(uintptr(unsafe.Pointer(&config)))
|
||||
defer func() {
|
||||
for _, pointer := range []*uint16{config.AutoConfigURL, config.Proxy, config.Bypass} {
|
||||
if pointer != nil {
|
||||
globalFree.Call(uintptr(unsafe.Pointer(pointer)))
|
||||
}
|
||||
}
|
||||
}()
|
||||
if result == 0 || config.AutoDetect != 0 || config.AutoConfigURL != nil || config.Proxy == nil {
|
||||
return ""
|
||||
}
|
||||
return sharedWindowsProxy(windows.UTF16PtrToString(config.Proxy))
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -17,7 +16,7 @@ func ShowFatalError(err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func launchGame(target string) error {
|
||||
func launchGame(target, proxyURL string) error {
|
||||
info, err := os.Stat(target)
|
||||
if err != nil || !info.IsDir() || !strings.EqualFold(filepath.Ext(target), ".app") {
|
||||
return fmt.Errorf("invalid macOS application bundle %q", target)
|
||||
@@ -27,14 +26,6 @@ func launchGame(target string) error {
|
||||
}
|
||||
// LaunchServices does not inherit open's environment; --env explicitly
|
||||
// supplies the Unity variables to the newly launched application.
|
||||
args := []string{target}
|
||||
for _, entry := range gameProxyEnvironment(os.Environ(), systemGameProxy()) {
|
||||
key, _, found := strings.Cut(entry, "=")
|
||||
if found && (strings.EqualFold(key, "UNITY_PROXYSERVER") || strings.EqualFold(key, "UNITY_NOPROXY")) {
|
||||
args = append(args, "--env", entry)
|
||||
}
|
||||
}
|
||||
args = append(args, "--args")
|
||||
args = append(args, gameLaunchArguments()...)
|
||||
args := gameOpenArguments(target, os.Environ(), proxyURL)
|
||||
return exec.Command("open", args...).Start()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ func ShowFatalError(err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func launchGame(string) error {
|
||||
func launchGame(string, string) error {
|
||||
return errors.New("the Brown Dust II client is not supported on Linux")
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func visibleCommand(name string, args ...string) *exec.Cmd {
|
||||
return command
|
||||
}
|
||||
|
||||
func launchGame(target string) error {
|
||||
func launchGame(target, proxyURL string) error {
|
||||
if processID, running, err := windowsExecutableProcessID(filepath.Base(target)); err != nil {
|
||||
return err
|
||||
} else if running {
|
||||
@@ -80,7 +80,7 @@ func launchGame(target string) error {
|
||||
}
|
||||
command := visibleCommand(target, gameLaunchArguments()...)
|
||||
command.Dir = filepath.Dir(target)
|
||||
command.Env = gameProxyEnvironment(os.Environ(), systemGameProxy())
|
||||
command.Env = gameProxyEnvironment(os.Environ(), proxyURL)
|
||||
if err := command.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
clientlayout "bd2server/internal/client/layout"
|
||||
@@ -27,6 +28,7 @@ const (
|
||||
type CDNMode string
|
||||
|
||||
type Settings struct {
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ServerOrigin string `json:"server_origin"`
|
||||
CDNMode CDNMode `json:"cdn_mode"`
|
||||
@@ -45,6 +47,10 @@ func Normalize(in Settings) (Settings, error) {
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
proxyURL, err := NormalizeProxyURL(in.ProxyURL)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
localDirectory := strings.TrimSpace(in.LocalResourceDirectory)
|
||||
switch in.CDNMode {
|
||||
case CDNOfficial, CDNServer:
|
||||
@@ -64,6 +70,7 @@ func Normalize(in Settings) (Settings, error) {
|
||||
}
|
||||
return Settings{
|
||||
SchemaVersion: SchemaVersion,
|
||||
ProxyURL: proxyURL,
|
||||
ServerOrigin: origin,
|
||||
CDNMode: in.CDNMode,
|
||||
LocalResourceDirectory: localDirectory,
|
||||
@@ -99,6 +106,35 @@ func NormalizeOrigin(raw string) (string, error) {
|
||||
return strings.TrimSuffix(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
// NormalizeProxyURL accepts only an explicit HTTP proxy endpoint. Errors never echo input.
|
||||
func NormalizeProxyURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
invalid := errors.New("client config: proxy must be an HTTP URL with a host and numeric port (1-65535), without credentials, path, query or fragment")
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme != "http" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.Contains(raw, "#") || (parsed.Path != "" && parsed.Path != "/") || parsed.RawPath != "" {
|
||||
return "", invalid
|
||||
}
|
||||
host, port, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil || host == "" || port == "" || strings.ContainsAny(host, " \t\r\n%") {
|
||||
return "", invalid
|
||||
}
|
||||
for _, ch := range port {
|
||||
if ch < '0' || ch > '9' {
|
||||
return "", invalid
|
||||
}
|
||||
}
|
||||
number, err := strconv.Atoi(port)
|
||||
if err != nil || number < 1 || number > 65535 {
|
||||
return "", invalid
|
||||
}
|
||||
parsed.Host = net.JoinHostPort(host, strconv.Itoa(number))
|
||||
parsed.Path = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func isLoopback(host string) bool {
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
|
||||
@@ -96,3 +96,38 @@ func TestPathUsesMacAppSiblingBepInEx(t *testing.T) {
|
||||
t.Fatalf("Path()=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitProxyNormalization(t *testing.T) {
|
||||
for _, test := range []struct{ raw, want string }{
|
||||
{"", ""}, {" ", ""}, {" http://127.0.0.1:12451/ ", "http://127.0.0.1:12451"}, {"http://[::1]:8080", "http://[::1]:8080"}, {"http://proxy.example:08080", "http://proxy.example:8080"},
|
||||
} {
|
||||
got, err := NormalizeProxyURL(test.raw)
|
||||
if err != nil || got != test.want {
|
||||
t.Errorf("normalize proxy: got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"http://proxy", "https://proxy:443", "socks5://proxy:1080", "http://user:secret@proxy:8080", "http://proxy:0", "http://proxy:65536", "http://proxy:http", "http://proxy:+80", "http://proxy:8080/path", "http://proxy:8080?secret", "http://proxy:8080?", "http://proxy:8080#", "http://proxy:8080#secret", "http://:8080", "http://[::1%25zone]:8080"} {
|
||||
if _, err := NormalizeProxyURL(bad); err == nil {
|
||||
t.Errorf("invalid proxy accepted: %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxySettingsPersistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
saved, err := Save(dir, Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial, ProxyURL: "http://localhost:12451/"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := Load(dir)
|
||||
if err != nil || loaded.ProxyURL != "http://localhost:12451" || saved != loaded {
|
||||
t.Fatalf("proxy not persisted: %#v, %v", loaded, err)
|
||||
}
|
||||
if _, err := Save(dir, Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err = Load(dir)
|
||||
if err != nil || loaded.ProxyURL != "" {
|
||||
t.Fatal("omitted proxy should use direct connection")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,19 @@ func FetchResourcePolicy(ctx context.Context, client *http.Client, settings clie
|
||||
}, nil
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
if normalized.ProxyURL != "" {
|
||||
proxyURL, _ := url.Parse(normalized.ProxyURL)
|
||||
transport.Proxy = func(request *http.Request) (*url.URL, error) {
|
||||
if resourceLoopback(request.URL.Hostname()) {
|
||||
return nil, nil
|
||||
}
|
||||
return proxyURL, nil
|
||||
}
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
client = &http.Client{Timeout: 10 * time.Second, Transport: transport}
|
||||
}
|
||||
body, err := json.Marshal(map[string]clientconfig.CDNMode{"cdn_mode": normalized.CDNMode})
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
clientconfig "bd2server/internal/client/config"
|
||||
@@ -171,3 +172,43 @@ func TestOfficialDoesNotContactServer(t *testing.T) {
|
||||
t.Fatalf("policy=%+v err=%v", policy, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePolicyLoopbackBypassesPlayerAndEnvironmentProxy(t *testing.T) {
|
||||
var proxyCalls atomic.Int32
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxyCalls.Add(1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
t.Setenv("HTTP_PROXY", proxy.URL)
|
||||
t.Setenv("HTTPS_PROXY", proxy.URL)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(ResourcePolicy{Mode: clientconfig.CDNServer, ServerDataURL: "https://cdn.example/ServerData", GameDataURL: "https://cdn.example/GameData", BundleVersion: testVersions().BundleVersion, GameDataVersion: testVersions().GameDataVersion})
|
||||
}))
|
||||
defer server.Close()
|
||||
for _, configured := range []string{"", proxy.URL} {
|
||||
_, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{ServerOrigin: server.URL, CDNMode: clientconfig.CDNServer, ProxyURL: configured}, testVersions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if proxyCalls.Load() != 0 {
|
||||
t.Fatal("loopback policy request used proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePolicyUsesExplicitProxyForRemoteOrigin(t *testing.T) {
|
||||
var proxyCalls atomic.Int32
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodConnect || r.Host != "remote.invalid:443" {
|
||||
t.Errorf("unexpected proxy request: %s %s", r.Method, r.Host)
|
||||
}
|
||||
proxyCalls.Add(1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
_, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{ServerOrigin: "https://remote.invalid", CDNMode: clientconfig.CDNServer, ProxyURL: proxy.URL}, testVersions())
|
||||
if err == nil || proxyCalls.Load() != 1 {
|
||||
t.Fatal("remote policy request did not use explicit HTTP proxy")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ internal sealed class ClientRouting : IDisposable
|
||||
{
|
||||
string path = Path.Combine(Paths.ConfigPath, ConfigFileName);
|
||||
ClientConfig config = ParseConfig(ReadBoundedFile(path, MaximumResponseBytes));
|
||||
// Set only our process-local choice. Login UI also uses this value when
|
||||
// the game is launched directly; it never imports a system proxy.
|
||||
Environment.SetEnvironmentVariable("BD2_CLIENT_PROXY_URL", config.proxy_url);
|
||||
ValidateOrigin(config.server_origin, "server_origin");
|
||||
config.server_origin = config.server_origin.TrimEnd('/');
|
||||
|
||||
@@ -174,12 +177,8 @@ internal sealed class ClientRouting : IDisposable
|
||||
request.AllowAutoRedirect = false;
|
||||
request.Timeout = 10_000;
|
||||
request.ReadWriteTimeout = 10_000;
|
||||
if (endpoint.IsLoopback)
|
||||
{
|
||||
// UnityWebRequest can inherit a system proxy even for loopback.
|
||||
// The configured private server must be contacted directly.
|
||||
request.Proxy = null;
|
||||
}
|
||||
request.Proxy = endpoint.IsLoopback || string.IsNullOrEmpty(config.proxy_url)
|
||||
? null : new WebProxy(config.proxy_url);
|
||||
|
||||
log.LogInfo("Requesting client resource policy: origin=" + config.server_origin +
|
||||
" endpoint=" + endpoint.AbsoluteUri + " mode=" + config.cdn_mode + " method=PUT");
|
||||
@@ -214,13 +213,14 @@ internal sealed class ClientRouting : IDisposable
|
||||
private static ClientConfig ParseConfig(string json)
|
||||
{
|
||||
JObject value = ParseObject(json, "client configuration");
|
||||
RequireOnly(value, "schema_version", "server_origin", "cdn_mode", "local_resource_directory");
|
||||
RequireOnly(value, "schema_version", "server_origin", "cdn_mode", "local_resource_directory", "proxy_url");
|
||||
ClientConfig config = new ClientConfig
|
||||
{
|
||||
schema_version = RequiredInteger(value, "schema_version"),
|
||||
server_origin = RequiredString(value, "server_origin"),
|
||||
cdn_mode = RequiredString(value, "cdn_mode"),
|
||||
local_resource_directory = OptionalString(value, "local_resource_directory")
|
||||
local_resource_directory = OptionalString(value, "local_resource_directory"),
|
||||
proxy_url = NormalizeClientProxy(OptionalString(value, "proxy_url"))
|
||||
};
|
||||
if (config.schema_version != 2)
|
||||
{
|
||||
@@ -241,6 +241,30 @@ internal sealed class ClientRouting : IDisposable
|
||||
return config;
|
||||
}
|
||||
|
||||
private static string NormalizeClientProxy(string raw)
|
||||
{
|
||||
raw = raw.Trim();
|
||||
if (raw.Length == 0) return string.Empty;
|
||||
const string error = "proxy_url must be an HTTP proxy address with an explicit port and no credentials or path";
|
||||
if (raw.IndexOfAny(new[] { '?', '#', '@', '%' }) >= 0) throw new InvalidDataException(error);
|
||||
foreach (char character in raw)
|
||||
if (char.IsWhiteSpace(character) || char.IsControl(character)) throw new InvalidDataException(error);
|
||||
if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri proxy) || proxy.Scheme != "http" ||
|
||||
string.IsNullOrEmpty(proxy.Host) || !string.IsNullOrEmpty(proxy.UserInfo) ||
|
||||
!string.IsNullOrEmpty(proxy.Query) || !string.IsNullOrEmpty(proxy.Fragment) || proxy.AbsolutePath != "/")
|
||||
throw new InvalidDataException(error);
|
||||
string authority = raw.Substring(raw.IndexOf("://", StringComparison.Ordinal) + 3).TrimEnd('/');
|
||||
int separator = authority.LastIndexOf(':');
|
||||
if (separator <= 0) throw new InvalidDataException(error);
|
||||
string portText = authority.Substring(separator + 1);
|
||||
foreach (char character in portText) if (character < '0' || character > '9') throw new InvalidDataException(error);
|
||||
if (!int.TryParse(portText, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture,
|
||||
out int port) || port < 1 || port > 65535) throw new InvalidDataException(error);
|
||||
string host = proxy.IdnHost.Trim('[', ']');
|
||||
if (proxy.HostNameType == UriHostNameType.IPv6) host = "[" + host + "]";
|
||||
return "http://" + host + ":" + port.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static ResourcePolicy ParsePolicy(string json)
|
||||
{
|
||||
JObject value = ParseObject(json, "resource policy");
|
||||
@@ -403,6 +427,7 @@ internal sealed class ClientRouting : IDisposable
|
||||
public string server_origin;
|
||||
public string cdn_mode;
|
||||
public string local_resource_directory;
|
||||
public string proxy_url;
|
||||
}
|
||||
|
||||
private sealed class ResourcePolicy
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"schema_version": 2,
|
||||
"server_origin": "http://127.0.0.1:8080",
|
||||
"cdn_mode": "local",
|
||||
"proxy_url": "",
|
||||
"local_resource_directory": "E:\\bd2\\dl"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
internal sealed class NativeProxyConfigurationException : InvalidOperationException
|
||||
{
|
||||
public NativeProxyConfigurationException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
internal sealed class NativeProxySettings
|
||||
{
|
||||
public string ProxyUrl { get; }
|
||||
public string Key { get; }
|
||||
internal NativeProxySettings(string proxy, string configuration)
|
||||
{
|
||||
ProxyUrl = proxy;
|
||||
using (var hash = SHA256.Create())
|
||||
Key = Convert.ToBase64String(hash.ComputeHash(Encoding.UTF8.GetBytes(configuration + "\n" + proxy)));
|
||||
}
|
||||
}
|
||||
|
||||
// Only the player's persisted client choice controls owned HTTP requests.
|
||||
// Local Identity and Client Studio supply this process-local value; inherited
|
||||
// system, Unity and standard HTTP proxy settings are never used as a fallback.
|
||||
internal static class NativeProxyPolicy
|
||||
{
|
||||
private const string ProxyVariable = "BD2_CLIENT_PROXY_URL";
|
||||
|
||||
public static NativeProxySettings Resolve(Uri destination)
|
||||
{
|
||||
if (destination == null || !destination.IsAbsoluteUri) throw new ArgumentException("Invalid proxy destination");
|
||||
if (destination.IsLoopback) return new NativeProxySettings(null, "loopback");
|
||||
string selected = Environment.GetEnvironmentVariable(ProxyVariable);
|
||||
if (string.IsNullOrWhiteSpace(selected)) return new NativeProxySettings(null, "client-direct");
|
||||
string proxy = NormalizeProxy(selected);
|
||||
return new NativeProxySettings(proxy, "client-manual");
|
||||
}
|
||||
|
||||
private static string NormalizeProxy(string raw)
|
||||
{
|
||||
raw = raw.Trim();
|
||||
if (raw.IndexOfAny(new[] { '?', '#', '@', '%' }) >= 0) throw InvalidProxy();
|
||||
foreach (char value in raw)
|
||||
if (char.IsWhiteSpace(value) || char.IsControl(value)) throw InvalidProxy();
|
||||
if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri proxy) || proxy.Scheme != "http" ||
|
||||
string.IsNullOrEmpty(proxy.Host) || !string.IsNullOrEmpty(proxy.UserInfo) ||
|
||||
!string.IsNullOrEmpty(proxy.Query) || !string.IsNullOrEmpty(proxy.Fragment) || proxy.AbsolutePath != "/")
|
||||
throw InvalidProxy();
|
||||
string authority = raw.Substring(raw.IndexOf("://", StringComparison.Ordinal) + 3).TrimEnd('/');
|
||||
int separator = authority.LastIndexOf(':');
|
||||
if (separator <= 0) throw InvalidProxy();
|
||||
string portText = authority.Substring(separator + 1);
|
||||
foreach (char value in portText) if (value < '0' || value > '9') throw InvalidProxy();
|
||||
if (!int.TryParse(portText, NumberStyles.None, CultureInfo.InvariantCulture, out int port) || port < 1 || port > 65535)
|
||||
throw InvalidProxy();
|
||||
string host = proxy.IdnHost.Trim('[', ']');
|
||||
if (proxy.HostNameType == UriHostNameType.IPv6) host = "[" + host + "]";
|
||||
return "http://" + host + ":" + port.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static NativeProxyConfigurationException InvalidProxy() =>
|
||||
new NativeProxyConfigurationException("Client proxy must be an HTTP proxy address with an explicit port and no credentials or path");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
internal static partial class PlatformControlHttp
|
||||
{
|
||||
private static long nextRequestId;
|
||||
private static Action<string> completionLogger;
|
||||
internal static void SetCompletionLogger(Action<string> logger) => Interlocked.Exchange(ref completionLogger, logger);
|
||||
|
||||
private static void LogCompletion(Uri uri, string method, Response response)
|
||||
{
|
||||
try
|
||||
{
|
||||
Action<string> logger = Volatile.Read(ref completionLogger);
|
||||
if (logger == null) return;
|
||||
string safeMethod = method == "GET" || method == "POST" || method == "PUT" ? method : "invalid";
|
||||
logger(string.Format(CultureInfo.InvariantCulture,
|
||||
"Native HTTP transaction: request_id={0} method={1} path={2} status={3} queue_ms={4} transport_ms={5} total_ms={6} response_bytes={7} failure={8} native_error={9} timing_scope=includes_server_wait",
|
||||
response.RequestId, safeMethod, DiagnosticPath(uri), response.StatusCode,
|
||||
response.QueueMilliseconds, response.TransportMilliseconds, response.ElapsedMilliseconds,
|
||||
response.Data?.LongLength ?? 0, response.FailureKind ?? (response.Error != null ? "Transport" : response.Success ? "none" : "HttpStatus"), response.NativeErrorCode));
|
||||
}
|
||||
catch { /* Logging must never change the request result. */ }
|
||||
}
|
||||
|
||||
private static string DiagnosticPath(Uri uri)
|
||||
{
|
||||
if (uri == null || !uri.IsAbsoluteUri) return "invalid";
|
||||
string path = uri.AbsolutePath;
|
||||
switch (path)
|
||||
{
|
||||
case "/auth/config": case "/auth/device": case "/auth/session/refresh": case "/auth/session/revoke":
|
||||
case "/client/runtime": case "/client/resources": case "/readyz": case "/livez": case "/healthz":
|
||||
case "/StateCheckInfoJson": case "/logs": return path;
|
||||
}
|
||||
string[] segments = path.Split('/');
|
||||
if (segments.Length == 5 && segments[1] == "auth" && segments[2] == "device" && segments[4] == "poll")
|
||||
return "/auth/device/{id}/poll";
|
||||
if (segments.Length == 3 && segments[1] == "game" && DiagnosticGamePacket(segments[2])) return path;
|
||||
return "redacted";
|
||||
}
|
||||
|
||||
// Game routes use static PascalCase packet names; identifiers are carried in the request body.
|
||||
private static bool DiagnosticGamePacket(string name)
|
||||
{
|
||||
if (name.Length == 0 || name.Length > 80 || name[0] < 'A' || name[0] > 'Z') return false;
|
||||
foreach (char value in name)
|
||||
if (!((value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z') || (value >= '0' && value <= '9'))) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
internal static partial class PlatformControlHttp
|
||||
{
|
||||
private static readonly Dictionary<string, CurlPool> CurlPools = new Dictionary<string, CurlPool>();
|
||||
private sealed class CurlPool
|
||||
{
|
||||
internal readonly Stack<IntPtr> Idle = new Stack<IntPtr>();
|
||||
internal int Users;
|
||||
internal DateTime LastUsed;
|
||||
internal bool Retired;
|
||||
}
|
||||
private static IntPtr AcquireCurl(Uri uri, NativeProxySettings proxy, CancellationToken token, out CurlPool pool)
|
||||
{
|
||||
pool = null;
|
||||
token.ThrowIfCancellationRequested();
|
||||
string key = uri.GetLeftPart(UriPartial.Authority) + "|" + proxy.Key;
|
||||
lock (PoolLock)
|
||||
{
|
||||
if (shuttingDown) throw new OperationCanceledException();
|
||||
List<string> stale = new List<string>();
|
||||
foreach (KeyValuePair<string, CurlPool> entry in CurlPools)
|
||||
if (entry.Value.Users == 0 && (DateTime.UtcNow - entry.Value.LastUsed > TimeSpan.FromMinutes(2) || CurlPools.Count >= 16)) stale.Add(entry.Key);
|
||||
foreach (string old in stale)
|
||||
{
|
||||
CurlPool retired = CurlPools[old]; retired.Retired = true;
|
||||
while (retired.Idle.Count != 0) curl_easy_cleanup(retired.Idle.Pop());
|
||||
CurlPools.Remove(old);
|
||||
}
|
||||
if (!CurlPools.TryGetValue(key, out pool)) { pool = new CurlPool(); CurlPools.Add(key, pool); }
|
||||
IntPtr handle = pool.Idle.Count != 0 ? pool.Idle.Pop() : curl_easy_init();
|
||||
if (handle == IntPtr.Zero) throw new InvalidOperationException("Could not create system curl request");
|
||||
pool.Users++;
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
private static void ReleaseCurl(CurlPool pool, IntPtr handle, bool reusable)
|
||||
{
|
||||
lock (PoolLock)
|
||||
{
|
||||
pool.Users--;
|
||||
pool.LastUsed = DateTime.UtcNow;
|
||||
if (shuttingDown || pool.Retired || !reusable || pool.Idle.Count >= 2) curl_easy_cleanup(handle);
|
||||
else pool.Idle.Push(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
internal static partial class PlatformControlHttp
|
||||
{
|
||||
private static readonly object PoolLock = new object();
|
||||
private static readonly CancellationTokenSource ShutdownCancellation = new CancellationTokenSource();
|
||||
private static readonly Dictionary<string, WindowsPool> WindowsPools = new Dictionary<string, WindowsPool>();
|
||||
private static readonly Dictionary<IntPtr, WindowsRequest> WindowsRequests = new Dictionary<IntPtr, WindowsRequest>();
|
||||
private static readonly WinHttpCallback WindowsCallback = OnWindowsStatus;
|
||||
private static bool shuttingDown;
|
||||
|
||||
public static void Shutdown()
|
||||
{
|
||||
lock (PoolLock)
|
||||
{
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
}
|
||||
ShutdownCancellation.Cancel();
|
||||
lock (PoolLock)
|
||||
{
|
||||
foreach (WindowsPool pool in WindowsPools.Values) pool.Retired = true;
|
||||
foreach (WindowsPool pool in WindowsPools.Values) if (pool.Users == 0) pool.Close();
|
||||
WindowsPools.Clear();
|
||||
foreach (CurlPool pool in CurlPools.Values)
|
||||
while (pool.Idle.Count != 0) curl_easy_cleanup(pool.Idle.Pop());
|
||||
CurlPools.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class WindowsPool
|
||||
{
|
||||
internal IntPtr Session, Connection;
|
||||
internal int Users;
|
||||
internal string Key;
|
||||
internal bool Retired;
|
||||
internal DateTime LastUsed;
|
||||
internal void Close()
|
||||
{
|
||||
if (Connection != IntPtr.Zero) WinHttpCloseHandle(Connection);
|
||||
if (Session != IntPtr.Zero) WinHttpCloseHandle(Session);
|
||||
Connection = Session = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
private static WindowsPool AcquireWindows(Uri uri, NativeProxySettings proxy)
|
||||
{
|
||||
string key = uri.GetLeftPart(UriPartial.Authority) + "|" + proxy.Key;
|
||||
lock (PoolLock)
|
||||
{
|
||||
if (shuttingDown) throw new OperationCanceledException();
|
||||
List<string> stale = new List<string>();
|
||||
foreach (KeyValuePair<string, WindowsPool> entry in WindowsPools)
|
||||
if (entry.Value.Users == 0 && (DateTime.UtcNow - entry.Value.LastUsed > TimeSpan.FromMinutes(2) || WindowsPools.Count >= 16))
|
||||
stale.Add(entry.Key);
|
||||
foreach (string old in stale) { WindowsPools[old].Close(); WindowsPools.Remove(old); }
|
||||
if (!WindowsPools.TryGetValue(key, out WindowsPool pool))
|
||||
{
|
||||
pool = new WindowsPool { Key = key };
|
||||
string server = proxy.ProxyUrl == null ? null : new Uri(proxy.ProxyUrl).Authority;
|
||||
pool.Session = WinHttpOpen("BD2LoginUI", server == null ? 1u : 3u, server, null, 0x10000000);
|
||||
if (pool.Session == IntPtr.Zero) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
||||
pool.Connection = WinHttpConnect(pool.Session, uri.IdnHost, (ushort)uri.Port, 0);
|
||||
if (pool.Connection == IntPtr.Zero) { int error = Marshal.GetLastWin32Error(); pool.Close(); throw new System.ComponentModel.Win32Exception(error); }
|
||||
WindowsPools.Add(key, pool);
|
||||
}
|
||||
pool.Users++;
|
||||
return pool;
|
||||
}
|
||||
}
|
||||
private static void ReleaseWindows(WindowsPool pool, bool reusable)
|
||||
{
|
||||
lock (PoolLock)
|
||||
{
|
||||
pool.LastUsed = DateTime.UtcNow;
|
||||
// Closing an aborted request can leave WinHTTP connection-cache entries
|
||||
// waiting for server-side I/O. Retire that cache instead of routing fresh
|
||||
// work through it; existing leases retain their session until finished.
|
||||
if (!reusable)
|
||||
{
|
||||
pool.Retired = true;
|
||||
if (WindowsPools.TryGetValue(pool.Key, out WindowsPool current) && ReferenceEquals(current, pool))
|
||||
WindowsPools.Remove(pool.Key);
|
||||
}
|
||||
if (--pool.Users == 0 && pool.Retired) pool.Close();
|
||||
}
|
||||
}
|
||||
|
||||
// Every native API invocation and handle close is serialized. Async operations
|
||||
// return immediately; WinHTTP owns outstanding I/O until HANDLE_CLOSING.
|
||||
// Pinned buffers remain alive until that final callback, including cancellation.
|
||||
private sealed class WindowsRequest
|
||||
{
|
||||
internal readonly object Gate = new object();
|
||||
internal readonly AutoResetEvent Ready = new AutoResetEvent(false);
|
||||
internal readonly ManualResetEvent Closed = new ManualResetEvent(false);
|
||||
internal IntPtr Handle;
|
||||
internal bool Closing;
|
||||
internal uint Status, Read;
|
||||
internal int Error;
|
||||
internal void Close()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (Closing) return;
|
||||
Closing = true;
|
||||
if (Handle != IntPtr.Zero) WinHttpCloseHandle(Handle);
|
||||
}
|
||||
}
|
||||
internal void Invoke(Func<bool> call)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (Closing) throw new OperationCanceledException();
|
||||
if (!call())
|
||||
{
|
||||
int error = Marshal.GetLastWin32Error();
|
||||
if (error != 997) throw new System.ComponentModel.Win32Exception(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
internal void Wait(uint wanted, CancellationToken token)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
Ready.WaitOne();
|
||||
token.ThrowIfCancellationRequested();
|
||||
lock (Gate)
|
||||
{
|
||||
if (Error != 0) throw new System.ComponentModel.Win32Exception(Error);
|
||||
if (Status == wanted) return;
|
||||
if (Closing) throw new OperationCanceledException();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private static void OnWindowsStatus(IntPtr handle, UIntPtr context, uint status, IntPtr information, uint length)
|
||||
{
|
||||
WindowsRequest request;
|
||||
lock (PoolLock) WindowsRequests.TryGetValue(handle, out request);
|
||||
if (request == null) return;
|
||||
// Do not acquire Gate: WinHTTP may synchronously invoke a callback inside
|
||||
// an API/CloseHandle. Callback updates precede setting the event.
|
||||
if (status == 0x800)
|
||||
{
|
||||
// Remove before waking the lease: WinHTTP may reuse this numeric
|
||||
// handle immediately after the final callback has completed.
|
||||
lock (PoolLock) WindowsRequests.Remove(handle);
|
||||
request.Ready.Set(); request.Closed.Set(); return;
|
||||
}
|
||||
if (status == 0x200000) request.Error = Marshal.ReadInt32(information, IntPtr.Size);
|
||||
if (status == 0x80000) request.Read = length;
|
||||
if (status == 0x400000 || status == 0x20000 || status == 0x80000 || status == 0x200000)
|
||||
{ request.Status = status; request.Ready.Set(); }
|
||||
}
|
||||
|
||||
private static Response SendWindows(Uri uri, string method, byte[] body, string authorization,
|
||||
int timeoutSeconds, CancellationToken token, int maxResponseBytes, IReadOnlyDictionary<string, string> requestHeaders)
|
||||
{
|
||||
NativeProxySettings proxy = NativeProxyPolicy.Resolve(uri);
|
||||
token.ThrowIfCancellationRequested();
|
||||
WindowsPool pool = AcquireWindows(uri, proxy);
|
||||
WindowsRequest state = new WindowsRequest();
|
||||
GCHandle bodyPin = default, readPin = default;
|
||||
bool callbackInstalled = false, reusable = false;
|
||||
try
|
||||
{
|
||||
state.Handle = WinHttpOpenRequest(pool.Connection, method, uri.PathAndQuery, null, null, IntPtr.Zero,
|
||||
uri.Scheme == Uri.UriSchemeHttps ? 0x00800000u : 0u);
|
||||
if (state.Handle == IntPtr.Zero) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
||||
lock (PoolLock) WindowsRequests.Add(state.Handle, state);
|
||||
if (WinHttpSetStatusCallback(state.Handle, WindowsCallback, 0x400000 | 0x20000 | 0x80000 | 0x200000 | 0x800, UIntPtr.Zero) == new IntPtr(-1))
|
||||
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
||||
callbackInstalled = true;
|
||||
using (token.Register(() => { state.Close(); state.Ready.Set(); }))
|
||||
{
|
||||
uint disabled = 0x1 | 0x2 | 0x4; // disable cookies, redirects, automatic authentication
|
||||
state.Invoke(() => WinHttpSetOption(state.Handle, 63, ref disabled, 4));
|
||||
int timeout = checked(timeoutSeconds * 1000);
|
||||
state.Invoke(() => WinHttpSetTimeouts(state.Handle, timeout, timeout, timeout, timeout));
|
||||
string headers = "Accept: application/json\r\n";
|
||||
if (body != null && !HasContentType(requestHeaders)) headers += "Content-Type: application/json\r\n";
|
||||
if (authorization != null) headers += "Authorization: " + authorization + "\r\n";
|
||||
if (requestHeaders != null) foreach (KeyValuePair<string, string> header in requestHeaders) headers += header.Key + ": " + header.Value + "\r\n";
|
||||
IntPtr input = IntPtr.Zero;
|
||||
if (body != null && body.Length != 0) { bodyPin = GCHandle.Alloc(body, GCHandleType.Pinned); input = bodyPin.AddrOfPinnedObject(); }
|
||||
state.Invoke(() => WinHttpSendRequest(state.Handle, headers, (uint)headers.Length, input, (uint)(body?.Length ?? 0), (uint)(body?.Length ?? 0), UIntPtr.Zero));
|
||||
state.Wait(0x400000, token);
|
||||
state.Invoke(() => WinHttpReceiveResponse(state.Handle, IntPtr.Zero));
|
||||
state.Wait(0x20000, token);
|
||||
uint status = 0, size = 4;
|
||||
state.Invoke(() => WinHttpQueryHeaders(state.Handle, 19 | 0x20000000, null, out status, ref size, IntPtr.Zero));
|
||||
Dictionary<string, string> responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
size = 0;
|
||||
lock (state.Gate)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
WinHttpQueryHeadersText(state.Handle, 22, null, IntPtr.Zero, ref size, IntPtr.Zero);
|
||||
}
|
||||
if (size > 65536) return Failure("Control response headers exceeded limit");
|
||||
if (size > 0)
|
||||
{
|
||||
int capacity = (int)size;
|
||||
IntPtr buffer = Marshal.AllocHGlobal(capacity);
|
||||
try
|
||||
{
|
||||
state.Invoke(() => WinHttpQueryHeadersText(state.Handle, 22, null, buffer, ref size, IntPtr.Zero));
|
||||
foreach (string line in (Marshal.PtrToStringUni(buffer) ?? "").Split(new[] { "\r\n" }, StringSplitOptions.None)) KeepResponseHeader(responseHeaders, line);
|
||||
}
|
||||
finally { for (int i = 0; i < capacity; i++) Marshal.WriteByte(buffer, i, 0); Marshal.FreeHGlobal(buffer); }
|
||||
}
|
||||
byte[] readBuffer = new byte[16384];
|
||||
readPin = GCHandle.Alloc(readBuffer, GCHandleType.Pinned);
|
||||
using (MemoryStream response = new MemoryStream())
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
state.Invoke(() => WinHttpReadData(state.Handle, readPin.AddrOfPinnedObject(), (uint)readBuffer.Length, IntPtr.Zero));
|
||||
state.Wait(0x80000, token);
|
||||
if (state.Read == 0) break;
|
||||
if (response.Length + state.Read > maxResponseBytes) return Failure("Control response exceeded limit");
|
||||
response.Write(readBuffer, 0, (int)state.Read);
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
byte[] data = response.ToArray();
|
||||
reusable = true;
|
||||
return new Response { StatusCode = (int)status, Data = data, Body = Encoding.UTF8.GetString(data), Headers = responseHeaders, RefreshInvalid = IsRefreshInvalid(responseHeaders) };
|
||||
}
|
||||
finally { if (response.TryGetBuffer(out ArraySegment<byte> used)) Array.Clear(used.Array, used.Offset, used.Count); }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception error)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new Response { Error = "WinHTTP error " + error.NativeErrorCode, NativeErrorCode = error.NativeErrorCode, FailureKind = "Transport" };
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (state.Handle != IntPtr.Zero)
|
||||
{
|
||||
state.Close();
|
||||
if (callbackInstalled) state.Closed.WaitOne();
|
||||
lock (PoolLock)
|
||||
if (WindowsRequests.TryGetValue(state.Handle, out WindowsRequest current) && ReferenceEquals(current, state))
|
||||
WindowsRequests.Remove(state.Handle);
|
||||
}
|
||||
if (bodyPin.IsAllocated) bodyPin.Free();
|
||||
if (readPin.IsAllocated) { byte[] buffer = (byte[])readPin.Target; Array.Clear(buffer, 0, buffer.Length); readPin.Free(); }
|
||||
state.Ready.Dispose(); state.Closed.Dispose();
|
||||
ReleaseWindows(pool, reusable);
|
||||
}
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Winapi)] private delegate void WinHttpCallback(IntPtr handle, UIntPtr context, uint status, IntPtr information, uint length);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] private static extern IntPtr WinHttpSetStatusCallback(IntPtr handle, WinHttpCallback callback, uint flags, UIntPtr reserved);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr WinHttpOpen(string agent, uint access, string proxy, string bypass, uint flags);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr WinHttpConnect(IntPtr session, string server, ushort port, uint reserved);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr WinHttpOpenRequest(IntPtr connection, string verb, string path, string version, string referer, IntPtr acceptTypes, uint flags);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool WinHttpSetTimeouts(IntPtr handle, int resolve, int connect, int send, int receive);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool WinHttpSetOption(IntPtr handle, uint option, ref uint value, uint size);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool WinHttpSendRequest(IntPtr request, string headers, uint headersLength, IntPtr body, uint bodyLength, uint totalLength, UIntPtr context);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool WinHttpReceiveResponse(IntPtr request, IntPtr reserved);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool WinHttpQueryHeaders(IntPtr request, uint info, string name, out uint value, ref uint size, IntPtr index);
|
||||
[DllImport("winhttp.dll", EntryPoint = "WinHttpQueryHeaders", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool WinHttpQueryHeadersText(IntPtr request, uint info, string name, IntPtr value, ref uint size, IntPtr index);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool WinHttpReadData(IntPtr request, IntPtr buffer, uint capacity, IntPtr read);
|
||||
[DllImport("winhttp.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool WinHttpCloseHandle(IntPtr handle);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ using System.Threading.Tasks;
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
// Own-origin control requests use OS TLS without Mono/Unity's TLS implementation.
|
||||
internal static class PlatformControlHttp
|
||||
internal static partial class PlatformControlHttp
|
||||
{
|
||||
public static bool IsSupported => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ||
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||
@@ -23,16 +23,68 @@ internal static class PlatformControlHttp
|
||||
public byte[] Data;
|
||||
public IReadOnlyDictionary<string, string> Headers;
|
||||
public bool RefreshInvalid;
|
||||
public long ElapsedMilliseconds;
|
||||
public long RequestId;
|
||||
public long QueueMilliseconds;
|
||||
public long TransportMilliseconds;
|
||||
public string FailureKind;
|
||||
public int NativeErrorCode;
|
||||
public bool Success => Error == null && StatusCode >= 200 && StatusCode < 300;
|
||||
}
|
||||
|
||||
public static Task<Response> Send(Uri uri, string method, byte[] body, string authorization,
|
||||
private static readonly SemaphoreSlim NativeSlots = new SemaphoreSlim(16, 16);
|
||||
public static async Task<Response> Send(Uri uri, string method, byte[] body, string authorization,
|
||||
int timeoutSeconds, CancellationToken lifetime, CancellationToken generation, int maxResponseBytes = 16 * 1024,
|
||||
IReadOnlyDictionary<string, string> requestHeaders = null)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
System.Diagnostics.Stopwatch elapsed = System.Diagnostics.Stopwatch.StartNew();
|
||||
long requestId = Interlocked.Increment(ref nextRequestId);
|
||||
long admittedAt = -1;
|
||||
Response completed = null;
|
||||
try
|
||||
{
|
||||
using (CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime, generation))
|
||||
if (timeoutSeconds <= 0 || timeoutSeconds > 60) return completed = Failure("Invalid control request");
|
||||
using (CancellationTokenSource admission = CancellationTokenSource.CreateLinkedTokenSource(lifetime, generation, ShutdownCancellation.Token))
|
||||
{
|
||||
admission.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
try { await NativeSlots.WaitAsync(admission.Token).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
bool owner = lifetime.IsCancellationRequested || generation.IsCancellationRequested || ShutdownCancellation.IsCancellationRequested;
|
||||
return completed = new Response { Error = owner ? "Native request owner canceled" : "Native request deadline exceeded", FailureKind = owner ? "OwnerCanceled" : "DeadlineExceeded" };
|
||||
}
|
||||
admittedAt = elapsed.ElapsedMilliseconds;
|
||||
try
|
||||
{
|
||||
// At most sixteen dedicated workers exist. Admission awaits without
|
||||
// occupying any worker, and native event waits never starve timer/UI
|
||||
// or relay tasks sharing the managed thread pool.
|
||||
return completed = await SendWorker(uri, method, body, authorization, timeoutSeconds, lifetime, generation, maxResponseBytes, requestHeaders, elapsed).ConfigureAwait(false);
|
||||
}
|
||||
finally { NativeSlots.Release(); }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The complete native transaction includes server wait; it is not a bandwidth measurement.
|
||||
if (completed != null)
|
||||
{
|
||||
completed.RequestId = requestId;
|
||||
completed.ElapsedMilliseconds = elapsed.ElapsedMilliseconds;
|
||||
completed.QueueMilliseconds = admittedAt < 0 ? completed.ElapsedMilliseconds : admittedAt;
|
||||
completed.TransportMilliseconds = admittedAt < 0 ? 0 : completed.ElapsedMilliseconds - admittedAt;
|
||||
LogCompletion(uri, method, completed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<Response> SendWorker(Uri uri, string method, byte[] body, string authorization,
|
||||
int timeoutSeconds, CancellationToken lifetime, CancellationToken generation, int maxResponseBytes,
|
||||
IReadOnlyDictionary<string, string> requestHeaders, System.Diagnostics.Stopwatch elapsed)
|
||||
{
|
||||
return Task.Factory.StartNew(() =>
|
||||
{
|
||||
using (CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime, generation, ShutdownCancellation.Token))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -43,154 +95,50 @@ internal static class PlatformControlHttp
|
||||
maxResponseBytes <= 0 || maxResponseBytes > 64 * 1024 * 1024 ||
|
||||
(body != null && body.Length > 64 * 1024 * 1024) ||
|
||||
(authorization != null && (authorization.Length > 16384 || authorization.IndexOf('\r') >= 0 || authorization.IndexOf('\n') >= 0)))
|
||||
return Failure("Invalid control request");
|
||||
cancellation.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
return Timed(Failure("Invalid control request"), elapsed);
|
||||
long budget = timeoutSeconds * 1000L - elapsed.ElapsedMilliseconds;
|
||||
if (budget <= 0) throw new OperationCanceledException();
|
||||
cancellation.CancelAfter(TimeSpan.FromMilliseconds(budget));
|
||||
cancellation.Token.ThrowIfCancellationRequested();
|
||||
if (requestHeaders != null)
|
||||
{
|
||||
if (requestHeaders.Count > 64) return Failure("Invalid control request headers");
|
||||
if (requestHeaders.Count > 64) return Timed(Failure("Invalid control request headers"), elapsed);
|
||||
int headerSize = 0;
|
||||
foreach (KeyValuePair<string, string> header in requestHeaders)
|
||||
{
|
||||
if (!AllowedRequestHeader(header.Key) || header.Key.Length > 256 || !ValidHeaderName(header.Key) || header.Value == null || header.Value.Length > 16384 ||
|
||||
header.Value.IndexOf('\r') >= 0 || header.Value.IndexOf('\n') >= 0)
|
||||
return Failure("Invalid control request headers");
|
||||
return Timed(Failure("Invalid control request headers"), elapsed);
|
||||
headerSize += header.Key.Length + header.Value.Length;
|
||||
if (headerSize > 65536) return Failure("Invalid control request headers");
|
||||
if (headerSize > 65536) return Timed(Failure("Invalid control request headers"), elapsed);
|
||||
}
|
||||
}
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return SendWindows(uri, method, body, authorization, timeoutSeconds, cancellation.Token, maxResponseBytes, requestHeaders);
|
||||
return Timed(SendWindows(uri, method, body, authorization, timeoutSeconds, cancellation.Token, maxResponseBytes, requestHeaders), elapsed);
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return SendMac(uri, method, body, authorization, timeoutSeconds, cancellation.Token, maxResponseBytes, requestHeaders);
|
||||
return Failure("Native control transport is unsupported");
|
||||
return Timed(SendMac(uri, method, body, authorization, timeoutSeconds, cancellation.Token, maxResponseBytes, requestHeaders, elapsed), elapsed);
|
||||
return Timed(Failure("Native control transport is unsupported"), elapsed);
|
||||
}
|
||||
catch (OperationCanceledException) { return Failure("Control request canceled"); }
|
||||
catch (Exception exception) { return Failure("Native control transport failed: " + exception.GetType().Name); }
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
bool owner = lifetime.IsCancellationRequested || generation.IsCancellationRequested || ShutdownCancellation.IsCancellationRequested;
|
||||
return Timed(new Response { Error = owner ? "Native request owner canceled" : "Native request deadline exceeded", FailureKind = owner ? "OwnerCanceled" : "DeadlineExceeded" }, elapsed);
|
||||
}
|
||||
catch (NativeProxyConfigurationException exception) { return Timed(new Response { Error = exception.Message, FailureKind = "ProxyConfiguration" }, elapsed); }
|
||||
catch (Exception exception) { return Timed(Failure("Native control transport failed: " + exception.GetType().Name), elapsed); }
|
||||
}
|
||||
});
|
||||
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
private static Response Failure(string error) => new Response { Error = error };
|
||||
|
||||
private static Response SendWindows(Uri uri, string method, byte[] body, string authorization,
|
||||
int timeoutSeconds, CancellationToken token, int maxResponseBytes, IReadOnlyDictionary<string, string> requestHeaders)
|
||||
{
|
||||
IntPtr session = IntPtr.Zero, connection = IntPtr.Zero, request = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
// Owned endpoints connect directly; browser OAuth keeps its own proxy settings.
|
||||
session = WinHttpOpen("BD2LoginUI", 1, null, null, 0); // WINHTTP_ACCESS_TYPE_NO_PROXY
|
||||
if (session == IntPtr.Zero) return WindowsFailure();
|
||||
int timeout = checked(timeoutSeconds * 1000);
|
||||
if (!WinHttpSetTimeouts(session, timeout, timeout, timeout, timeout)) return WindowsFailure();
|
||||
connection = WinHttpConnect(session, uri.IdnHost, (ushort)uri.Port, 0);
|
||||
if (connection == IntPtr.Zero) return WindowsFailure();
|
||||
request = WinHttpOpenRequest(connection, method, uri.PathAndQuery, null, null, IntPtr.Zero,
|
||||
uri.Scheme == Uri.UriSchemeHttps ? 0x00800000u : 0u);
|
||||
if (request == IntPtr.Zero) return WindowsFailure();
|
||||
uint disabled = 0x2 | 0x4; // WINHTTP_DISABLE_COOKIES | WINHTTP_DISABLE_REDIRECTS
|
||||
if (!WinHttpSetOption(request, 63, ref disabled, 4)) return WindowsFailure();
|
||||
string headers = "Accept: application/json\r\n";
|
||||
if (body != null && !HasContentType(requestHeaders)) headers += "Content-Type: application/json\r\n";
|
||||
if (authorization != null) headers += "Authorization: " + authorization + "\r\n";
|
||||
if (requestHeaders != null)
|
||||
foreach (KeyValuePair<string, string> header in requestHeaders) headers += header.Key + ": " + header.Value + "\r\n";
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Calls have native timeouts; never close a handle concurrently with a P/Invoke call.
|
||||
if (!WinHttpSendRequest(request, headers, (uint)headers.Length, body,
|
||||
(uint)(body?.Length ?? 0), (uint)(body?.Length ?? 0), UIntPtr.Zero)) return WindowsFailure();
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!WinHttpReceiveResponse(request, IntPtr.Zero)) return WindowsFailure();
|
||||
uint status, size = 4;
|
||||
if (!WinHttpQueryHeaders(request, 19 | 0x20000000, null, out status, ref size, IntPtr.Zero)) return WindowsFailure();
|
||||
Dictionary<string, string> responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
size = 0;
|
||||
WinHttpQueryHeadersText(request, 22, null, IntPtr.Zero, ref size, IntPtr.Zero);
|
||||
if (size > 64 * 1024) return Failure("Control response headers exceeded limit");
|
||||
if (size > 0 && size <= 64 * 1024)
|
||||
{
|
||||
int headerCapacity = (int)size;
|
||||
IntPtr headerBuffer = Marshal.AllocHGlobal((int)size);
|
||||
try
|
||||
{
|
||||
if (WinHttpQueryHeadersText(request, 22, null, headerBuffer, ref size, IntPtr.Zero))
|
||||
foreach (string line in (Marshal.PtrToStringUni(headerBuffer) ?? string.Empty).Split(new[] { "\r\n" }, StringSplitOptions.None))
|
||||
KeepResponseHeader(responseHeaders, line);
|
||||
}
|
||||
finally
|
||||
{
|
||||
for (int index = 0; index < headerCapacity; index++) Marshal.WriteByte(headerBuffer, index, 0);
|
||||
Marshal.FreeHGlobal(headerBuffer);
|
||||
}
|
||||
}
|
||||
using (MemoryStream response = new MemoryStream())
|
||||
{
|
||||
byte[] buffer = new byte[4096];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
uint read;
|
||||
if (!WinHttpReadData(request, buffer, (uint)buffer.Length, out read)) return WindowsFailure();
|
||||
if (read == 0) break;
|
||||
if (response.Length + read > maxResponseBytes) return Failure("Control response exceeded limit");
|
||||
response.Write(buffer, 0, (int)read);
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
byte[] data = response.ToArray();
|
||||
Array.Clear(response.GetBuffer(), 0, (int)response.Length);
|
||||
Array.Clear(buffer, 0, buffer.Length);
|
||||
return new Response { StatusCode = (int)status, Data = data, Headers = responseHeaders, Body = Encoding.UTF8.GetString(data),
|
||||
RefreshInvalid = IsRefreshInvalid(responseHeaders) };
|
||||
}
|
||||
finally
|
||||
{
|
||||
Array.Clear(buffer, 0, buffer.Length);
|
||||
if (response.TryGetBuffer(out ArraySegment<byte> used)) Array.Clear(used.Array, used.Offset, used.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (request != IntPtr.Zero) WinHttpCloseHandle(request);
|
||||
if (connection != IntPtr.Zero) WinHttpCloseHandle(connection);
|
||||
if (session != IntPtr.Zero) WinHttpCloseHandle(session);
|
||||
}
|
||||
}
|
||||
|
||||
private static Response WindowsFailure() => Failure("WinHTTP error " + Marshal.GetLastWin32Error());
|
||||
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern IntPtr WinHttpOpen(string agent, uint access, string proxy, string bypass, uint flags);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern IntPtr WinHttpConnect(IntPtr session, string server, ushort port, uint reserved);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern IntPtr WinHttpOpenRequest(IntPtr connection, string verb, string path, string version,
|
||||
string referer, IntPtr acceptTypes, uint flags);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpSetTimeouts(IntPtr handle, int resolve, int connect, int send, int receive);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpSetOption(IntPtr handle, uint option, ref uint value, uint size);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpSendRequest(IntPtr request, string headers, uint headersLength, byte[] body,
|
||||
uint bodyLength, uint totalLength, UIntPtr context);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpReceiveResponse(IntPtr request, IntPtr reserved);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpQueryHeaders(IntPtr request, uint info, string name, out uint value, ref uint size, IntPtr index);
|
||||
[DllImport("winhttp.dll", EntryPoint = "WinHttpQueryHeaders", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpQueryHeadersText(IntPtr request, uint info, string name, IntPtr value, ref uint size, IntPtr index);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpReadData(IntPtr request, [Out] byte[] buffer, uint capacity, out uint read);
|
||||
[DllImport("winhttp.dll")] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpCloseHandle(IntPtr handle);
|
||||
private static Response Timed(Response response, System.Diagnostics.Stopwatch elapsed) { response.ElapsedMilliseconds = elapsed.ElapsedMilliseconds; return response; }
|
||||
private static Response Failure(string error) => new Response { Error = error, FailureKind = "Transport" };
|
||||
|
||||
private static Response SendMac(Uri uri, string method, byte[] body, string authorization,
|
||||
int timeoutSeconds, CancellationToken token, int maxResponseBytes, IReadOnlyDictionary<string, string> requestHeaders)
|
||||
int timeoutSeconds, CancellationToken token, int maxResponseBytes, IReadOnlyDictionary<string, string> requestHeaders, System.Diagnostics.Stopwatch elapsed)
|
||||
{
|
||||
IntPtr curl = IntPtr.Zero, headers = IntPtr.Zero, pinnedBody = IntPtr.Zero;
|
||||
CurlPool pool = null;
|
||||
bool reusable = false;
|
||||
using (MemoryStream response = new MemoryStream())
|
||||
{
|
||||
bool exceeded = false;
|
||||
@@ -204,8 +152,8 @@ internal static class PlatformControlHttp
|
||||
if (token.IsCancellationRequested || length > (ulong)maxResponseBytes || response.Length + (long)length > maxResponseBytes)
|
||||
{ exceeded = !token.IsCancellationRequested; return UIntPtr.Zero; }
|
||||
byte[] bytes = new byte[(int)length];
|
||||
Marshal.Copy(data, bytes, 0, bytes.Length);
|
||||
response.Write(bytes, 0, bytes.Length);
|
||||
try { Marshal.Copy(data, bytes, 0, bytes.Length); response.Write(bytes, 0, bytes.Length); }
|
||||
finally { Array.Clear(bytes, 0, bytes.Length); }
|
||||
return new UIntPtr(length);
|
||||
}
|
||||
catch { return UIntPtr.Zero; }
|
||||
@@ -220,8 +168,8 @@ internal static class PlatformControlHttp
|
||||
return UIntPtr.Zero;
|
||||
headerBytes += (int)length;
|
||||
byte[] bytes = new byte[(int)length];
|
||||
Marshal.Copy(data, bytes, 0, bytes.Length);
|
||||
KeepResponseHeader(responseHeaders, Encoding.UTF8.GetString(bytes).TrimEnd('\r', '\n'));
|
||||
try { Marshal.Copy(data, bytes, 0, bytes.Length); KeepResponseHeader(responseHeaders, Encoding.UTF8.GetString(bytes).TrimEnd('\r', '\n')); }
|
||||
finally { Array.Clear(bytes, 0, bytes.Length); }
|
||||
return new UIntPtr(length);
|
||||
}
|
||||
catch { return UIntPtr.Zero; }
|
||||
@@ -230,7 +178,12 @@ internal static class PlatformControlHttp
|
||||
{
|
||||
// System libcurl is built with the OS TLS backend and its default trust store.
|
||||
if (!EnsureCurlInitialized()) return Failure("Could not initialize system curl");
|
||||
curl = curl_easy_init();
|
||||
NativeProxySettings proxy = NativeProxyPolicy.Resolve(uri);
|
||||
token.ThrowIfCancellationRequested();
|
||||
curl = AcquireCurl(uri, proxy, token, out pool);
|
||||
curl_easy_reset(curl);
|
||||
SetCurl(curl, 10004, proxy.ProxyUrl ?? string.Empty); // explicitly suppress environment proxies for direct routes
|
||||
SetCurl(curl, 10177, string.Empty); // policy already applied destination bypass
|
||||
if (curl == IntPtr.Zero) return Failure("Could not create system curl request");
|
||||
SetCurl(curl, 10002, uri.AbsoluteUri);
|
||||
SetCurl(curl, 10036, method);
|
||||
@@ -238,8 +191,10 @@ internal static class PlatformControlHttp
|
||||
SetCurl(curl, 64, 1L); // verify peer chain
|
||||
SetCurl(curl, 81, 2L); // verify hostname
|
||||
SetCurl(curl, 99, 1L); // no signals on worker threads
|
||||
SetCurl(curl, 155, checked(timeoutSeconds * 1000L));
|
||||
SetCurl(curl, 156, checked(timeoutSeconds * 1000L));
|
||||
long remaining = timeoutSeconds * 1000L - elapsed.ElapsedMilliseconds;
|
||||
if (remaining <= 0) throw new OperationCanceledException();
|
||||
SetCurl(curl, 155, remaining);
|
||||
SetCurl(curl, 156, remaining);
|
||||
SetCurl(curl, 181, uri.Scheme == Uri.UriSchemeHttps ? 2L : 3L);
|
||||
SetCurl(curl, 182, 2L); // redirects are disabled
|
||||
SetCurl(curl, 10018, "BD2LoginUI");
|
||||
@@ -266,20 +221,23 @@ internal static class PlatformControlHttp
|
||||
GC.KeepAlive(progress);
|
||||
GC.KeepAlive(header);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (result != 0) return Failure(exceeded ? "Control response exceeded limit" : "System curl error " + result);
|
||||
if (result != 0) return new Response { Error = exceeded ? "Control response exceeded limit" : "System curl error " + result, NativeErrorCode = result, FailureKind = "Transport" };
|
||||
long status;
|
||||
int infoResult = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
|
||||
? curl_easy_getinfo_arm64(curl, 0x200002, 0, 0, 0, 0, 0, 0, out status)
|
||||
: curl_easy_getinfo(curl, 0x200002, out status);
|
||||
if (infoResult != 0) return Failure("Could not read system curl status");
|
||||
byte[] data = response.ToArray();
|
||||
reusable = true;
|
||||
Array.Clear(response.GetBuffer(), 0, (int)response.Length);
|
||||
return new Response { StatusCode = (int)status, Data = data, Headers = responseHeaders, Body = Encoding.UTF8.GetString(data),
|
||||
RefreshInvalid = IsRefreshInvalid(responseHeaders) };
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (curl != IntPtr.Zero) curl_easy_cleanup(curl);
|
||||
// Reset while callbacks and body/header storage still exist. Reset keeps
|
||||
// only libcurl connection/DNS caches, clearing request secrets and state.
|
||||
if (curl != IntPtr.Zero) { curl_easy_reset(curl); ReleaseCurl(pool, curl, reusable); }
|
||||
if (headers != IntPtr.Zero) curl_slist_free_all(headers);
|
||||
if (pinnedBody != IntPtr.Zero)
|
||||
{
|
||||
@@ -378,6 +336,7 @@ internal static class PlatformControlHttp
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern int curl_global_init(long flags);
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern IntPtr curl_easy_init();
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern void curl_easy_cleanup(IntPtr curl);
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern void curl_easy_reset(IntPtr curl);
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_perform(IntPtr curl);
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_getinfo(IntPtr curl, int info, out long value);
|
||||
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_setopt_long(IntPtr curl, int option, long value);
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
private void OnApplicationQuit()
|
||||
{
|
||||
DisposeGameRelay();
|
||||
PlatformControlHttp.Shutdown();
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
@@ -31,10 +32,15 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
try
|
||||
{
|
||||
Log = Logger;
|
||||
PlatformControlHttp.SetCompletionLogger(message => Logger.LogInfo(message));
|
||||
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("UNITY_PROXYSERVER")) &&
|
||||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("UNITY_NOPROXY")))
|
||||
Logger.LogInfo("Game loopback transport uses launch-time Unity proxy bypass");
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => DisposeGameRelay();
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) =>
|
||||
{
|
||||
DisposeGameRelay();
|
||||
PlatformControlHttp.Shutdown();
|
||||
};
|
||||
Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message));
|
||||
EnsureRecoveryHost();
|
||||
InitializeCredentials();
|
||||
|
||||
@@ -139,17 +139,30 @@ internal sealed class SecureGameRelay : IDisposable
|
||||
if (count <= 0) throw new IOException("Incomplete relay body");
|
||||
offset += count;
|
||||
}
|
||||
response = await PlatformControlHttp.Send(remote, start[0], body, null, 4, lifetime.Token,
|
||||
// The native hard deadline leaves margin inside Unity's 30s timeout.
|
||||
response = await PlatformControlHttp.Send(remote, start[0], body, null, 25, lifetime.Token,
|
||||
lifetime.Token, 64 * 1024 * 1024, headers).ConfigureAwait(false);
|
||||
if (maintenance || response.StatusCode == 0)
|
||||
log?.LogInfo("Game relay upstream result: path=" + remote.AbsolutePath + " status=" + response.StatusCode +
|
||||
" error=" + (response.Error ?? "none"));
|
||||
if (response.StatusCode == 0 || response.Data == null) throw new IOException("Native game transport unavailable");
|
||||
log?.LogInfo("Game relay upstream result: request_id=" + response.RequestId + " status=" + response.StatusCode +
|
||||
" failure=" + (response.FailureKind ?? "none"));
|
||||
if (response.StatusCode == 0 || response.Data == null)
|
||||
{
|
||||
// Return a complete, recognizable local failure. The game sends all
|
||||
// non-Success results through its own same-packet backoff; recovery
|
||||
// distinguishes this marker from actual upstream HTTP errors.
|
||||
byte[] failureBody = Encoding.UTF8.GetBytes("Native game transport unavailable.\n");
|
||||
byte[] failure = Encoding.ASCII.GetBytes("HTTP/1.1 502 Native Transport Failure\r\nConnection: close\r\nCache-Control: no-store\r\nX-BD2-Transport-Failure: 1\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: " +
|
||||
failureBody.Length.ToString(CultureInfo.InvariantCulture) + "\r\n\r\n");
|
||||
stream.Write(failure, 0, failure.Length);
|
||||
stream.Write(failureBody, 0, failureBody.Length);
|
||||
return;
|
||||
}
|
||||
StringBuilder output = new StringBuilder("HTTP/1.1 ").Append(response.StatusCode.ToString(CultureInfo.InvariantCulture)).Append(" Response\r\nConnection: close\r\nCache-Control: no-store\r\n");
|
||||
foreach (KeyValuePair<string, string> item in response.Headers)
|
||||
{
|
||||
if ((item.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || item.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
item.Key.StartsWith("X-BD2-", StringComparison.OrdinalIgnoreCase)) && item.Value.IndexOfAny(new[] { '\r', '\n' }) < 0)
|
||||
(item.Key.StartsWith("X-BD2-", StringComparison.OrdinalIgnoreCase) &&
|
||||
!item.Key.Equals("X-BD2-Transport-Failure", StringComparison.OrdinalIgnoreCase))) && item.Value.IndexOfAny(new[] { '\r', '\n' }) < 0)
|
||||
output.Append(item.Key).Append(": ").Append(item.Value).Append("\r\n");
|
||||
}
|
||||
output.Append("Content-Length: ").Append(response.Data.Length.ToString(CultureInfo.InvariantCulture)).Append("\r\n\r\n");
|
||||
|
||||
@@ -31,6 +31,10 @@ internal static class SessionRecovery
|
||||
private static int RecoveryEnterScheduled;
|
||||
internal static int RecoveryGeneration;
|
||||
internal static int RuntimeProbeFailures;
|
||||
private static float LastGameSuccess;
|
||||
private static float LastGameTransportFailure;
|
||||
private static float RuntimeProbeFailureSince;
|
||||
private static bool RuntimeProbeDegraded;
|
||||
internal static CancellationTokenSource ControlProbeCancellation = new CancellationTokenSource();
|
||||
|
||||
internal static void DisposeGameRelay()
|
||||
@@ -128,6 +132,7 @@ internal static class SessionRecovery
|
||||
}
|
||||
if (GameRelay != null)
|
||||
{
|
||||
__instance.timeout = 30;
|
||||
__instance.url = GameRelay.Rewrite(canonical).AbsoluteUri;
|
||||
}
|
||||
}
|
||||
@@ -150,6 +155,10 @@ internal static class SessionRecovery
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (request.result == UnityWebRequest.Result.Success && request.responseCode >= 200 && request.responseCode < 300)
|
||||
LastGameSuccess = Time.realtimeSinceStartup;
|
||||
else if (IsGameTransportFailure(request))
|
||||
LastGameTransportFailure = Time.realtimeSinceStartup;
|
||||
if (requestUri.AbsolutePath.Equals("/game/LoginUser", StringComparison.Ordinal) &&
|
||||
request.responseCode >= 200 && request.responseCode < 300)
|
||||
{
|
||||
@@ -262,11 +271,17 @@ internal static class SessionRecovery
|
||||
{
|
||||
return true;
|
||||
}
|
||||
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
object failedPacket = __0.GetType().GetGameProperty("PacketData", flags)?.GetValue(__0, null);
|
||||
object retryValue = failedPacket?.GetType().GetGameProperty("RetryCount", flags)?.GetValue(failedPacket, null);
|
||||
// Preserve the game's same-packet backoff for two transient failures;
|
||||
// never replay a mutation independently in the native transport.
|
||||
if (retryValue is int retries && retries < 2) return true;
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
|
||||
{
|
||||
return !BeginSessionRecovery("transport failure during recovery");
|
||||
return !BeginSessionRecovery("transport failure during recovery after client retries");
|
||||
}
|
||||
return !EstablishedGameSession || !BeginSessionRecovery("transport failure");
|
||||
return !EstablishedGameSession || !BeginSessionRecovery("transport failure after client retries");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -299,11 +314,17 @@ internal static class SessionRecovery
|
||||
}
|
||||
match = request;
|
||||
}
|
||||
// HTTP errors (including 501) are ProtocolError, even though the game calls
|
||||
// ExponetialBackOff for every non-Success result. Do not infer status from error text.
|
||||
return match != null && match.result == UnityWebRequest.Result.ConnectionError;
|
||||
if (match == null || !IsGameTransportFailure(match)) return false;
|
||||
LastGameTransportFailure = Time.realtimeSinceStartup;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsGameTransportFailure(UnityWebRequest request) =>
|
||||
request.result == UnityWebRequest.Result.ConnectionError ||
|
||||
(request.result == UnityWebRequest.Result.ProtocolError && request.responseCode == 502 &&
|
||||
GameRelay != null && GameRelay.TryResolve(new Uri(request.url), out _) &&
|
||||
string.Equals(request.GetResponseHeader("X-BD2-Transport-Failure"), "1", StringComparison.Ordinal));
|
||||
|
||||
private static bool IsConfiguredServerFailure(object packetException)
|
||||
{
|
||||
if (packetException == null || ServerRoot == null)
|
||||
@@ -437,45 +458,71 @@ internal static class SessionRecovery
|
||||
lifetime, ControlProbeCancellation.Token, response => result = response);
|
||||
if (lifetime.IsCancellationRequested) yield break;
|
||||
if (result == null || generation != Volatile.Read(ref RecoveryGeneration) || Volatile.Read(ref SessionRecoveryInProgress) != 0) continue;
|
||||
if (!result.Success)
|
||||
RuntimeStatus status = null;
|
||||
if (result.Success && result.StatusCode == 200)
|
||||
{
|
||||
Log?.LogWarning("Server runtime probe failed: " + result.Error);
|
||||
RuntimeProbeFailures++;
|
||||
if (RuntimeProbeFailures >= 3)
|
||||
try { status = JsonUtility.FromJson<RuntimeStatus>(result.Body); }
|
||||
catch (Exception) { }
|
||||
}
|
||||
bool valid = status != null && !string.IsNullOrWhiteSpace(status.instance_id) &&
|
||||
(status.status == "ready" || status.status == "draining");
|
||||
if (!valid)
|
||||
{
|
||||
if (!IsUnavailableProbe(result))
|
||||
{
|
||||
BeginSessionRecovery("runtime probe failed three consecutive times");
|
||||
// Malformed/unsupported control responses degrade monitoring;
|
||||
// they do not prove the game session disappeared.
|
||||
RuntimeProbeFailures = 0;
|
||||
RuntimeProbeFailureSince = 0;
|
||||
if (!RuntimeProbeDegraded)
|
||||
Log?.LogWarning("Server runtime monitoring degraded: " + (result.Error ?? "InvalidRuntimeStatus"));
|
||||
RuntimeProbeDegraded = true;
|
||||
yield return interval;
|
||||
continue;
|
||||
}
|
||||
float now = Time.realtimeSinceStartup;
|
||||
if (RuntimeProbeFailures == 0) RuntimeProbeFailureSince = now;
|
||||
RuntimeProbeFailures++;
|
||||
Log?.LogWarning("Server runtime probe failed: " + (result.Error ?? "InvalidRuntimeStatus"));
|
||||
// Control-path hiccups alone must not interrupt active gameplay.
|
||||
// A quiet session still recovers after a sustained outage, so a
|
||||
// dead server cannot leave the client idle forever.
|
||||
bool recentSuccess = LastGameSuccess > 0 && now - LastGameSuccess < 30f;
|
||||
bool recentFailure = LastGameTransportFailure > LastGameSuccess && now - LastGameTransportFailure < 60f;
|
||||
if (RuntimeProbeFailures >= 3 && !recentSuccess)
|
||||
{
|
||||
if (recentFailure)
|
||||
BeginSessionRecovery("runtime probes and game transport unavailable");
|
||||
else if (now - RuntimeProbeFailureSince >= 90f && now - LastGameSuccess >= 90f)
|
||||
{
|
||||
ControlProbeResult readiness = null;
|
||||
yield return RequestControlEndpoint(new Uri(ServerRoot, "readyz"), UnityWebRequest.kHttpVerbGET,
|
||||
null, null, 6, lifetime, ControlProbeCancellation.Token, response => readiness = response);
|
||||
if (lifetime.IsCancellationRequested) yield break;
|
||||
if (generation != Volatile.Read(ref RecoveryGeneration)) continue;
|
||||
if (readiness != null && IsUnavailableProbe(readiness) && Time.realtimeSinceStartup - LastGameSuccess >= 90f)
|
||||
BeginSessionRecovery("runtime and readiness unavailable during idle session");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
RuntimeProbeFailures = 0;
|
||||
RuntimeStatus status = null;
|
||||
try
|
||||
{
|
||||
status = JsonUtility.FromJson<RuntimeStatus>(result.Body);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("Server runtime response was invalid: " + ex.Message);
|
||||
}
|
||||
if (status != null && !string.IsNullOrEmpty(status.instance_id))
|
||||
{
|
||||
if (ServerInstanceID == null)
|
||||
{
|
||||
ServerInstanceID = status.instance_id;
|
||||
}
|
||||
else if (ServerInstanceID != status.instance_id || status.status == "draining")
|
||||
{
|
||||
ServerInstanceID = status.instance_id;
|
||||
BeginSessionRecovery(status.status == "draining" ? "server draining" : "server instance changed");
|
||||
}
|
||||
}
|
||||
RuntimeProbeFailureSince = 0;
|
||||
RuntimeProbeDegraded = false;
|
||||
bool changed = ServerInstanceID != null && ServerInstanceID != status.instance_id;
|
||||
ServerInstanceID = status.instance_id;
|
||||
if (changed || status.status == "draining")
|
||||
BeginSessionRecovery(status.status == "draining" ? "server draining" : "server instance changed");
|
||||
}
|
||||
}
|
||||
yield return interval;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsUnavailableProbe(ControlProbeResult result) =>
|
||||
result.StatusCode == 0 || result.StatusCode == 502 || result.StatusCode == 503 || result.StatusCode == 504;
|
||||
|
||||
internal static bool SuppressNetworkErrorDuringRecovery(object __0, object __1)
|
||||
{
|
||||
Log?.LogWarning("Client network error: type=" + __0 + ", code=" + __1 +
|
||||
@@ -514,35 +561,78 @@ internal static class SessionRecovery
|
||||
yield break;
|
||||
}
|
||||
float deadline = Time.realtimeSinceStartup + 30f;
|
||||
bool fieldLoaded = false;
|
||||
bool safeSceneLoaded = false;
|
||||
string safeScene = null;
|
||||
while (Time.realtimeSinceStartup < deadline && generation == Volatile.Read(ref RecoveryGeneration))
|
||||
{
|
||||
GameFieldManager field = FindUnitySingleton<GameFieldManager>();
|
||||
PropertyInfo loaded = field?.GetType().GetGameProperty(nameof(GameFieldManager.IsLoadedField), BindingFlags.Instance | BindingFlags.Public);
|
||||
if (loaded != null && loaded.GetValue(field) is bool ready && ready)
|
||||
bool ready;
|
||||
try
|
||||
{
|
||||
fieldLoaded = true;
|
||||
break;
|
||||
ready = TryGetRecoveredSafeScene(out safeScene);
|
||||
}
|
||||
if (IsPackCollectionActive())
|
||||
catch (Exception ex)
|
||||
{
|
||||
fieldLoaded = true;
|
||||
FinishRecovery(false, "could not inspect recovered scene: " + ex.Message, generation);
|
||||
yield break;
|
||||
}
|
||||
if (ready)
|
||||
{
|
||||
safeSceneLoaded = true;
|
||||
break;
|
||||
}
|
||||
yield return new WaitForSecondsRealtime(0.25f);
|
||||
}
|
||||
if (!fieldLoaded)
|
||||
if (!safeSceneLoaded)
|
||||
{
|
||||
if (generation == Volatile.Read(ref RecoveryGeneration))
|
||||
{
|
||||
BeginSessionRecovery("safe scene load timed out");
|
||||
// A local scene deadline is not evidence of an expired session.
|
||||
// Retain normal client error handling instead of relogging forever.
|
||||
FinishRecovery(false, "safe scene load timed out after authoritative player sync; restart the client manually", generation);
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
Log?.LogInfo("Recovered synchronized player data in safe scene: " + safeScene);
|
||||
FinishRecovery(true, null, generation);
|
||||
}
|
||||
|
||||
private static bool IsPackCollectionActive()
|
||||
private static bool TryGetRecoveredSafeScene(out string scene)
|
||||
{
|
||||
scene = null;
|
||||
if (!EstablishedGameSession || IsRecoveryUIActive("LoadingUI") || IsRecoveryUIActive("IntroUI")) return false;
|
||||
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public;
|
||||
PackManager pack = FindUnitySingleton<PackManager>();
|
||||
PropertyInfo packLoading = pack?.GetType().GetGameProperty(nameof(PackManager.IsPackLoading), flags);
|
||||
if (!(packLoading?.GetValue(pack) is bool loading) || loading) return false;
|
||||
GameFieldManager field = FindUnitySingleton<GameFieldManager>();
|
||||
PropertyInfo battle = field?.GetType().GetGameProperty(nameof(GameFieldManager.IsBattlePlay), flags);
|
||||
if (!(battle?.GetValue(field) is bool inBattle) || inBattle) return false;
|
||||
|
||||
// OpenMenuUICoroutine deliberately clears IsLoadedField and loads an
|
||||
// empty scene. EntryFlow plus the completed, active MenuUI is its ready
|
||||
// state; waiting for a loaded field would restart a healthy home menu.
|
||||
object flow = typeof(UIManager).GetGameProperty("EntryFlow", BindingFlags.Static | BindingFlags.Public)?.GetValue(null, null);
|
||||
PropertyInfo menuState = flow?.GetType().GetGameProperty("IsInMenuUI", flags);
|
||||
if (menuState?.GetValue(flow) is bool inMenu && inMenu && IsRecoveryUIReady("MenuUI"))
|
||||
{
|
||||
scene = "home menu";
|
||||
return true;
|
||||
}
|
||||
PropertyInfo loaded = field.GetType().GetGameProperty(nameof(GameFieldManager.IsLoadedField), flags);
|
||||
if (loaded?.GetValue(field) is bool ready && ready)
|
||||
{
|
||||
scene = "field";
|
||||
return true;
|
||||
}
|
||||
if (IsRecoveryUIReady("PackCollectionUI"))
|
||||
{
|
||||
scene = "pack collection";
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Component GetRecoveryUI(string name)
|
||||
{
|
||||
Type uiManager = typeof(UIManager);
|
||||
MethodInfo getUI = uiManager?.GetGameMethod(
|
||||
@@ -551,8 +641,21 @@ internal static class SessionRecovery
|
||||
null,
|
||||
new[] { typeof(string) },
|
||||
null);
|
||||
Component collection = getUI?.Invoke(null, new object[] { "PackCollectionUI" }) as Component;
|
||||
return collection != null && collection.gameObject != null && collection.gameObject.activeInHierarchy;
|
||||
return getUI?.Invoke(null, new object[] { name }) as Component;
|
||||
}
|
||||
|
||||
private static bool IsRecoveryUIActive(string name)
|
||||
{
|
||||
Component ui = GetRecoveryUI(name);
|
||||
return ui != null && ui.gameObject != null && ui.gameObject.activeInHierarchy;
|
||||
}
|
||||
|
||||
private static bool IsRecoveryUIReady(string name)
|
||||
{
|
||||
Component ui = GetRecoveryUI(name);
|
||||
if (ui == null || ui.gameObject == null || !ui.gameObject.activeInHierarchy) return false;
|
||||
PropertyInfo loaded = ui.GetType().GetGameProperty("IsLoadedUI", BindingFlags.Instance | BindingFlags.Public);
|
||||
return loaded?.GetValue(ui) is bool ready && ready;
|
||||
}
|
||||
|
||||
private static void ShowRecoveryOverlay(string message)
|
||||
|
||||
Reference in New Issue
Block a user