Files
3x-ui/internal/web/service/xray_config_inject_test.go
T
MHSanaei ca4f32e3da 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.
2026-06-10 23:52:20 +02:00

160 lines
5.2 KiB
Go

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"}`))
var parsed struct {
Services []string `json:"services"`
Tag string `json:"tag"`
}
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatal(err)
}
want := map[string]bool{"HandlerService": true, "StatsService": true, "RoutingService": true, "LoggerService": true}
if len(parsed.Services) != 4 {
t.Fatalf("expected 4 services, got %v", parsed.Services)
}
for _, svc := range parsed.Services {
if !want[svc] {
t.Fatalf("unexpected service %q", svc)
}
}
if parsed.Tag != "api" {
t.Fatalf("tag must be preserved, got %q", parsed.Tag)
}
// complete api block is returned unchanged (no marshal churn)
full := json_util.RawMessage(`{"services":["HandlerService","StatsService","RoutingService"],"tag":"api"}`)
if got := ensureAPIServices(full); string(got) != string(full) {
t.Fatalf("complete api block must pass through untouched, got %s", got)
}
// absent api block stays absent
if got := ensureAPIServices(nil); got != nil {
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")
}
}