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({
} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
-
-
+
+
-
-
+
+
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": "انتخاب تاریخ",