Просмотр исходного кода

fix(panel-proxy): route custom geo and http(s) Telegram through panelProxy

Custom geosite/geoip downloads built their own ssrfSafeTransport and never used the configured Panel Network Proxy, so geo updates failed on servers where GitHub is filtered. Route all custom-geo HTTP (startup probes + downloads) through panelProxy when set, falling back to the direct SSRF-guarded transport otherwise; the target URL stays SSRF-validated.

The Telegram bot only honored a socks5:// panel proxy and silently rejected http(s)://, despite the setting advertising both. Branch the fasthttp dialer (FasthttpHTTPDialer for http(s), FasthttpSocksDialer for socks5) and accept all three schemes in the fallback and NewBot validation.

Add tests proving the panel proxy is used by custom geo and that the bot dialer speaks HTTP CONNECT vs SOCKS5 per scheme.
MHSanaei 9 часов назад
Родитель
Сommit
db5ce06256

+ 1 - 1
frontend/src/lib/xray/inbound-defaults.ts

@@ -162,7 +162,7 @@ export function createDefaultShadowsocksInboundSettings(
   return {
     method,
     password: seed.password ?? RandomUtil.randomShadowsocksPassword(method),
-    network: seed.network ?? 'tcp',
+    network: seed.network ?? 'tcp,udp',
     clients: [],
     ivCheck: seed.ivCheck ?? false,
   };

+ 1 - 1
frontend/src/schemas/protocols/inbound/shadowsocks.ts

@@ -29,7 +29,7 @@ export type ShadowsocksClient = z.infer<typeof ShadowsocksClientSchema>;
 export const ShadowsocksInboundSettingsSchema = z.object({
   method: SSMethodSchema.default('2022-blake3-aes-256-gcm'),
   password: z.string().default(''),
-  network: SSNetworkSchema.default('tcp'),
+  network: SSNetworkSchema.default('tcp,udp'),
   clients: z.array(ShadowsocksClientSchema).default([]),
   ivCheck: z.boolean().default(false),
 });

+ 1 - 1
frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap

@@ -12,7 +12,7 @@ exports[`createDefault*InboundSettings factories > shadowsocks 1`] = `
   "clients": [],
   "ivCheck": false,
   "method": "2022-blake3-aes-256-gcm",
-  "network": "tcp",
+  "network": "tcp,udp",
   "password": "ZmFrZS1zcy1zZWVk",
 }
 `;

+ 32 - 9
web/service/custom_geo.go

@@ -18,6 +18,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/database"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/netproxy"
 	"github.com/mhsanaei/3x-ui/v3/util/netsafe"
 )
 
@@ -73,6 +74,7 @@ type CustomGeoService struct {
 	updateAllGetAll  func() ([]model.CustomGeoResource, error)
 	updateAllApply   func(id int, onStartup bool) (string, error)
 	updateAllRestart func() error
+	getPanelProxy    func() (string, error)
 }
 
 func NewCustomGeoService() *CustomGeoService {
@@ -82,6 +84,7 @@ func NewCustomGeoService() *CustomGeoService {
 	s.updateAllGetAll = s.GetAll
 	s.updateAllApply = s.applyDownloadAndPersist
 	s.updateAllRestart = func() error { return s.serverService.RestartXrayService() }
+	s.getPanelProxy = (&SettingService{}).GetPanelProxy
 	return s
 }
 
@@ -206,12 +209,32 @@ func ssrfSafeTransport() http.RoundTripper {
 	return cloned
 }
 
-func probeCustomGeoURLWithGET(rawURL string) error {
-	sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
+func (s *CustomGeoService) httpClient(timeout time.Duration) *http.Client {
+	proxyURL := ""
+	if s.getPanelProxy != nil {
+		if p, err := s.getPanelProxy(); err != nil {
+			logger.Warning("custom geo: read panel proxy:", err)
+		} else {
+			proxyURL = strings.TrimSpace(p)
+		}
+	}
+	if proxyURL != "" {
+		client, err := netproxy.NewHTTPClient(proxyURL, timeout)
+		if err != nil {
+			logger.Warningf("custom geo: invalid panel proxy %q, using direct connection: %v", proxyURL, err)
+		} else {
+			return client
+		}
+	}
+	return &http.Client{Timeout: timeout, Transport: ssrfSafeTransport()}
+}
+
+func (s *CustomGeoService) probeCustomGeoURLWithGET(rawURL string) error {
+	sanitizedURL, err := s.sanitizeURL(rawURL)
 	if err != nil {
 		return err
 	}
-	client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
+	client := s.httpClient(customGeoProbeTimeout)
 	req, err := http.NewRequest(http.MethodGet, sanitizedURL, nil)
 	if err != nil {
 		return err
@@ -231,12 +254,12 @@ func probeCustomGeoURLWithGET(rawURL string) error {
 	}
 }
 
-func probeCustomGeoURL(rawURL string) error {
-	sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
+func (s *CustomGeoService) probeCustomGeoURL(rawURL string) error {
+	sanitizedURL, err := s.sanitizeURL(rawURL)
 	if err != nil {
 		return err
 	}
-	client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
+	client := s.httpClient(customGeoProbeTimeout)
 	req, err := http.NewRequest(http.MethodHead, sanitizedURL, nil)
 	if err != nil {
 		return err
@@ -251,7 +274,7 @@ func probeCustomGeoURL(rawURL string) error {
 		return nil
 	}
 	if sc == http.StatusMethodNotAllowed || sc == http.StatusNotImplemented {
-		return probeCustomGeoURLWithGET(rawURL)
+		return s.probeCustomGeoURLWithGET(rawURL)
 	}
 	return fmt.Errorf("head status %d", sc)
 }
@@ -283,7 +306,7 @@ func (s *CustomGeoService) EnsureOnStartup() {
 			continue
 		}
 		logger.Infof("custom geo startup id=%d alias=%s path=%s: missing or needs repair, probing source", r.Id, r.Alias, localPath)
-		if err := probeCustomGeoURL(r.Url); err != nil {
+		if err := s.probeCustomGeoURL(r.Url); err != nil {
 			logger.Warningf("custom geo startup id=%d alias=%s url=%s: probe: %v (attempting download anyway)", r.Id, r.Alias, r.Url, err)
 		}
 		_, _ = s.applyDownloadAndPersist(r.Id, true)
@@ -366,7 +389,7 @@ func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, last
 		}
 	}
 
-	client := &http.Client{Timeout: 10 * time.Minute, Transport: ssrfSafeTransport()}
+	client := s.httpClient(10 * time.Minute)
 	// lgtm[go/request-forgery]
 	resp, err := client.Do(req)
 	if err != nil {

+ 2 - 2
web/service/custom_geo_test.go

@@ -322,7 +322,7 @@ func TestProbeCustomGeoURL_HEADOK(t *testing.T) {
 		w.WriteHeader(http.StatusOK)
 	}))
 	defer ts.Close()
-	if err := probeCustomGeoURL(ts.URL); err != nil {
+	if err := (&CustomGeoService{}).probeCustomGeoURL(ts.URL); err != nil {
 		t.Fatal(err)
 	}
 }
@@ -342,7 +342,7 @@ func TestProbeCustomGeoURL_HEAD405GETRange(t *testing.T) {
 		w.WriteHeader(http.StatusBadRequest)
 	}))
 	defer ts.Close()
-	if err := probeCustomGeoURL(ts.URL); err != nil {
+	if err := (&CustomGeoService{}).probeCustomGeoURL(ts.URL); err != nil {
 		t.Fatal(err)
 	}
 }

+ 103 - 0
web/service/panel_proxy_test.go

@@ -0,0 +1,103 @@
+package service
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/util/netproxy"
+)
+
+func recordingProxy(t *testing.T, hits *int64) *httptest.Server {
+	t.Helper()
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		atomic.AddInt64(hits, 1)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(make([]byte, minDatBytes+1))
+	}))
+}
+
+func originServer(t *testing.T, hits *int64) *httptest.Server {
+	t.Helper()
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		atomic.AddInt64(hits, 1)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(make([]byte, minDatBytes+1))
+	}))
+}
+
+func TestPanelProxy_NetproxyHelperRoutesThroughProxy(t *testing.T) {
+	var proxyHits, originHits int64
+	proxy := recordingProxy(t, &proxyHits)
+	defer proxy.Close()
+	origin := originServer(t, &originHits)
+	defer origin.Close()
+
+	client, err := netproxy.NewHTTPClient(proxy.URL, 5*time.Second)
+	if err != nil {
+		t.Fatal(err)
+	}
+	resp, err := client.Get(origin.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = resp.Body.Close()
+
+	if atomic.LoadInt64(&proxyHits) != 1 {
+		t.Fatalf("expected panel proxy to be hit once, got %d (origin hits=%d)", proxyHits, originHits)
+	}
+}
+
+func TestPanelProxy_CustomGeoDownloadUsesProxy(t *testing.T) {
+	disableSSRFCheck(t)
+
+	var proxyHits, originHits int64
+	proxy := recordingProxy(t, &proxyHits)
+	defer proxy.Close()
+	origin := originServer(t, &originHits)
+	defer origin.Close()
+
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	dest := filepath.Join(dir, "geosite_repro.dat")
+
+	s := CustomGeoService{getPanelProxy: func() (string, error) { return proxy.URL, nil }}
+	if _, _, err := s.downloadToPath(origin.URL, dest, ""); err != nil {
+		t.Fatalf("download failed: %v", err)
+	}
+	if _, err := os.Stat(dest); err != nil {
+		t.Fatalf("expected file to be written: %v", err)
+	}
+
+	if got := atomic.LoadInt64(&proxyHits); got != 1 {
+		t.Fatalf("custom geo download did not route through the Panel Network Proxy "+
+			"(proxy hits=%d, origin hits=%d)", got, atomic.LoadInt64(&originHits))
+	}
+}
+
+func TestPanelProxy_CustomGeoDownloadDirectWhenUnset(t *testing.T) {
+	disableSSRFCheck(t)
+
+	var proxyHits, originHits int64
+	proxy := recordingProxy(t, &proxyHits)
+	defer proxy.Close()
+	origin := originServer(t, &originHits)
+	defer origin.Close()
+
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	dest := filepath.Join(dir, "geosite_direct.dat")
+
+	s := CustomGeoService{}
+	if _, _, err := s.downloadToPath(origin.URL, dest, ""); err != nil {
+		t.Fatalf("download failed: %v", err)
+	}
+	if atomic.LoadInt64(&proxyHits) != 0 || atomic.LoadInt64(&originHits) != 1 {
+		t.Fatalf("expected direct connection (proxy=0, origin=1), got proxy=%d origin=%d",
+			atomic.LoadInt64(&proxyHits), atomic.LoadInt64(&originHits))
+	}
+}

+ 17 - 12
web/service/tgbot.go

@@ -247,12 +247,11 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
 	}
 
 	// Fall back to the panel-wide proxy when no dedicated bot proxy is set.
-	// The bot's fasthttp dialer only supports SOCKS5, so other schemes are ignored.
 	if tgBotProxy == "" {
 		panelProxy, perr := t.settingService.GetPanelProxy()
 		if perr != nil {
 			logger.Warning("Failed to get panel proxy URL:", perr)
-		} else if strings.HasPrefix(panelProxy, "socks5://") {
+		} else if isSupportedBotProxyScheme(panelProxy) {
 			tgBotProxy = panelProxy
 		}
 	}
@@ -304,6 +303,12 @@ func (t *Tgbot) trySetBotCommands(bot *telego.Bot) {
 	}
 }
 
+func isSupportedBotProxyScheme(proxyUrl string) bool {
+	return strings.HasPrefix(proxyUrl, "socks5://") ||
+		strings.HasPrefix(proxyUrl, "http://") ||
+		strings.HasPrefix(proxyUrl, "https://")
+}
+
 // createRobustFastHTTPClient creates a fasthttp.Client with proper connection handling
 func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
 	client := &fasthttp.Client{
@@ -326,9 +331,12 @@ func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
 		},
 	}
 
-	// Set proxy if provided
 	if proxyUrl != "" {
-		client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl)
+		if strings.HasPrefix(proxyUrl, "socks5://") {
+			client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl)
+		} else {
+			client.Dial = fasthttpproxy.FasthttpHTTPDialer(proxyUrl)
+		}
 	}
 
 	return client
@@ -338,15 +346,12 @@ func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
 func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
 	// Validate proxy URL if provided
 	if proxyUrl != "" {
-		if !strings.HasPrefix(proxyUrl, "socks5://") {
-			logger.Warning("Invalid socks5 URL, ignoring proxy")
+		if !isSupportedBotProxyScheme(proxyUrl) {
+			logger.Warning("Unsupported proxy scheme (want socks5:// or http(s)://), ignoring proxy")
 			proxyUrl = "" // Clear invalid proxy
-		} else {
-			_, err := url.Parse(proxyUrl)
-			if err != nil {
-				logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err)
-				proxyUrl = ""
-			}
+		} else if _, err := url.Parse(proxyUrl); err != nil {
+			logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err)
+			proxyUrl = ""
 		}
 	}
 

+ 88 - 0
web/service/tgbot_test.go

@@ -1,8 +1,11 @@
 package service
 
 import (
+	"io"
+	"net"
 	"reflect"
 	"testing"
+	"time"
 )
 
 func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
@@ -11,3 +14,88 @@ func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
 		t.Fatal("LoginAttempt must not carry attempted passwords")
 	}
 }
+
+func TestIsSupportedBotProxyScheme(t *testing.T) {
+	supported := []string{
+		"socks5://127.0.0.1:1080",
+		"http://127.0.0.1:8080",
+		"https://127.0.0.1:8080",
+	}
+	for _, p := range supported {
+		if !isSupportedBotProxyScheme(p) {
+			t.Errorf("expected %q to be supported", p)
+		}
+	}
+	unsupported := []string{"", "ftp://x", "127.0.0.1:1080", "socks4://1.2.3.4:1080"}
+	for _, p := range unsupported {
+		if isSupportedBotProxyScheme(p) {
+			t.Errorf("expected %q to be unsupported", p)
+		}
+	}
+}
+
+func recordingDialTarget(t *testing.T, n int) (addr string, got chan []byte) {
+	t.Helper()
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	got = make(chan []byte, 1)
+	t.Cleanup(func() { _ = ln.Close() })
+	go func() {
+		conn, err := ln.Accept()
+		if err != nil {
+			return
+		}
+		defer conn.Close()
+		_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
+		buf := make([]byte, n)
+		m, _ := io.ReadFull(conn, buf)
+		got <- buf[:m]
+	}()
+	return ln.Addr().String(), got
+}
+
+func TestTgbotProxyDialerSelectsHTTPForHTTPScheme(t *testing.T) {
+	addr, got := recordingDialTarget(t, len("CONNECT "))
+	tg := &Tgbot{}
+	client := tg.createRobustFastHTTPClient("http://" + addr)
+	if client.Dial == nil {
+		t.Fatal("Dial must be set for an http:// proxy")
+	}
+	go func() { _, _ = client.Dial("example.com:443") }()
+	select {
+	case b := <-got:
+		if string(b) != "CONNECT " {
+			t.Fatalf("expected HTTP CONNECT to the proxy, got %q", b)
+		}
+	case <-time.After(3 * time.Second):
+		t.Fatal("proxy never received a connection")
+	}
+}
+
+func TestTgbotProxyDialerSelectsSOCKSForSocks5Scheme(t *testing.T) {
+	addr, got := recordingDialTarget(t, 1)
+	tg := &Tgbot{}
+	client := tg.createRobustFastHTTPClient("socks5://" + addr)
+	if client.Dial == nil {
+		t.Fatal("Dial must be set for a socks5:// proxy")
+	}
+	go func() { _, _ = client.Dial("example.com:443") }()
+	select {
+	case b := <-got:
+		if len(b) != 1 || b[0] != 0x05 {
+			t.Fatalf("expected SOCKS5 greeting (0x05), got %v", b)
+		}
+	case <-time.After(3 * time.Second):
+		t.Fatal("proxy never received a connection")
+	}
+}
+
+func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
+	tg := &Tgbot{}
+	client := tg.createRobustFastHTTPClient("")
+	if client.Dial != nil {
+		t.Fatal("Dial must be nil when no proxy is configured")
+	}
+}