ProxyFunc parsed system.networkProxy once, when a client was built, and baked the result into the transport. Editing the proxy address therefore had no effect on any client that already existed: ntfy, the outgoing webhook, NapCat's HTTP API, Telegram's polling and the NapCat WebSocket dialer all kept the address they were built with, and Email's SMTP dialer did the same. Only a controller rebuild would pick up a new one, and that fires only when controllerMethod changes — so the address was effectively fixed until a restart. Resolve it inside the returned function instead. A transport proxy function that returns a nil URL asks for a direct connection, so this also covers clearing the setting: a channel built while a proxy was configured now falls back to dialing directly rather than retrying a dead address. Opting out is now the only case where ProxyFunc returns nil. That is deliberate: a function resolved to nothing at construction time is exactly what pinned the address in the first place. DialWithTimeout reads the address per dial for the same reason, which is what lets the gomail NetDialTimeout hook follow a settings change.
134 lines
4.0 KiB
Go
134 lines
4.0 KiB
Go
package netproxy
|
|
|
|
import (
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"nukumizu-backend/config"
|
|
)
|
|
|
|
// publishConfig writes a config.json and makes it the configuration in effect,
|
|
// which is what a settings update does.
|
|
func publishConfig(t *testing.T, body string) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "config.json")
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write temp config: %v", err)
|
|
}
|
|
if _, err := config.LoadGlobalConfig(path); err != nil {
|
|
t.Fatalf("LoadGlobalConfig: %v", err)
|
|
}
|
|
}
|
|
|
|
// resolve runs a proxy function and returns the URL it chose, or "" when it
|
|
// chose a direct connection.
|
|
func resolve(t *testing.T, proxy func(*http.Request) (*url.URL, error)) string {
|
|
t.Helper()
|
|
req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest: %v", err)
|
|
}
|
|
u, err := proxy(req)
|
|
if err != nil {
|
|
t.Fatalf("proxy function: %v", err)
|
|
}
|
|
if u == nil {
|
|
return ""
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
// TestProxyFuncResolvesPerCall is the property that makes system.networkProxy
|
|
// hot-reloadable: the function handed to a transport keeps reading the live
|
|
// configuration instead of the address that was configured when it was built.
|
|
func TestProxyFuncResolvesPerCall(t *testing.T) {
|
|
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
|
|
|
|
proxy := ProxyFunc(true)
|
|
if proxy == nil {
|
|
t.Fatal("ProxyFunc(true) returned nil, so the channel would never proxy")
|
|
}
|
|
if got := resolve(t, proxy); got != "http://127.0.0.1:7890" {
|
|
t.Errorf("first resolution = %q", got)
|
|
}
|
|
|
|
// The same function must follow a settings update.
|
|
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:8888"}}`)
|
|
if got := resolve(t, proxy); got != "http://127.0.0.1:8888" {
|
|
t.Errorf("after a settings update the same function resolved %q", got)
|
|
}
|
|
|
|
// Clearing the proxy falls back to a direct connection.
|
|
publishConfig(t, `{"system":{"networkProxy":""}}`)
|
|
if got := resolve(t, proxy); got != "" {
|
|
t.Errorf("a cleared proxy still resolved %q", got)
|
|
}
|
|
}
|
|
|
|
func TestProxyFuncOptOutReturnsNil(t *testing.T) {
|
|
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
|
|
|
|
// A channel with networkUseProxy off must not be handed a function at all,
|
|
// so its transport keeps the default direct dialing.
|
|
if ProxyFunc(false) != nil {
|
|
t.Error("ProxyFunc(false) must return nil")
|
|
}
|
|
}
|
|
|
|
func TestProxyFuncNormalizesMissingScheme(t *testing.T) {
|
|
publishConfig(t, `{"system":{"networkProxy":"127.0.0.1:7890"}}`)
|
|
|
|
if got := resolve(t, ProxyFunc(true)); got != "http://127.0.0.1:7890" {
|
|
t.Errorf("resolved %q, want the http:// prefix added", got)
|
|
}
|
|
}
|
|
|
|
func TestProxyFuncIgnoresUnusableProxy(t *testing.T) {
|
|
// A value that cannot be parsed must leave the client dialing directly
|
|
// rather than failing every request.
|
|
publishConfig(t, `{"system":{"networkProxy":"://missing-scheme"}}`)
|
|
|
|
if got := resolve(t, ProxyFunc(true)); got != "" {
|
|
t.Errorf("an unparseable proxy resolved %q, want a direct connection", got)
|
|
}
|
|
}
|
|
|
|
// TestDialWithTimeoutDialsDirectlyWithoutProxy covers the path a cleared
|
|
// system.networkProxy takes: the dialer was built while a proxy was configured,
|
|
// and must fall back to a direct dial once there is none.
|
|
func TestDialWithTimeoutDialsDirectlyWithoutProxy(t *testing.T) {
|
|
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
|
|
|
|
dial := DialWithTimeout(true)
|
|
if dial == nil {
|
|
t.Fatal("DialWithTimeout(true) returned nil")
|
|
}
|
|
|
|
// No proxy is listening on that address, so a dial attempted now would
|
|
// fail; clearing the setting is what makes the direct path reachable.
|
|
publishConfig(t, `{"system":{"networkProxy":""}}`)
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
go func() {
|
|
conn, err := ln.Accept()
|
|
if err == nil {
|
|
conn.Close()
|
|
}
|
|
}()
|
|
|
|
conn, err := dial("tcp", ln.Addr().String(), 5*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("dial through a cleared proxy: %v", err)
|
|
}
|
|
conn.Close()
|
|
}
|