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.
275 lines
8.9 KiB
Go
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
|
|
}
|
|
}
|