- Delete the xray settings page (routes/menu/title), geodata UI/service, XraySettingController/Service and routing-sync write paths; the stored xrayTemplateConfig row loses its last readers (GetXrayConfigTemplate now returns the embedded factory template; B1 already deleted the row). - Settings GeneralTab gains the four trafficStatsSwitches switches (settings.trafficStatsSwitches, B1) with the dual warning copy; entity AllSetting carries the key end to end (TestTrafficStatsSwitchesPlumbing). - Tag-directory consumers (panelOutbound, mtproto egress, node/host outbound pickers) move from POST /xray/ to GET /server/outboundDirectory. - Shared Mux/Sockopt forms and balancer helpers relocated to lib/xray; converter preserves sniffing ips/domains exclusions into the panel sidecar instead of dropping them (B2 gap closed by retention). - Freedom final-rules DB fix seeders retired; getDefaultJsonConfig endpoint removed (no in-repo consumer); locales pruned to referenced keys (pages.xray 331 -> 68) plus the five new settings keys.
245 lines
9.5 KiB
Go
245 lines
9.5 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
func TestParseAndValidateForwardingSpec(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
selfID int
|
|
wantErr string
|
|
}{
|
|
{"empty is direct", "", 0, ""},
|
|
{"direct mode", `{"mode":"direct"}`, 0, ""},
|
|
{"manual ok", `{"mode":"forward","landing":"manual","address":"10.0.0.2","port":1080,"protocol":"socks5","username":"u","password":"p"}`, 0, ""},
|
|
{"manual http ok", `{"mode":"forward","landing":"manual","address":"10.0.0.2","port":8080,"protocol":"http"}`, 0, ""},
|
|
{"manual no address", `{"mode":"forward","landing":"manual","port":1080,"protocol":"socks5"}`, 0, "address is required"},
|
|
{"manual bad port", `{"mode":"forward","landing":"manual","address":"10.0.0.2","port":70000,"protocol":"socks5"}`, 0, "out of range"},
|
|
{"manual bad protocol", `{"mode":"forward","landing":"manual","address":"10.0.0.2","port":1080,"protocol":"vless"}`, 0, "unsupported"},
|
|
{"managed ok", `{"mode":"forward","landing":"managed","targetInboundId":7}`, 0, ""},
|
|
{"managed no target", `{"mode":"forward","landing":"managed"}`, 0, "requires choosing"},
|
|
{"managed self", `{"mode":"forward","landing":"managed","targetInboundId":7}`, 7, "itself"},
|
|
{"unknown landing", `{"mode":"forward","landing":"carrier-pigeon"}`, 0, "unknown landing"},
|
|
{"broken json", `{"mode":`, 0, "invalid forwarding"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
spec, err := ParseForwardingSpec(c.raw)
|
|
if c.wantErr == "invalid forwarding" {
|
|
if err == nil || !strings.Contains(err.Error(), c.wantErr) {
|
|
t.Fatalf("Parse error = %v, want containing %q", err, c.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
err = validateForwardingSpec(spec, c.selfID)
|
|
if c.wantErr == "" && err != nil {
|
|
t.Fatalf("validate: %v", err)
|
|
}
|
|
if c.wantErr != "" && (err == nil || !strings.Contains(err.Error(), c.wantErr)) {
|
|
t.Fatalf("validate error = %v, want containing %q", err, c.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateForwardingManagedTarget(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflictNode(t, "landing-mixed", "10.240.64.2", 47814, model.Mixed, "",
|
|
`{"auth":"password","accounts":[{"user":"ngs_x","pass":"secret"}],"udp":true,"ip":"127.0.0.1"}`, nil)
|
|
seedInboundConflictNode(t, "plain-vless", "", 443, model.VLESS, "", moveTestSettings, nil)
|
|
|
|
var landing, plain model.Inbound
|
|
database.GetDB().Where("tag = ?", "landing-mixed").First(&landing)
|
|
database.GetDB().Where("tag = ?", "plain-vless").First(&plain)
|
|
|
|
svc := &InboundService{}
|
|
entry := &model.Inbound{Forwarding: `{"mode":"forward","landing":"managed","targetInboundId":` +
|
|
strconv.Itoa(landing.Id) + `}`}
|
|
if err := svc.validateForwarding(entry); err != nil {
|
|
t.Fatalf("managed → mixed should pass: %v", err)
|
|
}
|
|
entry.Forwarding = `{"mode":"forward","landing":"managed","targetInboundId":` + strconv.Itoa(plain.Id) + `}`
|
|
if err := svc.validateForwarding(entry); err == nil {
|
|
t.Fatal("managed → vless should fail")
|
|
}
|
|
entry.Forwarding = `{"mode":"forward","landing":"managed","targetInboundId":99999}`
|
|
if err := svc.validateForwarding(entry); err == nil {
|
|
t.Fatal("managed → missing target should fail")
|
|
}
|
|
}
|
|
|
|
|
|
type fwdOutbound struct {
|
|
Tag string `json:"tag"`
|
|
Protocol string `json:"protocol"`
|
|
Settings struct {
|
|
Servers []struct {
|
|
Address string `json:"address"`
|
|
Port int `json:"port"`
|
|
Users []struct {
|
|
User string `json:"user"`
|
|
Pass string `json:"pass"`
|
|
} `json:"users"`
|
|
} `json:"servers"`
|
|
} `json:"settings"`
|
|
}
|
|
|
|
func agentConfigParts(t *testing.T, nodeID int) ([]fwdOutbound, []map[string]any) {
|
|
t.Helper()
|
|
cfg, err := (&XrayService{}).GetXrayConfigForAgent(nodeID, "", "")
|
|
if err != nil {
|
|
t.Fatalf("GetXrayConfigForAgent: %v", err)
|
|
}
|
|
var outbounds []fwdOutbound
|
|
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
|
t.Fatalf("unmarshal outbounds: %v", err)
|
|
}
|
|
var routing struct {
|
|
Rules []map[string]any `json:"rules"`
|
|
}
|
|
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
|
t.Fatalf("unmarshal routing: %v", err)
|
|
}
|
|
return outbounds, routing.Rules
|
|
}
|
|
|
|
func findOutbound(outbounds []fwdOutbound, tag string) *fwdOutbound {
|
|
for i := range outbounds {
|
|
if outbounds[i].Tag == tag {
|
|
return &outbounds[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestAgentConfigCompilesForwarding(t *testing.T) {
|
|
setupConflictDB(t)
|
|
|
|
agentNode := &model.Node{Name: "fwd-agent", Enable: true, Status: "online", InboundsAdoptedAt: 1}
|
|
if err := database.GetDB().Create(agentNode).Error; err != nil {
|
|
t.Fatalf("create agent node: %v", err)
|
|
}
|
|
landingNode := &model.Node{Name: "fwd-landing", Address: "198.51.100.23", Enable: true, Status: "online", InboundsAdoptedAt: 1}
|
|
if err := database.GetDB().Create(landingNode).Error; err != nil {
|
|
t.Fatalf("create landing node: %v", err)
|
|
}
|
|
|
|
// Managed landing: mixed inbound pinned to its internal listen IP.
|
|
seedInboundConflictNode(t, "landing-mixed", "10.240.64.2", 47814, model.Mixed, "",
|
|
`{"auth":"password","accounts":[{"user":"ngs_x","pass":"secret"}],"udp":true,"ip":"127.0.0.1"}`, &landingNode.Id)
|
|
var landing model.Inbound
|
|
database.GetDB().Where("tag = ?", "landing-mixed").First(&landing)
|
|
|
|
// Entry A: manual forwarding.
|
|
seedInboundConflictNode(t, "entry-manual", "", 2445, model.VLESS, `{"network":"tcp"}`, moveTestSettings, &agentNode.Id)
|
|
// Entry B: managed forwarding to the landing above.
|
|
seedInboundConflictNode(t, "entry-managed", "", 2446, model.VLESS, `{"network":"tcp"}`, moveTestSettings, &agentNode.Id)
|
|
// Entry C: no forwarding.
|
|
seedInboundConflictNode(t, "entry-direct", "", 2447, model.VLESS, `{"network":"tcp"}`, moveTestSettings, &agentNode.Id)
|
|
|
|
var entryA, entryB model.Inbound
|
|
database.GetDB().Where("tag = ?", "entry-manual").First(&entryA)
|
|
database.GetDB().Where("tag = ?", "entry-managed").First(&entryB)
|
|
db := database.GetDB()
|
|
db.Model(&model.Inbound{}).Where("id = ?", entryA.Id).Update("forwarding",
|
|
`{"mode":"forward","landing":"manual","address":"192.0.2.10","port":1080,"protocol":"socks5","username":"u1","password":"p1"}`)
|
|
db.Model(&model.Inbound{}).Where("id = ?", entryB.Id).Update("forwarding",
|
|
`{"mode":"forward","landing":"managed","targetInboundId":`+strconv.Itoa(landing.Id)+`}`)
|
|
|
|
outbounds, rules := agentConfigParts(t, agentNode.Id)
|
|
|
|
tagA := "fwd-" + strconv.Itoa(entryA.Id)
|
|
obA := findOutbound(outbounds, tagA)
|
|
if obA == nil {
|
|
t.Fatalf("manual forwarding outbound %s missing; have %v", tagA, outbounds)
|
|
}
|
|
if obA.Protocol != "socks" || len(obA.Settings.Servers) != 1 {
|
|
t.Fatalf("manual outbound shape: %+v", obA)
|
|
}
|
|
srv := obA.Settings.Servers[0]
|
|
if srv.Address != "192.0.2.10" || srv.Port != 1080 || len(srv.Users) != 1 ||
|
|
srv.Users[0].User != "u1" || srv.Users[0].Pass != "p1" {
|
|
t.Fatalf("manual server = %+v", srv)
|
|
}
|
|
|
|
tagB := "fwd-" + strconv.Itoa(entryB.Id)
|
|
obB := findOutbound(outbounds, tagB)
|
|
if obB == nil {
|
|
t.Fatalf("managed forwarding outbound %s missing", tagB)
|
|
}
|
|
srvB := obB.Settings.Servers[0]
|
|
if srvB.Address != "10.240.64.2" || srvB.Port != 47814 || len(srvB.Users) != 1 ||
|
|
srvB.Users[0].User != "ngs_x" || srvB.Users[0].Pass != "secret" {
|
|
t.Fatalf("managed server = %+v (want listen IP + target account)", srvB)
|
|
}
|
|
|
|
// Routing rules for both entries point at their compiled outbounds.
|
|
ruleTargets := map[string]string{}
|
|
for _, r := range rules {
|
|
tags, _ := r["inboundTag"].([]any)
|
|
if len(tags) == 1 {
|
|
if s, ok := tags[0].(string); ok {
|
|
ruleTargets[s], _ = r["outboundTag"].(string)
|
|
}
|
|
}
|
|
}
|
|
if ruleTargets["entry-manual"] != tagA || ruleTargets["entry-managed"] != tagB {
|
|
t.Fatalf("rule targets = %v", ruleTargets)
|
|
}
|
|
|
|
// Template base must still be there (default freedom outbound) and the
|
|
// direct entry must not have produced a forwarding outbound.
|
|
if findOutbound(outbounds, "direct") == nil {
|
|
t.Fatal("template default outbound 'direct' lost")
|
|
}
|
|
for _, ob := range outbounds {
|
|
if strings.HasPrefix(ob.Tag, "fwd-") && ob.Tag != tagA && ob.Tag != tagB {
|
|
t.Fatalf("unexpected forwarding outbound %s", ob.Tag)
|
|
}
|
|
}
|
|
|
|
// Dynamic compile: edit the forwarding spec, rebuild, it applies —
|
|
// proves nothing was snapshotted into a stored egress. (ITEM6 B4:
|
|
// the stored xrayTemplateConfig row is retired — B1 deletes it and
|
|
// GetXrayConfigTemplate always returns the embedded factory
|
|
// template — so a leftover row edit must NOT leak into the agent
|
|
// config anymore; the second half below pins exactly that.)
|
|
settingSvc := &SettingService{}
|
|
tmpl, err := settingSvc.GetXrayConfigTemplate()
|
|
if err != nil {
|
|
t.Fatalf("read template: %v", err)
|
|
}
|
|
var tmplObj map[string]any
|
|
if err := json.Unmarshal([]byte(tmpl), &tmplObj); err != nil {
|
|
t.Fatalf("template json: %v", err)
|
|
}
|
|
tmplObj["outbounds"] = append(tmplObj["outbounds"].([]any),
|
|
map[string]any{"tag": "marker-tmpl", "protocol": "freedom"})
|
|
rawTmpl, _ := json.Marshal(tmplObj)
|
|
if err := settingSvc.setString("xrayTemplateConfig", string(rawTmpl)); err != nil {
|
|
t.Fatalf("write template: %v", err)
|
|
}
|
|
db.Model(&model.Inbound{}).Where("id = ?", entryA.Id).Update("forwarding",
|
|
`{"mode":"forward","landing":"manual","address":"192.0.2.10","port":2080,"protocol":"socks5","username":"u1","password":"p1"}`)
|
|
|
|
outbounds2, _ := agentConfigParts(t, agentNode.Id)
|
|
if findOutbound(outbounds2, "marker-tmpl") != nil {
|
|
t.Fatal("retired template row leaked into agent config (ITEM6 B4: template read path must stay closed)")
|
|
}
|
|
obA2 := findOutbound(outbounds2, tagA)
|
|
if obA2 == nil || obA2.Settings.Servers[0].Port != 2080 {
|
|
t.Fatalf("spec edit not reflected: %+v", obA2)
|
|
}
|
|
}
|