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:
2026-10-04 20:10:01 +08:00
parent aec053f7d2
commit e4276eb493
23 changed files with 1001 additions and 410 deletions
+8 -4
View File
@@ -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
}
+31 -77
View File
@@ -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 ""
}
+58 -31
View File
@@ -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))
}
+2 -11
View File
@@ -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()
}
+1 -1
View File
@@ -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")
}
+2 -2
View File
@@ -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
+36
View File
@@ -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
+35
View File
@@ -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")
}
}
+13 -1
View File
@@ -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 {
+41
View File
@@ -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")
}
}