Files
3x-ui-agent/internal/web/service/forwarding.go
T
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

275 lines
8.9 KiB
Go

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