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.
This commit is contained in:
@@ -67,6 +67,7 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
|
||||
func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.getInbounds)
|
||||
g.GET("/list/slim", a.getInboundsSlim)
|
||||
g.GET("/forwardTargets", a.getForwardTargets)
|
||||
g.GET("/options", a.getInboundOptions)
|
||||
g.GET("/allLinks", a.getAllInboundLinks)
|
||||
g.GET("/get/:id", a.getInbound)
|
||||
@@ -97,6 +98,60 @@ func (a *InboundController) getInbounds(c *gin.Context) {
|
||||
jsonObj(c, inbounds, nil)
|
||||
}
|
||||
|
||||
// forwardTarget is one selectable managed landing for the inbound
|
||||
// forwarding form. Credentials are deliberately absent: the landing's
|
||||
// address/port/account are resolved server-side when the entry agent's
|
||||
// config is generated.
|
||||
type forwardTarget struct {
|
||||
Id int `json:"id"`
|
||||
Remark string `json:"remark"`
|
||||
Tag string `json:"tag"`
|
||||
Protocol string `json:"protocol"`
|
||||
Listen string `json:"listen"`
|
||||
Port int `json:"port"`
|
||||
Enable bool `json:"enable"`
|
||||
NodeId *int `json:"nodeId"`
|
||||
NodeName string `json:"nodeName"`
|
||||
}
|
||||
|
||||
// getForwardTargets lists the SOCKS-family (mixed) inbounds that can be
|
||||
// picked as a managed forwarding landing.
|
||||
func (a *InboundController) getForwardTargets(c *gin.Context) {
|
||||
user := session.GetLoginUser(c)
|
||||
inbounds, err := a.inboundService.GetInbounds(user.Id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
nodeService := service.NodeService{}
|
||||
nodes, _ := nodeService.GetAll()
|
||||
nodeNames := make(map[int]string, len(nodes))
|
||||
for _, n := range nodes {
|
||||
nodeNames[n.Id] = n.Name
|
||||
}
|
||||
targets := make([]forwardTarget, 0)
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Protocol != model.Mixed && inbound.Protocol != model.Protocol("socks") {
|
||||
continue
|
||||
}
|
||||
t := forwardTarget{
|
||||
Id: inbound.Id,
|
||||
Remark: inbound.Remark,
|
||||
Tag: inbound.Tag,
|
||||
Protocol: string(inbound.Protocol),
|
||||
Listen: inbound.Listen,
|
||||
Port: inbound.Port,
|
||||
Enable: inbound.Enable,
|
||||
NodeId: inbound.NodeID,
|
||||
}
|
||||
if inbound.NodeID != nil {
|
||||
t.NodeName = nodeNames[*inbound.NodeID]
|
||||
}
|
||||
targets = append(targets, t)
|
||||
}
|
||||
jsonObj(c, targets, nil)
|
||||
}
|
||||
|
||||
// getInboundsSlim is the list-page variant that strips full client
|
||||
// payloads from settings.clients[]. Detail-view flows still use /get/:id.
|
||||
func (a *InboundController) getInboundsSlim(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// ForwardingSpec is the stored shape of an inbound's 「转发」 (forwarding)
|
||||
// form (amo-style, per-inbound granularity). The form is what the panel
|
||||
// persists on the inbound row; the compiled egress (a socks/http outbound
|
||||
// plus an inboundTag routing rule on the entry agent) is derived from it
|
||||
// every time the agent's config is generated — never snapshotted — so
|
||||
// template edits keep applying to forwarding agents and the spec survives
|
||||
// inbound re-homing untouched.
|
||||
type ForwardingSpec struct {
|
||||
// Mode: "" or "direct" = no forwarding; "forward" = enabled.
|
||||
Mode string `json:"mode"`
|
||||
// Landing: "managed" (an existing mixed inbound in this panel) or
|
||||
// "manual" (a free-form endpoint).
|
||||
Landing string `json:"landing"`
|
||||
// Managed only: the landing inbound's id. Address/port/credentials are
|
||||
// resolved from that row at config-build time, never copied here.
|
||||
TargetInboundId int `json:"targetInboundId"`
|
||||
// Manual only: landing endpoint as dialed by the entry agent.
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"` // "socks5" | "http" (entry → exit transport)
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// Enabled reports whether the spec turns forwarding on.
|
||||
func (f *ForwardingSpec) Enabled() bool {
|
||||
return f != nil && f.Mode == "forward"
|
||||
}
|
||||
|
||||
// ParseForwardingSpec decodes the stored form. Empty input is a nil spec
|
||||
// (direct), not an error.
|
||||
func ParseForwardingSpec(raw string) (*ForwardingSpec, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var spec ForwardingSpec
|
||||
if err := json.Unmarshal([]byte(raw), &spec); err != nil {
|
||||
return nil, fmt.Errorf("invalid forwarding settings: %w", err)
|
||||
}
|
||||
return &spec, nil
|
||||
}
|
||||
|
||||
// validateForwardingSpec is the field-level save gate (no DB access).
|
||||
func validateForwardingSpec(spec *ForwardingSpec, selfID int) error {
|
||||
if !spec.Enabled() {
|
||||
return nil
|
||||
}
|
||||
switch spec.Landing {
|
||||
case "managed":
|
||||
if spec.TargetInboundId <= 0 {
|
||||
return fmt.Errorf("forwarding: a managed landing requires choosing a target inbound")
|
||||
}
|
||||
if selfID > 0 && spec.TargetInboundId == selfID {
|
||||
return fmt.Errorf("forwarding: an inbound cannot forward to itself")
|
||||
}
|
||||
case "manual":
|
||||
if strings.TrimSpace(spec.Address) == "" {
|
||||
return fmt.Errorf("forwarding: landing address is required")
|
||||
}
|
||||
if spec.Port < 1 || spec.Port > 65535 {
|
||||
return fmt.Errorf("forwarding: landing port %d out of range", spec.Port)
|
||||
}
|
||||
if spec.Protocol != "socks5" && spec.Protocol != "http" {
|
||||
return fmt.Errorf("forwarding: unsupported entry-to-exit protocol %q", spec.Protocol)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("forwarding: unknown landing type %q", spec.Landing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateForwarding is the save-time gate for AddInbound/UpdateInbound:
|
||||
// parse + field checks + (for managed landings) target existence and type.
|
||||
func (s *InboundService) validateForwarding(inbound *model.Inbound) error {
|
||||
spec, err := ParseForwardingSpec(inbound.Forwarding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateForwardingSpec(spec, inbound.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Enabled() && spec.Landing == "managed" {
|
||||
target, err := s.GetInbound(spec.TargetInboundId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("forwarding: target inbound %d not found", spec.TargetInboundId)
|
||||
}
|
||||
if !isForwardingLandingProtocol(target.Protocol) {
|
||||
return fmt.Errorf("forwarding: target inbound %d (%s) is not a SOCKS landing", target.Id, target.Protocol)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isForwardingLandingProtocol reports whether an inbound can serve as a
|
||||
// managed landing: the fork's SOCKS-family inbound is "mixed" (SOCKS+HTTP
|
||||
// combined); a bare "socks" row can only arrive via sync from an upstream
|
||||
// panel, and speaks the same wire protocol.
|
||||
func isForwardingLandingProtocol(p model.Protocol) bool {
|
||||
return p == model.Mixed || p == model.Protocol("socks")
|
||||
}
|
||||
|
||||
// resolvedLanding is a forwarding spec compiled to a concrete endpoint.
|
||||
type resolvedLanding struct {
|
||||
address string
|
||||
port int
|
||||
protocol string // xray outbound protocol: "socks" | "http"
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
// resolveForwardingLanding turns a spec into a concrete endpoint. Managed
|
||||
// landings read the target row live: a concrete listen IP wins (the user's
|
||||
// internal-network landings are recorded exactly that way), otherwise the
|
||||
// hosting node's address; credentials come from the target's first account.
|
||||
func (s *XrayService) resolveForwardingLanding(spec *ForwardingSpec) (*resolvedLanding, error) {
|
||||
if spec.Landing == "manual" {
|
||||
protocol := "socks"
|
||||
if spec.Protocol == "http" {
|
||||
protocol = "http"
|
||||
}
|
||||
return &resolvedLanding{
|
||||
address: strings.TrimSpace(spec.Address),
|
||||
port: spec.Port,
|
||||
protocol: protocol,
|
||||
username: spec.Username,
|
||||
password: spec.Password,
|
||||
}, nil
|
||||
}
|
||||
target, err := s.inboundService.GetInbound(spec.TargetInboundId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("target inbound %d: %w", spec.TargetInboundId, err)
|
||||
}
|
||||
if !isForwardingLandingProtocol(target.Protocol) {
|
||||
return nil, fmt.Errorf("target inbound %d is %s, not a SOCKS landing", target.Id, target.Protocol)
|
||||
}
|
||||
address := strings.TrimSpace(target.Listen)
|
||||
if address == "" || address == "0.0.0.0" || address == "::" {
|
||||
address = ""
|
||||
if target.NodeID != nil {
|
||||
if node, nerr := s.nodeService.GetById(*target.NodeID); nerr == nil && node != nil {
|
||||
address = strings.TrimSpace(node.Address)
|
||||
}
|
||||
}
|
||||
if address == "" {
|
||||
return nil, fmt.Errorf("target inbound %d has no resolvable landing address", target.Id)
|
||||
}
|
||||
}
|
||||
user, pass := socksAccountOf(target.Settings)
|
||||
return &resolvedLanding{
|
||||
address: address,
|
||||
port: target.Port,
|
||||
protocol: "socks",
|
||||
username: user,
|
||||
password: pass,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// socksAccountOf extracts the first account from a mixed/socks inbound's
|
||||
// settings. noauth (or account-less) settings yield empty credentials.
|
||||
func socksAccountOf(settings string) (string, string) {
|
||||
var st struct {
|
||||
Auth string `json:"auth"`
|
||||
Accounts []struct {
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(settings), &st); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if st.Auth == "noauth" || len(st.Accounts) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
return st.Accounts[0].User, st.Accounts[0].Pass
|
||||
}
|
||||
|
||||
// applyInboundForwarding compiles the forwarding specs of the inbounds homed
|
||||
// on one agent into extra outbounds and inboundTag routing rules on cfg. It
|
||||
// runs after applyAgentEgress, so entries ride on top of whichever egress
|
||||
// base is in effect (live template or the stored per-agent override) and
|
||||
// template edits keep flowing to forwarding agents. Unresolvable entries
|
||||
// are skipped with a warning — a stale landing must never take down the
|
||||
// whole agent config.
|
||||
func (s *XrayService) applyInboundForwarding(cfg *xray.Config, inbounds []*model.Inbound) {
|
||||
var outbounds []any
|
||||
var rules []any
|
||||
for _, inbound := range inbounds {
|
||||
spec, err := ParseForwardingSpec(inbound.Forwarding)
|
||||
if err != nil {
|
||||
logger.Warningf("Inbound %s: %v (forwarding ignored)", inbound.Tag, err)
|
||||
continue
|
||||
}
|
||||
if !spec.Enabled() {
|
||||
continue
|
||||
}
|
||||
if spec.Landing == "managed" && spec.TargetInboundId == inbound.Id {
|
||||
logger.Warningf("Inbound %s: forwarding target is itself, ignored", inbound.Tag)
|
||||
continue
|
||||
}
|
||||
landing, err := s.resolveForwardingLanding(spec)
|
||||
if err != nil {
|
||||
logger.Warningf("Inbound %s: forwarding skipped: %v", inbound.Tag, err)
|
||||
continue
|
||||
}
|
||||
tag := fmt.Sprintf("fwd-%d", inbound.Id)
|
||||
server := map[string]any{
|
||||
"address": landing.address,
|
||||
"port": landing.port,
|
||||
}
|
||||
if landing.username != "" {
|
||||
server["users"] = []any{map[string]any{
|
||||
"user": landing.username,
|
||||
"pass": landing.password,
|
||||
}}
|
||||
}
|
||||
outbounds = append(outbounds, map[string]any{
|
||||
"tag": tag,
|
||||
"protocol": landing.protocol,
|
||||
"settings": map[string]any{"servers": []any{server}},
|
||||
})
|
||||
rules = append(rules, map[string]any{
|
||||
"type": "field",
|
||||
"inboundTag": []any{inbound.Tag},
|
||||
"outboundTag": tag,
|
||||
})
|
||||
}
|
||||
if len(outbounds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var existing []any
|
||||
if len(cfg.OutboundConfigs) > 0 {
|
||||
_ = json.Unmarshal(cfg.OutboundConfigs, &existing)
|
||||
}
|
||||
present := make(map[string]bool, len(existing))
|
||||
for _, o := range existing {
|
||||
if m, ok := o.(map[string]any); ok {
|
||||
if t, ok := m["tag"].(string); ok {
|
||||
present[t] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range outbounds {
|
||||
if tag := o.(map[string]any)["tag"].(string); !present[tag] {
|
||||
existing = append(existing, o)
|
||||
}
|
||||
}
|
||||
if raw, err := json.Marshal(existing); err == nil {
|
||||
cfg.OutboundConfigs = raw
|
||||
}
|
||||
|
||||
routing := map[string]any{}
|
||||
if len(cfg.RouterConfig) > 0 {
|
||||
_ = json.Unmarshal(cfg.RouterConfig, &routing)
|
||||
}
|
||||
oldRules, _ := routing["rules"].([]any)
|
||||
routing["rules"] = append(rules, oldRules...)
|
||||
if raw, err := json.Marshal(routing); err == nil {
|
||||
cfg.RouterConfig = raw
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -966,6 +966,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if err := normalizeInboundShareAddressStrict(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := s.validateForwarding(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
|
||||
tag, err := s.resolveInboundTag(inbound, 0)
|
||||
if err != nil {
|
||||
@@ -1477,6 +1480,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
return inbound, false, err
|
||||
}
|
||||
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
|
||||
if err := s.validateForwarding(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
|
||||
clients, err := s.GetClients(inbound)
|
||||
if err != nil {
|
||||
@@ -1642,6 +1648,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
oldInbound.Settings = inbound.Settings
|
||||
oldInbound.StreamSettings = inbound.StreamSettings
|
||||
oldInbound.Sniffing = inbound.Sniffing
|
||||
oldInbound.Forwarding = inbound.Forwarding
|
||||
if strings.TrimSpace(inbound.ShareAddrStrategy) == "" {
|
||||
normalizeInboundShareAddress(oldInbound)
|
||||
inbound.ShareAddrStrategy = oldInbound.ShareAddrStrategy
|
||||
|
||||
@@ -208,6 +208,9 @@ func (s *XrayService) buildXrayConfig(nodeID *int, outboundsJSON, routingRulesJS
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Inbounds that made it into this build, kept for the agent-only
|
||||
// forwarding compilation below.
|
||||
var includedInbounds []*model.Inbound
|
||||
for _, inbound := range inbounds {
|
||||
if !inbound.Enable {
|
||||
continue
|
||||
@@ -394,6 +397,7 @@ func (s *XrayService) buildXrayConfig(nodeID *int, outboundsJSON, routingRulesJS
|
||||
|
||||
inboundConfig := inbound.GenXrayInboundConfig()
|
||||
xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
|
||||
includedInbounds = append(includedInbounds, inbound)
|
||||
}
|
||||
|
||||
if nodeID != nil {
|
||||
@@ -402,6 +406,10 @@ func (s *XrayService) buildXrayConfig(nodeID *int, outboundsJSON, routingRulesJS
|
||||
if err := applyAgentEgress(xrayConfig, outboundsJSON, routingRulesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Per-inbound forwarding forms compile on top of that base, freshly
|
||||
// on every pull — the template stays the single source for the
|
||||
// shared outbounds and no egress snapshot is ever stored.
|
||||
s.applyInboundForwarding(xrayConfig, includedInbounds)
|
||||
return xrayConfig, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -282,6 +282,28 @@
|
||||
"streamTab": "Stream",
|
||||
"securityTab": "Security",
|
||||
"sniffingTab": "Sniffing",
|
||||
"forwardingTab": "Forwarding",
|
||||
"forwarding": {
|
||||
"mode": "Forwarding",
|
||||
"direct": "Direct",
|
||||
"forward": "Forward (entry → exit)",
|
||||
"landing": "Landing",
|
||||
"managed": "Managed landing",
|
||||
"manual": "Manual endpoint",
|
||||
"target": "Landing inbound",
|
||||
"targetPlaceholder": "Pick a SOCKS (mixed) inbound",
|
||||
"targetDisabled": "disabled",
|
||||
"address": "Landing address",
|
||||
"port": "Exit port",
|
||||
"protocol": "Entry → exit protocol",
|
||||
"username": "SOCKS5 username",
|
||||
"password": "Landing password",
|
||||
"importLink": "Import from link",
|
||||
"importPlaceholder": "socks5://user:pass@host:port",
|
||||
"importApply": "Import",
|
||||
"importFailed": "Could not parse that link",
|
||||
"help": "Applies when this inbound is deployed to an agent node: on every config pull the panel compiles this forwarding into a dedicated outbound plus a routing rule for that agent. A managed landing's address and credentials are resolved from the chosen inbound at build time."
|
||||
},
|
||||
"sniffingMetadataOnly": "Metadata only",
|
||||
"sniffingRouteOnly": "Route only",
|
||||
"sniffingIpsExcluded": "IPs excluded",
|
||||
|
||||
@@ -282,6 +282,28 @@
|
||||
"streamTab": "传输",
|
||||
"securityTab": "安全",
|
||||
"sniffingTab": "嗅探",
|
||||
"forwardingTab": "转发",
|
||||
"forwarding": {
|
||||
"mode": "转发",
|
||||
"direct": "直连",
|
||||
"forward": "转发(入口→出口)",
|
||||
"landing": "落地方式",
|
||||
"managed": "受管落地",
|
||||
"manual": "手动端点",
|
||||
"target": "落地入站",
|
||||
"targetPlaceholder": "选择一个 SK5(mixed)入站",
|
||||
"targetDisabled": "已停用",
|
||||
"address": "落地地址",
|
||||
"port": "出口端口",
|
||||
"protocol": "入口到出口协议",
|
||||
"username": "SOCKS5 用户名",
|
||||
"password": "落地密码",
|
||||
"importLink": "从链接导入",
|
||||
"importPlaceholder": "socks5://user:pass@host:port",
|
||||
"importApply": "导入",
|
||||
"importFailed": "链接解析失败",
|
||||
"help": "仅当本入站部署到 agent 节点时生效:该 agent 每次拉取配置时,面板把这条转发编译成专属出站加一条路由规则拼进它的配置。受管落地的地址与账密在生成时从所选入站实时解析。"
|
||||
},
|
||||
"sniffingMetadataOnly": "仅元数据",
|
||||
"sniffingRouteOnly": "仅路由",
|
||||
"sniffingIpsExcluded": "排除的 IP",
|
||||
|
||||
Reference in New Issue
Block a user