diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 2f45b3c1..039ff730 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/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, }; diff --git a/frontend/src/schemas/protocols/inbound/shadowsocks.ts b/frontend/src/schemas/protocols/inbound/shadowsocks.ts index 9a8e0316..9c18c22f 100644 --- a/frontend/src/schemas/protocols/inbound/shadowsocks.ts +++ b/frontend/src/schemas/protocols/inbound/shadowsocks.ts @@ -29,7 +29,7 @@ export type ShadowsocksClient = z.infer; 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), }); diff --git a/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap b/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap index 8d64ae99..263572e2 100644 --- a/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap +++ b/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", } `; diff --git a/web/service/custom_geo.go b/web/service/custom_geo.go index d58fe552..b63b1b7b 100644 --- a/web/service/custom_geo.go +++ b/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 { diff --git a/web/service/custom_geo_test.go b/web/service/custom_geo_test.go index a1e15b8c..66511a31 100644 --- a/web/service/custom_geo_test.go +++ b/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) } } diff --git a/web/service/panel_proxy_test.go b/web/service/panel_proxy_test.go new file mode 100644 index 00000000..52c0bacd --- /dev/null +++ b/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)) + } +} diff --git a/web/service/tgbot.go b/web/service/tgbot.go index b59e7d7c..dcb6cb2c 100644 --- a/web/service/tgbot.go +++ b/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 = "" } } diff --git a/web/service/tgbot_test.go b/web/service/tgbot_test.go index 70411122..36e17e78 100644 --- a/web/service/tgbot_test.go +++ b/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") + } +}