diff --git a/go/internal/client/app/game_proxy.go b/go/internal/client/app/game_proxy.go new file mode 100644 index 0000000..803d026 --- /dev/null +++ b/go/internal/client/app/game_proxy.go @@ -0,0 +1,89 @@ +package app + +import ( + "net" + "net/url" + "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, "" + } + 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) + } + } + entry := "UNITY_NOPROXY=" + strings.Join(parts, ",") + if index >= 0 { + result[index] = entry + } else { + result = append(result, entry) + } + 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 + } + 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 "" +} diff --git a/go/internal/client/app/game_proxy_darwin.go b/go/internal/client/app/game_proxy_darwin.go new file mode 100644 index 0000000..59bbbb3 --- /dev/null +++ b/go/internal/client/app/game_proxy_darwin.go @@ -0,0 +1,38 @@ +//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 "" +} diff --git a/go/internal/client/app/game_proxy_test.go b/go/internal/client/app/game_proxy_test.go new file mode 100644 index 0000000..ccd3853 --- /dev/null +++ b/go/internal/client/app/game_proxy_test.go @@ -0,0 +1,44 @@ +package app + +import ( + "reflect" + "strings" + "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 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") + } + } + } +} diff --git a/go/internal/client/app/game_proxy_windows.go b/go/internal/client/app/game_proxy_windows.go new file mode 100644 index 0000000..e9d24e3 --- /dev/null +++ b/go/internal/client/app/game_proxy_windows.go @@ -0,0 +1,30 @@ +//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)) +} diff --git a/go/internal/client/app/platform_darwin.go b/go/internal/client/app/platform_darwin.go index 7f798c9..af15551 100644 --- a/go/internal/client/app/platform_darwin.go +++ b/go/internal/client/app/platform_darwin.go @@ -25,6 +25,16 @@ func launchGame(target string) error { if err := exec.Command("pgrep", "-x", "BrownDust II").Run(); err == nil { return errGameAlreadyRunning } - args := append([]string{target, "--args"}, gameLaunchArguments()...) + // 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()...) return exec.Command("open", args...).Start() } diff --git a/go/internal/client/app/platform_windows.go b/go/internal/client/app/platform_windows.go index 8288c12..0a7b192 100644 --- a/go/internal/client/app/platform_windows.go +++ b/go/internal/client/app/platform_windows.go @@ -4,6 +4,7 @@ package app import ( "fmt" + "os" "os/exec" "path/filepath" "strings" @@ -79,6 +80,7 @@ func launchGame(target string) error { } command := visibleCommand(target, gameLaunchArguments()...) command.Dir = filepath.Dir(target) + command.Env = gameProxyEnvironment(os.Environ(), systemGameProxy()) if err := command.Start(); err != nil { return err } diff --git a/plugins/LoginUI/LoginController.cs b/plugins/LoginUI/LoginController.cs index bb1614a..cbf788a 100644 --- a/plugins/LoginUI/LoginController.cs +++ b/plugins/LoginUI/LoginController.cs @@ -228,6 +228,9 @@ internal static class LoginController private static void ShowLoginPanel(object introUI) { + // The official manual-login branch cancels the 10-second startup watchdog. + // Browser authentication waits for the user and must not retain that timer. + CancelMaintenanceTimeout.Invoke(introUI, null); AccessTokens.Clear(); LoginInProgress = false; EstablishedGameSession = false; @@ -269,12 +272,15 @@ internal static class LoginController private static IEnumerator DeviceLogin(object introUI, string provider) { - string endpoint = new Uri(ServerRoot, "auth/device").AbsoluteUri; + int generation = Volatile.Read(ref RecoveryGeneration); + Uri origin = ServerRoot; + string endpoint = new Uri(origin, "auth/device").AbsoluteUri; byte[] body = Encoding.UTF8.GetBytes(JsonUtility.ToJson(new DeviceRequest { provider = provider })); { ControlProbeResult request = null; yield return AuthRequest(introUI, new Uri(endpoint), UnityWebRequest.kHttpVerbPOST, body, null, result => request = result); if (request == null) yield break; + if (!IsCurrentLogin(generation, origin)) { request.Body = null; yield break; } if (!request.Success) { LoginInProgress = false; @@ -299,24 +305,24 @@ internal static class LoginController yield break; } Application.OpenURL(start.start_url); - yield return PollDevice(introUI, start); + yield return PollDevice(introUI, start, generation, origin); } } - private static IEnumerator PollDevice(object introUI, DeviceStart start) + private static IEnumerator PollDevice(object introUI, DeviceStart start, int generation, Uri origin) { - int generation = Volatile.Read(ref RecoveryGeneration); int delay = Math.Max(1, start.poll_interval); float deadline = Time.realtimeSinceStartup + Math.Max(30, start.expires_in); - string endpoint = new Uri(ServerRoot, "auth/device/" + Uri.EscapeDataString(start.transaction_id) + "/poll").AbsoluteUri; + string endpoint = new Uri(origin, "auth/device/" + Uri.EscapeDataString(start.transaction_id) + "/poll").AbsoluteUri; while (Time.realtimeSinceStartup < deadline) { yield return new WaitForSecondsRealtime(delay); - if (generation != Volatile.Read(ref RecoveryGeneration)) yield break; + if (!IsCurrentLogin(generation, origin)) { start.device_secret = null; yield break; } { ControlProbeResult request = null; yield return AuthRequest(introUI, new Uri(endpoint), UnityWebRequest.kHttpVerbPOST, Array.Empty(), "Device " + start.device_secret, result => request = result); if (request == null) yield break; + if (!IsCurrentLogin(generation, origin)) { request.Body = null; start.device_secret = null; yield break; } if (request.StatusCode == 202) { continue; @@ -345,7 +351,7 @@ internal static class LoginController Log?.LogError("Login transaction returned incomplete credentials"); yield break; } - CompleteInteractiveLogin(introUI, result); + CompleteInteractiveLogin(introUI, result, generation, origin); yield break; } } @@ -353,8 +359,17 @@ internal static class LoginController Log?.LogError("Login transaction expired"); } - private static void CompleteInteractiveLogin(object introUI, TokenResult result) + private static bool IsCurrentLogin(int generation, Uri origin) => + generation == Volatile.Read(ref RecoveryGeneration) && origin != null && ServerRoot != null && SameOrigin(origin, ServerRoot); + + private static void CompleteInteractiveLogin(object introUI, TokenResult result, int generation, Uri origin) { + if (!IsCurrentLogin(generation, origin)) + { + result.access_token = null; + result.refresh_token = null; + return; + } if (!RefreshCredentials.IsSupported) { AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in); @@ -370,6 +385,12 @@ internal static class LoginController } Action confirmed = delegate { + if (!IsCurrentLogin(generation, origin)) + { + result.access_token = null; + result.refresh_token = null; + return; + } try { bool autoLogin = PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0; @@ -483,6 +504,8 @@ internal static class LoginController bool credentialRejected = (request.StatusCode == 401 || request.StatusCode == 409) && request.RefreshInvalid; if (credentialRejected) { + Log?.LogWarning("Automatic-login credential rejected: HTTP=" + request.StatusCode + + ", refreshInvalid=" + request.RefreshInvalid + ", classification=invalid-credential"); refreshToken = null; attemptID = null; ClearSavedLogin(); @@ -576,6 +599,7 @@ internal static class LoginController try { ContinueMaintenance = true; + Log?.LogInfo("Authenticated login is requesting maintenance information: automatic=" + automatic); SendMaintenance.Invoke(introUI, new object[] { automatic }); } finally diff --git a/plugins/LoginUI/LoginRuntime.cs b/plugins/LoginUI/LoginRuntime.cs index 5267ce8..1c15148 100644 --- a/plugins/LoginUI/LoginRuntime.cs +++ b/plugins/LoginUI/LoginRuntime.cs @@ -14,6 +14,7 @@ internal static class LoginRuntime internal static ManualLogSource Log; internal static MethodInfo SetIntroState; internal static MethodInfo SendMaintenance; + internal static MethodInfo CancelMaintenanceTimeout; internal static Uri ServerRoot; internal static MethodInfo OpenPCLoginPopup; internal static MethodInfo EnterGame; diff --git a/plugins/LoginUI/Plugin.cs b/plugins/LoginUI/Plugin.cs index eb9d2de..97234a8 100644 --- a/plugins/LoginUI/Plugin.cs +++ b/plugins/LoginUI/Plugin.cs @@ -31,6 +31,9 @@ public sealed class Plugin : BaseUnityPlugin try { Log = Logger; + 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(); Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message)); EnsureRecoveryHost(); @@ -49,6 +52,8 @@ public sealed class Plugin : BaseUnityPlugin throw new MissingMethodException("IntroUI.Awake() was not found (client version mismatch)"); } SendMaintenance = Game.Method(ui => ui.SendMaintenanceInfo(false)); + CancelMaintenanceTimeout = introUI.GetGameMethod( + "CancelMaintenanceTimeout", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, Type.EmptyTypes, null); SetIntroState = introUI.GetGameMethod("SetIntroState", BindingFlags.Instance | BindingFlags.NonPublic); EnterGame = introUI.GetGameMethod( "Enter", @@ -72,7 +77,7 @@ public sealed class Plugin : BaseUnityPlugin MethodInfo exponentialBackOff = networkManager?.GetGameMethod( "ExponetialBackOff", BindingFlags.Instance | BindingFlags.NonPublic); - if (SendMaintenance == null || SetIntroState == null || OpenPCLoginPopup == null || + if (SendMaintenance == null || CancelMaintenanceTimeout == null || CancelMaintenanceTimeout.ReturnType != typeof(void) || SetIntroState == null || OpenPCLoginPopup == null || accessTokenGetter == null || clearPCLocalData == null || disposeWebRequest == null || clientNetworkError == null || exponentialBackOff == null || EnterGame == null) { diff --git a/plugins/LoginUI/SecureGameRelay.cs b/plugins/LoginUI/SecureGameRelay.cs index 836889f..8e43c97 100644 --- a/plugins/LoginUI/SecureGameRelay.cs +++ b/plugins/LoginUI/SecureGameRelay.cs @@ -78,7 +78,7 @@ internal sealed class SecureGameRelay : IDisposable { try { using (client) await Serve(client).ConfigureAwait(false); } catch (Exception ex) when (ex is IOException || ex is SocketException || ex is OperationCanceledException || ex is InvalidDataException) - { /* Close failed transport: the game's existing ConnectionError recovery handles it. */ } + { log?.LogWarning("Native game relay connection failed: " + ex.GetType().Name); } catch (Exception ex) { log?.LogWarning("Native game transport failed: " + ex.GetType().Name); } finally { slots.Release(); } }); @@ -108,6 +108,8 @@ internal sealed class SecureGameRelay : IDisposable throw new InvalidDataException("Invalid relay request"); if (!Uri.TryCreate(local, start[1], out Uri target) || !TryResolve(target, out Uri remote)) throw new InvalidDataException("Invalid relay target"); + bool maintenance = remote.AbsolutePath.Equals("/game/MaintenanceInfo", StringComparison.Ordinal); + if (maintenance) log?.LogInfo("Game relay received MaintenanceInfo request"); Dictionary headers = new Dictionary(StringComparer.OrdinalIgnoreCase); int length = 0; bool lengthSeen = false; @@ -139,6 +141,9 @@ internal sealed class SecureGameRelay : IDisposable } response = await PlatformControlHttp.Send(remote, start[0], body, null, 4, 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"); 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 item in response.Headers) diff --git a/plugins/LoginUI/SessionRecovery.cs b/plugins/LoginUI/SessionRecovery.cs index 2939a29..12c0359 100644 --- a/plugins/LoginUI/SessionRecovery.cs +++ b/plugins/LoginUI/SessionRecovery.cs @@ -225,6 +225,39 @@ internal static class SessionRecovery { try { + try + { + const BindingFlags diagnosticFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + object packet = __0?.GetType().GetGameProperty("PacketData", diagnosticFlags)?.GetValue(__0, null); + string path = packet?.GetType().GetGameProperty("SendPath", diagnosticFlags)?.GetValue(packet, null) as string; + if (path == "MaintenanceInfo") + { + string server = packet?.GetType().GetGameField("RequestServerURL", diagnosticFlags)?.GetValue(packet) as string; + string endpoint = Uri.TryCreate(server, UriKind.Absolute, out Uri parsed) ? parsed.GetLeftPart(UriPartial.Authority) + parsed.AbsolutePath : "invalid"; + string message = __0?.GetType().GetGameProperty("Message", diagnosticFlags)?.GetValue(__0, null) as string ?? string.Empty; + string category = message.IndexOf("tim", StringComparison.OrdinalIgnoreCase) >= 0 ? "Timeout" : + message.IndexOf("cert", StringComparison.OrdinalIgnoreCase) >= 0 || message.IndexOf("TLS", StringComparison.OrdinalIgnoreCase) >= 0 || message.IndexOf("SSL", StringComparison.OrdinalIgnoreCase) >= 0 ? "TLS" : "Other"; + Log?.LogDebug("Maintenance backoff: endpoint=" + endpoint + ", category=" + category + ", observedRequests=" + GameRequests.Count); + foreach (WeakReference reference in GameRequests) + { + if (!reference.TryGetTarget(out UnityWebRequest request) || !request.isDone) continue; + if (!TryGetOwnedRequestUri(request.url, out Uri original) || original.AbsolutePath != "/game/MaintenanceInfo") continue; + string error = request.error ?? string.Empty; + string safeError = error.IndexOf("Insecure", StringComparison.OrdinalIgnoreCase) >= 0 ? "InsecureConnectionBlocked" : + error.IndexOf("connect", StringComparison.OrdinalIgnoreCase) >= 0 ? "ConnectionFailure" : + error.IndexOf("tim", StringComparison.OrdinalIgnoreCase) >= 0 ? "Timeout" : + error.IndexOf("HTTP", StringComparison.OrdinalIgnoreCase) >= 0 ? "HttpFailure" : + error.IndexOf("SSL", StringComparison.OrdinalIgnoreCase) >= 0 || error.IndexOf("cert", StringComparison.OrdinalIgnoreCase) >= 0 ? "TlsFailure" : "Unclassified"; + Log?.LogWarning("Maintenance completed transport: result=" + request.result + ", status=" + request.responseCode + + ", error=" + safeError + ", errorLength=" + error.Length); + } + } + + } + catch (Exception ex) + { + Log?.LogDebug("Maintenance transport diagnostic unavailable: " + ex.GetType().Name); + } if (ServerRoot == null || !IsConfiguredServerFailure(__0) || !IsTransportFailure(__0)) { return true; @@ -443,8 +476,10 @@ internal static class SessionRecovery } } - internal static bool SuppressNetworkErrorDuringRecovery() + internal static bool SuppressNetworkErrorDuringRecovery(object __0, object __1) { + Log?.LogWarning("Client network error: type=" + __0 + ", code=" + __1 + + ", recovering=" + (Volatile.Read(ref SessionRecoveryInProgress) != 0)); return Volatile.Read(ref SessionRecoveryInProgress) == 0; }