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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user