Files
Muse f6d652019d P9(3): per-inbound forwarding module (amo-style form, dynamic egress compile)
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.
2026-10-21 17:40:42 +00:00

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)
}
}