From 9d9737f470316729472d16725378951c3c46641f Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 28 May 2026 00:45:32 +0200 Subject: [PATCH] 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). --- frontend/src/models/setting.ts | 1 + .../src/pages/inbounds/InboundInfoModal.tsx | 8 +-- frontend/src/pages/settings/GeneralTab.tsx | 8 +++ frontend/src/pages/settings/TelegramTab.tsx | 21 ++---- frontend/src/schemas/setting.ts | 1 + go.mod | 2 +- util/netproxy/netproxy.go | 72 +++++++++++++++++++ util/netproxy/netproxy_test.go | 54 ++++++++++++++ web/entity/entity.go | 1 + web/service/panel.go | 4 +- web/service/server.go | 9 ++- web/service/setting.go | 28 ++++++++ web/service/tgbot.go | 11 +++ web/translation/en-US.json | 2 + web/translation/fa-IR.json | 2 + 15 files changed, 196 insertions(+), 28 deletions(-) create mode 100644 util/netproxy/netproxy.go create mode 100644 util/netproxy/netproxy_test.go diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index 7e09c315..ff9d1d7f 100644 --- a/frontend/src/models/setting.ts +++ b/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; diff --git a/frontend/src/pages/inbounds/InboundInfoModal.tsx b/frontend/src/pages/inbounds/InboundInfoModal.tsx index 75431c57..dcd7b75d 100644 --- a/frontend/src/pages/inbounds/InboundInfoModal.tsx +++ b/frontend/src/pages/inbounds/InboundInfoModal.tsx @@ -911,11 +911,11 @@ export default function InboundInfoModal({ + + - - + + diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx index a8c20cd6..7a610f5e 100644 --- a/frontend/src/pages/settings/GeneralTab.tsx +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -170,6 +170,14 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp /> + + updateSetting({ panelProxy: e.target.value })} + /> + + updateSetting({ pageSize: Number(v) || 0 })} /> diff --git a/frontend/src/pages/settings/TelegramTab.tsx b/frontend/src/pages/settings/TelegramTab.tsx index 1d3094d7..99da5b00 100644 --- a/frontend/src/pages/settings/TelegramTab.tsx +++ b/frontend/src/pages/settings/TelegramTab.tsx @@ -61,6 +61,11 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr options={langOptions} /> + + + updateSetting({ tgBotAPIServer: e.target.value })} /> + ), }, @@ -85,22 +90,6 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr ), }, - { - key: '3', - label: t('pages.settings.proxyAndServer'), - children: ( - <> - - updateSetting({ tgBotProxy: e.target.value })} /> - - - updateSetting({ tgBotAPIServer: e.target.value })} /> - - - ), - }, ]} /> ); } diff --git a/frontend/src/schemas/setting.ts b/frontend/src/schemas/setting.ts index 4f8561b3..a8852f18 100644 --- a/frontend/src/schemas/setting.ts +++ b/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(), diff --git a/go.mod b/go.mod index f4637edb..86c52f24 100644 --- a/go.mod +++ b/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 diff --git a/util/netproxy/netproxy.go b/util/netproxy/netproxy.go new file mode 100644 index 00000000..d436fca3 --- /dev/null +++ b/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{} +} diff --git a/util/netproxy/netproxy_test.go b/util/netproxy/netproxy_test.go new file mode 100644 index 00000000..830a3105 --- /dev/null +++ b/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:pass@127.0.0.1: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) + } + } + }) + } +} diff --git a/web/entity/entity.go b/web/entity/entity.go index 3ad6b0d7..38930756 100644 --- a/web/entity/entity.go +++ b/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 diff --git a/web/service/panel.go b/web/service/panel.go index b776a214..b89fd9cd 100644 --- a/web/service/panel.go +++ b/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 diff --git a/web/service/server.go b/web/service/server.go index 9fc65b40..dc1d3f72 100644 --- a/web/service/server.go +++ b/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) diff --git a/web/service/setting.go b/web/service/setting.go index 9280acda..267ec1cb 100644 --- a/web/service/setting.go +++ b/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") } diff --git a/web/service/tgbot.go b/web/service/tgbot.go index d62f23a7..a37cc6f8 100644 --- a/web/service/tgbot.go +++ b/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 { diff --git a/web/translation/en-US.json b/web/translation/en-US.json index 99c4c846..0c3439ac 100644 --- a/web/translation/en-US.json +++ b/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", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index 5fcfc208..16325e97 100644 --- a/web/translation/fa-IR.json +++ b/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": "انتخاب تاریخ",