Design as decided 2026-10-21 (replaces the raw-JSON egress editor idea): the forwarding form lives on the inbound, and the entry agent's egress entries are compiled from it on every config pull — never snapshotted. - model: Inbound.Forwarding (JSON text) via AutoMigrate; panel-side metadata only, never reaches xray itself - service/forwarding.go: ForwardingSpec (mode direct/forward; landing managed|manual), save-time validation (managed target must exist and be a SOCKS-family landing — the fork's SK5 inbound is 'mixed'; no self-forward; manual endpoint/protocol sanity), live resolution (managed: concrete listen IP wins, else hosting node's address; credentials from the target's first account), and the compiler that appends one socks/http outbound (tag fwd-<inboundId>) plus a prepended inboundTag rule per forwarding inbound - buildXrayConfig agent path: compile runs after applyAgentEgress, so entries ride on the live template (or stored egress override) and template edits keep flowing; unresolvable entries skip with a warning instead of failing the whole config - UpdateInbound's field-merge now carries Forwarding (its absence was caught by the smoke: adds worked, updates silently kept the old spec) - GET /panel/api/inbounds/forwardTargets: managed-landing candidates (id/remark/tag/listen/port/node name) — credentials never exposed - frontend: new Forwarding tab in the inbound form (mode, landing type, managed picker, manual endpoint with link import incl. base64 form), adapter round-trips the spec as a JSON string like settings; en/zh strings; generated API types refreshed Acceptance: unit tests for parse/validate/resolve/compile incl. the dynamic-recompile proof (template edit + spec edit both reflected), and an end-to-end smoke (real binary, two agent nodes, all inbound writes in browser form-urlencoded format): 22/22 PASS — see P9-REPORT.md.
241 lines
9.2 KiB
Go
241 lines
9.2 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 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)
|
|
}
|
|
}
|