mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-12 22:31:00 +00:00
feat: replace panel proxy URL with outbound-based egress bridge
Instead of requiring a manual SOCKS5/HTTP URL, the panel now lets the admin pick an Xray outbound from a dropdown (same UX as Geodata Auto-Update). At runtime, injectPanelEgress appends a loopback SOCKS inbound (tag: panel-egress) and prepends a routing rule so the panel's own HTTP traffic — version checks, Telegram, normal geo-file updates — is routed through the chosen outbound. Xray-native Geodata Auto-Update is unaffected (it uses its own geodata.outbound inside Xray). Blackhole outbounds are excluded from both picker dropdowns since routing any download through one just drops it. Translations updated for all 13 locales.
This commit is contained in:
+1
-1
@@ -28,7 +28,7 @@ func originServer(t *testing.T, hits *int64) *httptest.Server {
|
||||
}))
|
||||
}
|
||||
|
||||
func TestPanelProxy_NetproxyHelperRoutesThroughProxy(t *testing.T) {
|
||||
func TestPanelEgress_NetproxyHelperRoutesThroughProxy(t *testing.T) {
|
||||
var proxyHits, originHits int64
|
||||
proxy := recordingProxy(t, &proxyHits)
|
||||
defer proxy.Close()
|
||||
@@ -95,7 +95,7 @@ var defaultValueMap = map[string]string{
|
||||
"externalTrafficInformURI": "",
|
||||
"restartXrayOnClientDisable": "true",
|
||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||
"panelProxy": "",
|
||||
"panelOutbound": "",
|
||||
|
||||
// LDAP defaults
|
||||
"ldapEnable": "false",
|
||||
@@ -384,26 +384,52 @@ func (s *SettingService) SetTgBotProxy(token string) error {
|
||||
return s.setString("tgBotProxy", token)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetPanelProxy() (string, error) {
|
||||
return s.getString("panelProxy")
|
||||
// GetPanelOutbound returns the Xray outbound tag the panel's own outbound
|
||||
// requests (version checks, Telegram, subscription fetches) are routed through.
|
||||
func (s *SettingService) GetPanelOutbound() (string, error) {
|
||||
return s.getString("panelOutbound")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetPanelProxy(proxyUrl string) error {
|
||||
return s.setString("panelProxy", proxyUrl)
|
||||
func (s *SettingService) SetPanelOutbound(tag string) error {
|
||||
return s.setString("panelOutbound", tag)
|
||||
}
|
||||
|
||||
// PanelEgressProxyURL resolves the loopback SOCKS bridge that the generated
|
||||
// config exposes when a panel outbound is configured (see injectPanelEgress).
|
||||
// It returns "" — meaning a direct connection — when the feature is off or
|
||||
// the bridge is not present in the running core yet.
|
||||
func (s *SettingService) PanelEgressProxyURL() string {
|
||||
tag, err := s.GetPanelOutbound()
|
||||
if err != nil || tag == "" {
|
||||
return ""
|
||||
}
|
||||
proc := XrayProcess()
|
||||
if proc == nil || !proc.IsRunning() {
|
||||
logger.Warning("panel outbound [", tag, "] is set but Xray is not running, using a direct connection")
|
||||
return ""
|
||||
}
|
||||
cfg := proc.GetConfig()
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
for i := range cfg.InboundConfigs {
|
||||
if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
|
||||
return fmt.Sprintf("socks5://127.0.0.1:%d", cfg.InboundConfigs[i].Port)
|
||||
}
|
||||
}
|
||||
logger.Warning("panel outbound [", tag, "] is set but the egress bridge is not in the running config, using a direct connection")
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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.
|
||||
// outbound requests through the configured panel outbound (via the loopback
|
||||
// SOCKS bridge in the running Xray). When the feature is off or the bridge
|
||||
// is unavailable it falls back to a direct client.
|
||||
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 = ""
|
||||
}
|
||||
proxyUrl := s.PanelEgressProxyURL()
|
||||
client, err := netproxy.NewHTTPClient(proxyUrl, timeout)
|
||||
if err != nil {
|
||||
logger.Warningf("Invalid panel proxy %q, using direct connection: %v", proxyUrl, err)
|
||||
logger.Warningf("Invalid panel egress proxy %q, using direct connection: %v", proxyUrl, err)
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
return client
|
||||
|
||||
@@ -234,13 +234,12 @@ 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.
|
||||
// Fall back to the panel-wide egress bridge when no dedicated bot proxy is
|
||||
// set. Resolved once at bot start: if Xray comes up later, the bot keeps
|
||||
// its direct connection until it is restarted.
|
||||
if tgBotProxy == "" {
|
||||
panelProxy, perr := t.settingService.GetPanelProxy()
|
||||
if perr != nil {
|
||||
logger.Warning("Failed to get panel proxy URL:", perr)
|
||||
} else if isSupportedBotProxyScheme(panelProxy) {
|
||||
tgBotProxy = panelProxy
|
||||
if egress := t.settingService.PanelEgressProxyURL(); egress != "" && isSupportedBotProxyScheme(egress) {
|
||||
tgBotProxy = egress
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -273,9 +273,82 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
mergeSubscriptionOutbounds(xrayConfig, prepend, appendList)
|
||||
}
|
||||
|
||||
// Wire the panel's own HTTP traffic through the configured outbound, after
|
||||
// the subscription merge so subscription outbound tags are valid targets.
|
||||
if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
|
||||
logger.Warning("read panelOutbound setting failed:", err)
|
||||
} else if egressTag != "" {
|
||||
injectPanelEgress(xrayConfig, egressTag)
|
||||
}
|
||||
|
||||
return xrayConfig, nil
|
||||
}
|
||||
|
||||
// PanelEgressInboundTag is the tag of the loopback SOCKS inbound injected into
|
||||
// the generated config when a panel outbound is configured. The panel's own
|
||||
// HTTP clients dial through it to egress via the chosen outbound.
|
||||
const PanelEgressInboundTag = "panel-egress"
|
||||
|
||||
// panelEgressBasePort is the first port tried for the egress bridge; ports
|
||||
// already taken by other inbounds in the generated config are skipped.
|
||||
const panelEgressBasePort = 62790
|
||||
|
||||
// injectPanelEgress appends a loopback SOCKS inbound to the generated config
|
||||
// and prepends a routing rule sending it to outboundTag. Both live only in the
|
||||
// generated config — the stored template is never modified — and both are
|
||||
// hot-appliable, so changing the panel outbound never restarts the core.
|
||||
func injectPanelEgress(cfg *xray.Config, outboundTag string) {
|
||||
for i := range cfg.InboundConfigs {
|
||||
if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
|
||||
logger.Warning("panel egress: inbound tag [", PanelEgressInboundTag, "] already exists, skipping injection")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// The rule must exist before the inbound takes traffic, otherwise the
|
||||
// bridge would silently egress through the default outbound instead.
|
||||
routing := map[string]any{}
|
||||
if len(cfg.RouterConfig) > 0 {
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
logger.Warning("panel egress: routing section is unparsable, skipping injection:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
rules, _ := routing["rules"].([]any)
|
||||
rule := map[string]any{
|
||||
"type": "field",
|
||||
"inboundTag": []any{PanelEgressInboundTag},
|
||||
"outboundTag": outboundTag,
|
||||
}
|
||||
routing["rules"] = append([]any{rule}, rules...)
|
||||
newRouting, err := json.Marshal(routing)
|
||||
if err != nil {
|
||||
logger.Warning("panel egress: failed to rebuild routing section, skipping injection:", err)
|
||||
return
|
||||
}
|
||||
cfg.RouterConfig = json_util.RawMessage(newRouting)
|
||||
|
||||
used := make(map[int]struct{}, len(cfg.InboundConfigs))
|
||||
for i := range cfg.InboundConfigs {
|
||||
used[cfg.InboundConfigs[i].Port] = struct{}{}
|
||||
}
|
||||
port := panelEgressBasePort
|
||||
for {
|
||||
if _, taken := used[port]; !taken {
|
||||
break
|
||||
}
|
||||
port++
|
||||
}
|
||||
|
||||
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
|
||||
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
||||
Port: port,
|
||||
Protocol: "socks",
|
||||
Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
|
||||
Tag: PanelEgressInboundTag,
|
||||
})
|
||||
}
|
||||
|
||||
// mergeSubscriptionOutbounds appends the subscription outbounds to the
|
||||
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
|
||||
// template so that manually configured outbounds are never overwritten.
|
||||
|
||||
@@ -2,11 +2,23 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// injectPanelEgress logs when it skips injection; the package logger must
|
||||
// exist before any test exercises a skipped path.
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestEnsureAPIServices(t *testing.T) {
|
||||
// legacy template without RoutingService gets it injected
|
||||
out := ensureAPIServices(json_util.RawMessage(`{"services":["HandlerService","LoggerService","StatsService"],"tag":"api"}`))
|
||||
@@ -41,3 +53,107 @@ func TestEnsureAPIServices(t *testing.T) {
|
||||
t.Fatalf("nil api block must stay nil, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func egressTestConfig() *xray.Config {
|
||||
return &xray.Config{
|
||||
RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
|
||||
InboundConfigs: []xray.InboundConfig{
|
||||
{Port: 62789, Protocol: "tunnel", Tag: "api", Listen: json_util.RawMessage(`"127.0.0.1"`)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type egressRouting struct {
|
||||
DomainStrategy string `json:"domainStrategy"`
|
||||
Rules []struct {
|
||||
InboundTag []string `json:"inboundTag"`
|
||||
OutboundTag string `json:"outboundTag"`
|
||||
Type string `json:"type"`
|
||||
} `json:"rules"`
|
||||
}
|
||||
|
||||
func TestInjectPanelEgress(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
injectPanelEgress(cfg, "warp")
|
||||
|
||||
if len(cfg.InboundConfigs) != 2 {
|
||||
t.Fatalf("expected the egress inbound to be appended, got %d inbounds", len(cfg.InboundConfigs))
|
||||
}
|
||||
ib := cfg.InboundConfigs[1]
|
||||
if ib.Tag != PanelEgressInboundTag || ib.Protocol != "socks" || ib.Port != panelEgressBasePort {
|
||||
t.Fatalf("unexpected egress inbound: %+v", ib)
|
||||
}
|
||||
if string(ib.Listen) != `"127.0.0.1"` {
|
||||
t.Fatalf("egress inbound must listen on loopback, got %s", ib.Listen)
|
||||
}
|
||||
|
||||
var routing egressRouting
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if routing.DomainStrategy != "AsIs" {
|
||||
t.Fatalf("routing keys outside rules must be preserved, got %+v", routing)
|
||||
}
|
||||
if len(routing.Rules) != 2 {
|
||||
t.Fatalf("expected egress rule + existing rule, got %+v", routing.Rules)
|
||||
}
|
||||
first := routing.Rules[0]
|
||||
if first.Type != "field" || first.OutboundTag != "warp" ||
|
||||
len(first.InboundTag) != 1 || first.InboundTag[0] != PanelEgressInboundTag {
|
||||
t.Fatalf("egress rule must be prepended, got %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPanelEgress_PortCollision(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
||||
xray.InboundConfig{Port: panelEgressBasePort, Protocol: "vless", Tag: "in-1"},
|
||||
xray.InboundConfig{Port: panelEgressBasePort + 1, Protocol: "vless", Tag: "in-2"},
|
||||
)
|
||||
injectPanelEgress(cfg, "direct")
|
||||
got := cfg.InboundConfigs[len(cfg.InboundConfigs)-1]
|
||||
if got.Tag != PanelEgressInboundTag || got.Port != panelEgressBasePort+2 {
|
||||
t.Fatalf("egress inbound must skip taken ports, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPanelEgress_TagCollisionSkips(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
||||
xray.InboundConfig{Port: 1234, Protocol: "socks", Tag: PanelEgressInboundTag},
|
||||
)
|
||||
before := string(cfg.RouterConfig)
|
||||
injectPanelEgress(cfg, "direct")
|
||||
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
|
||||
t.Fatal("a user inbound owning the egress tag must make injection a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPanelEgress_NoRoutingSection(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.RouterConfig = nil
|
||||
injectPanelEgress(cfg, "direct")
|
||||
|
||||
var routing egressRouting
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(routing.Rules) != 1 || routing.Rules[0].OutboundTag != "direct" {
|
||||
t.Fatalf("a routing section must be created with the egress rule, got %+v", routing)
|
||||
}
|
||||
if len(cfg.InboundConfigs) != 2 {
|
||||
t.Fatal("egress inbound must still be appended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPanelEgress_BadRoutingSkips(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.RouterConfig = json_util.RawMessage(`{not json`)
|
||||
injectPanelEgress(cfg, "direct")
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatal("unparsable routing must skip the whole injection, inbound included")
|
||||
}
|
||||
if string(cfg.RouterConfig) != `{not json` {
|
||||
t.Fatal("unparsable routing must be left untouched")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user