Преглед изворни кода

feat(settings): panel network proxy for the panel's own outbound requests

Add a panelProxy setting that routes the panel's self-initiated HTTP requests (geo updates, Xray version/core download, panel update check) through an admin-configured socks5/http(s) proxy, to bypass server-side filtering of GitHub/Telegram. The Telegram bot falls back to it when tgBotProxy is empty (socks5 only). New util/netproxy.NewHTTPClient builds the proxied client.

Also fix the Mixed-inbound SOCKS/HTTP share URLs that had host:port and user:pass in the wrong order, and consolidate the Telegram settings tab (move API server into the general tab, drop the empty Proxy & Server tab).
MHSanaei пре 6 часа
родитељ
комит
9d9737f470

+ 1 - 0
frontend/src/models/setting.ts

@@ -9,6 +9,7 @@ export class AllSetting {
   webBasePath = '/';
   sessionMaxAge = 360;
   trustedProxyCIDRs = '127.0.0.1/32,::1/128';
+  panelProxy = '';
   pageSize = 25;
   expireDiff = 0;
   trafficDiff = 0;

+ 4 - 4
frontend/src/pages/inbounds/InboundInfoModal.tsx

@@ -911,11 +911,11 @@ export default function InboundInfoModal({
                       <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
                     </Tooltip>
                     <Space size={4} wrap className="share-buttons">
-                      <Tooltip title={`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`}>
-                        <Button size="small" onClick={() => copyText(`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`, t)}>SOCKS5</Button>
+                      <Tooltip title={`socks5://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`}>
+                        <Button size="small" onClick={() => copyText(`socks5://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`, t)}>SOCKS5</Button>
                       </Tooltip>
-                      <Tooltip title={`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`}>
-                        <Button size="small" onClick={() => copyText(`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`, t)}>HTTP</Button>
+                      <Tooltip title={`http://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`}>
+                        <Button size="small" onClick={() => copyText(`http://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`, t)}>HTTP</Button>
                       </Tooltip>
                       <Tooltip title="https://t.me/socks?server=...&port=...&user=...&pass=...">
                         <Button size="small" onClick={() => copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`, t)}>Telegram</Button>

+ 8 - 0
frontend/src/pages/settings/GeneralTab.tsx

@@ -170,6 +170,14 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
               />
             </SettingListItem>
 
+            <SettingListItem paddings="small" title={t('pages.settings.panelProxy')} description={t('pages.settings.panelProxyDesc')}>
+              <Input
+                value={allSetting.panelProxy}
+                placeholder="socks5:// or http://user:pass@host:port"
+                onChange={(e) => updateSetting({ panelProxy: e.target.value })}
+              />
+            </SettingListItem>
+
             <SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
               <InputNumber value={allSetting.pageSize} min={0} step={5} style={{ width: '100%' }}
                 onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />

+ 5 - 16
frontend/src/pages/settings/TelegramTab.tsx

@@ -61,6 +61,11 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
                 options={langOptions}
               />
             </SettingListItem>
+
+            <SettingListItem paddings="small" title={t('pages.settings.telegramAPIServer')} description={t('pages.settings.telegramAPIServerDesc')}>
+              <Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
+                onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
+            </SettingListItem>
           </>
         ),
       },
@@ -85,22 +90,6 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
           </>
         ),
       },
-      {
-        key: '3',
-        label: t('pages.settings.proxyAndServer'),
-        children: (
-          <>
-            <SettingListItem paddings="small" title={t('pages.settings.telegramProxy')} description={t('pages.settings.telegramProxyDesc')}>
-              <Input value={allSetting.tgBotProxy} placeholder="socks5://user:pass@host:port"
-                onChange={(e) => updateSetting({ tgBotProxy: e.target.value })} />
-            </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.telegramAPIServer')} description={t('pages.settings.telegramAPIServerDesc')}>
-              <Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
-                onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
-            </SettingListItem>
-          </>
-        ),
-      },
     ]} />
   );
 }

+ 1 - 0
frontend/src/schemas/setting.ts

@@ -13,6 +13,7 @@ export const AllSettingSchema = z.object({
   webBasePath: absolutePath.optional(),
   sessionMaxAge: z.number().int().min(1).optional(),
   trustedProxyCIDRs: z.string().optional(),
+  panelProxy: z.string().optional(),
   pageSize: z.number().int().min(1).max(1000).optional(),
   expireDiff: nonNegativeInt.optional(),
   trafficDiff: nonNegativeInt.optional(),

+ 1 - 1
go.mod

@@ -95,7 +95,7 @@ require (
 	golang.org/x/arch v0.27.0 // indirect
 	golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
 	golang.org/x/mod v0.36.0 // indirect
-	golang.org/x/net v0.55.0 // indirect
+	golang.org/x/net v0.55.0
 	golang.org/x/sync v0.20.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
 	golang.org/x/tools v0.45.0 // indirect

+ 72 - 0
util/netproxy/netproxy.go

@@ -0,0 +1,72 @@
+// Package netproxy builds HTTP clients that route the panel's own outbound
+// requests through an admin-configured proxy, used to reach GitHub and Telegram
+// from servers where those services are filtered.
+package netproxy
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"golang.org/x/net/proxy"
+)
+
+// NewHTTPClient returns an *http.Client whose transport honors proxyURL.
+//
+// An empty proxyURL yields a plain client (unchanged behavior). socks5/socks5h
+// URLs are dialed through golang.org/x/net/proxy; http/https URLs use the
+// standard library proxy support. Any other scheme returns an error so callers
+// can log it and fall back to a direct connection.
+//
+// The proxy address is intentionally not subjected to SSRF filtering: it is
+// admin-configured and is commonly a loopback/private address (for example a
+// local Xray SOCKS inbound).
+func NewHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
+	if proxyURL == "" {
+		return &http.Client{Timeout: timeout}, nil
+	}
+
+	parsed, err := url.Parse(proxyURL)
+	if err != nil {
+		return nil, fmt.Errorf("parse proxy url: %w", err)
+	}
+
+	transport := baseTransport()
+
+	switch strings.ToLower(parsed.Scheme) {
+	case "socks5", "socks5h":
+		var auth *proxy.Auth
+		if parsed.User != nil {
+			password, _ := parsed.User.Password()
+			auth = &proxy.Auth{User: parsed.User.Username(), Password: password}
+		}
+		dialer, err := proxy.SOCKS5("tcp", parsed.Host, auth, proxy.Direct)
+		if err != nil {
+			return nil, fmt.Errorf("create socks5 dialer: %w", err)
+		}
+		if contextDialer, ok := dialer.(proxy.ContextDialer); ok {
+			transport.DialContext = contextDialer.DialContext
+		} else {
+			transport.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
+				return dialer.Dial(network, addr)
+			}
+		}
+	case "http", "https":
+		transport.Proxy = http.ProxyURL(parsed)
+	default:
+		return nil, fmt.Errorf("unsupported proxy scheme %q", parsed.Scheme)
+	}
+
+	return &http.Client{Timeout: timeout, Transport: transport}, nil
+}
+
+func baseTransport() *http.Transport {
+	if base, ok := http.DefaultTransport.(*http.Transport); ok {
+		return base.Clone()
+	}
+	return &http.Transport{}
+}

+ 54 - 0
util/netproxy/netproxy_test.go

@@ -0,0 +1,54 @@
+package netproxy
+
+import (
+	"net/http"
+	"testing"
+	"time"
+)
+
+func TestNewHTTPClient(t *testing.T) {
+	tests := []struct {
+		name      string
+		proxyURL  string
+		wantErr   bool
+		wantProxy bool
+		wantDial  bool
+	}{
+		{name: "empty returns direct client", proxyURL: ""},
+		{name: "socks5 sets custom dialer", proxyURL: "socks5://127.0.0.1:1080", wantDial: true},
+		{name: "socks5 with auth", proxyURL: "socks5://user:[email protected]:1080", wantDial: true},
+		{name: "http sets transport proxy", proxyURL: "http://127.0.0.1:8080", wantProxy: true},
+		{name: "https sets transport proxy", proxyURL: "https://127.0.0.1:8080", wantProxy: true},
+		{name: "unsupported scheme errors", proxyURL: "ftp://127.0.0.1:21", wantErr: true},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			client, err := NewHTTPClient(tc.proxyURL, 5*time.Second)
+			if tc.wantErr {
+				if err == nil {
+					t.Fatalf("expected error for %q, got nil", tc.proxyURL)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("unexpected error for %q: %v", tc.proxyURL, err)
+			}
+			if client.Timeout != 5*time.Second {
+				t.Errorf("timeout = %v, want 5s", client.Timeout)
+			}
+			if tc.wantProxy {
+				transport, ok := client.Transport.(*http.Transport)
+				if !ok || transport.Proxy == nil {
+					t.Errorf("expected transport with Proxy set for %q", tc.proxyURL)
+				}
+			}
+			if tc.wantDial {
+				transport, ok := client.Transport.(*http.Transport)
+				if !ok || transport.DialContext == nil {
+					t.Errorf("expected transport with DialContext set for %q", tc.proxyURL)
+				}
+			}
+		})
+	}
+}

+ 1 - 0
web/entity/entity.go

@@ -29,6 +29,7 @@ type AllSetting struct {
 	WebBasePath       string `json:"webBasePath" form:"webBasePath"`                                   // Base path for web panel URLs
 	SessionMaxAge     int    `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=0,lte=525600"`   // Session maximum age in minutes (cap at one year)
 	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`                       // Trusted reverse proxy IPs/CIDRs for forwarded headers
+	PanelProxy        string `json:"panelProxy" form:"panelProxy"`                                     // Proxy URL for the panel's own outbound requests (GitHub/Telegram)
 
 	// UI settings
 	PageSize    int    `json:"pageSize" form:"pageSize" validate:"gte=1,lte=1000"`     // Number of items per page in lists

+ 2 - 2
web/service/panel.go

@@ -131,7 +131,7 @@ func (s *PanelService) StartUpdate() error {
 }
 
 func downloadPanelUpdater() (string, error) {
-	client := &http.Client{Timeout: 15 * time.Second}
+	client := (&SettingService{}).NewProxiedHTTPClient(15 * time.Second)
 	resp, err := client.Get(panelUpdaterURL)
 	if err != nil {
 		return "", fmt.Errorf("download panel updater: %w", err)
@@ -169,7 +169,7 @@ func downloadPanelUpdater() (string, error) {
 }
 
 func fetchLatestPanelVersion() (string, error) {
-	client := &http.Client{Timeout: 10 * time.Second}
+	client := (&SettingService{}).NewProxiedHTTPClient(10 * time.Second)
 	resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest")
 	if err != nil {
 		return "", err

+ 4 - 5
web/service/server.go

@@ -617,8 +617,6 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
 	return s.emaCPU, nil
 }
 
-var xrayVersionsClient = &http.Client{Timeout: 10 * time.Second}
-
 const (
 	maxXrayArchiveBytes = 200 << 20
 	maxXrayBinaryBytes  = 200 << 20
@@ -630,7 +628,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
 		bufferSize = 8192
 	)
 
-	resp, err := xrayVersionsClient.Get(XrayURL)
+	resp, err := s.settingService.NewProxiedHTTPClient(10 * time.Second).Get(XrayURL)
 	if err != nil {
 		return nil, err
 	}
@@ -729,7 +727,7 @@ func (s *ServerService) downloadXRay(version string) (string, error) {
 
 	fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch)
 	url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName)
-	client := &http.Client{Timeout: 60 * time.Second}
+	client := s.settingService.NewProxiedHTTPClient(60 * time.Second)
 	resp, err := client.Get(url)
 	if err != nil {
 		return "", err
@@ -1273,6 +1271,8 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
 		}
 	}
 
+	client := s.settingService.NewProxiedHTTPClient(0)
+
 	downloadFile := func(url, destPath string) error {
 		var req *http.Request
 		req, err := http.NewRequest("GET", url, nil)
@@ -1288,7 +1288,6 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
 			}
 		}
 
-		client := &http.Client{}
 		resp, err := client.Do(req)
 		if err != nil {
 			return common.NewErrorf("Failed to download Geofile from %s: %v", url, err)

+ 28 - 0
web/service/setting.go

@@ -6,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"net/http"
 	"reflect"
 	"strconv"
 	"strings"
@@ -15,6 +16,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/netproxy"
 	"github.com/mhsanaei/3x-ui/v3/util/random"
 	"github.com/mhsanaei/3x-ui/v3/util/reflect_util"
 	"github.com/mhsanaei/3x-ui/v3/web/entity"
@@ -88,6 +90,7 @@ var defaultValueMap = map[string]string{
 	"externalTrafficInformURI":    "",
 	"restartXrayOnClientDisable":  "true",
 	"xrayOutboundTestUrl":         "https://www.google.com/generate_204",
+	"panelProxy":                  "",
 
 	// LDAP defaults
 	"ldapEnable":            "false",
@@ -351,6 +354,31 @@ func (s *SettingService) SetTgBotProxy(token string) error {
 	return s.setString("tgBotProxy", token)
 }
 
+func (s *SettingService) GetPanelProxy() (string, error) {
+	return s.getString("panelProxy")
+}
+
+func (s *SettingService) SetPanelProxy(proxyUrl string) error {
+	return s.setString("panelProxy", proxyUrl)
+}
+
+// NewProxiedHTTPClient returns an HTTP client that routes the panel's own
+// outbound requests through the configured panelProxy setting. An invalid or
+// missing proxy falls back to a direct client so existing behavior is preserved.
+func (s *SettingService) NewProxiedHTTPClient(timeout time.Duration) *http.Client {
+	proxyUrl, err := s.GetPanelProxy()
+	if err != nil {
+		logger.Warning("Failed to read panel proxy setting:", err)
+		proxyUrl = ""
+	}
+	client, err := netproxy.NewHTTPClient(proxyUrl, timeout)
+	if err != nil {
+		logger.Warningf("Invalid panel proxy %q, using direct connection: %v", proxyUrl, err)
+		return &http.Client{Timeout: timeout}
+	}
+	return client
+}
+
 func (s *SettingService) GetTgBotAPIServer() (string, error) {
 	return s.getString("tgBotAPIServer")
 }

+ 11 - 0
web/service/tgbot.go

@@ -246,6 +246,17 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
 		logger.Warning("Failed to get Telegram bot proxy URL:", err)
 	}
 
+	// 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://") {
+			tgBotProxy = panelProxy
+		}
+	}
+
 	// Get Telegram bot API server URL
 	tgBotAPIServer, err := t.settingService.GetTgBotAPIServer()
 	if err != nil {

+ 2 - 0
web/translation/en-US.json

@@ -686,6 +686,8 @@
       "panelUrlPathDesc": "The URI path for the web panel. (begins with ‘/‘ and concludes with ‘/‘)",
       "pageSize": "Pagination Size",
       "pageSizeDesc": "Define page size for inbounds table. (0 = disable)",
+      "panelProxy": "Panel Network Proxy",
+      "panelProxyDesc": "Routes the panel's own outbound requests (geo updates, Xray/panel version checks, Telegram) through this proxy to bypass server-side filtering of GitHub/Telegram. Accepts socks5:// or http(s)://, e.g. a local Xray SOCKS inbound. Leave empty for a direct connection.",
       "remarkModel": "Remark Model & Separation Character",
       "datepicker": "Calendar Type",
       "datepickerPlaceholder": "Select date",

+ 2 - 0
web/translation/fa-IR.json

@@ -621,6 +621,8 @@
       "panelUrlPathDesc": "برای وب پنل. با '/' شروع‌ و با '/' خاتمه‌ می‌یابد URI مسیر",
       "pageSize": "اندازه صفحه بندی جدول",
       "pageSizeDesc": "(اندازه صفحه برای جدول ورودی‌ها.(0 = غیرفعال",
+      "panelProxy": "پراکسی شبکه‌ی پنل",
+      "panelProxyDesc": "درخواست‌های خروجیِ خودِ پنل (آپدیت geo، چک نسخه‌ی Xray و پنل، تلگرام) را از این پراکسی عبور می‌دهد تا فیلترینگ سروری گیت‌هاب/تلگرام دور زده شود. پشتیبانی از socks5:// و http(s)://، برای نمونه یک اینباند SOCKS لوکالِ Xray. برای اتصال مستقیم خالی بگذارید.",
       "remarkModel": "نام‌کانفیگ و جداکننده",
       "datepicker": "نوع تقویم",
       "datepickerPlaceholder": "انتخاب تاریخ",