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