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 template and the spec, rebuild, both apply — // proves nothing was snapshotted into a stored egress. 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("template edit not reflected in agent config (egress snapshot?)") } obA2 := findOutbound(outbounds2, tagA) if obA2 == nil || obA2.Settings.Servers[0].Port != 2080 { t.Fatalf("spec edit not reflected: %+v", obA2) } }