From f6d652019d7a6ab0ec4c6d8e8028f906845a51e7 Mon Sep 17 00:00:00 2001 From: Muse Date: Wed, 21 Oct 2026 17:40:42 +0000 Subject: [PATCH] P9(3): per-inbound forwarding module (amo-style form, dynamic egress compile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-) 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. --- frontend/src/generated/examples.ts | 1 + frontend/src/generated/schemas.ts | 5 + frontend/src/generated/types.ts | 1 + frontend/src/generated/zod.ts | 1 + frontend/src/lib/xray/inbound-form-adapter.ts | 24 +- .../src/pages/inbounds/form/ForwardingTab.tsx | 173 +++++++++++ .../pages/inbounds/form/InboundFormModal.tsx | 12 + frontend/src/schemas/forms/inbound-form.ts | 26 ++ internal/database/model/model.go | 7 + internal/web/controller/inbound.go | 55 ++++ internal/web/service/forwarding.go | 274 ++++++++++++++++++ internal/web/service/forwarding_test.go | 240 +++++++++++++++ internal/web/service/inbound.go | 7 + internal/web/service/xray.go | 8 + internal/web/translation/en-US.json | 22 ++ internal/web/translation/zh-CN.json | 22 ++ 16 files changed, 874 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/inbounds/form/ForwardingTab.tsx create mode 100644 internal/web/service/forwarding.go create mode 100644 internal/web/service/forwarding_test.go diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 06ec29d..76876cf 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -419,6 +419,7 @@ export const EXAMPLES: Record = { "enable": true, "expiryTime": 0, "fallbackParent": null, + "forwarding": "", "id": 1, "lastTrafficResetTime": 0, "listen": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index a78fc49..6cc8c1d 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1647,6 +1647,10 @@ export const SCHEMAS: Record = { "description": "FallbackParent is populated by the API layer when this inbound is\nattached as a fallback child of a VLESS/Trojan TCP-TLS master.\nThe frontend uses it to rewrite client-share links so they advertise\nthe master's externally reachable endpoint instead of the child's\nloopback listen. Not persisted.", "nullable": true }, + "forwarding": { + "description": "Forwarding holds the per-inbound forwarding form as JSON\n(service.ForwardingSpec). Empty means direct egress. Panel-side\nmetadata only: when an agent pulls its config, the entry agent's\noutbounds/routing gain a compiled entry for this inbound — the raw\nstring itself never reaches xray.", + "type": "string" + }, "id": { "description": "Unique identifier", "example": 1, @@ -1757,6 +1761,7 @@ export const SCHEMAS: Record = { "down", "enable", "expiryTime", + "forwarding", "id", "lastTrafficResetTime", "listen", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index de8b47d..5d48249 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -385,6 +385,7 @@ export interface Inbound { enable: boolean; expiryTime: number; fallbackParent?: FallbackParentInfo | null; + forwarding: string; id: number; lastTrafficResetTime: number; listen: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index ba6acd0..7edf92b 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -422,6 +422,7 @@ export const InboundSchema = z.object({ enable: z.boolean(), expiryTime: z.number().int(), fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(), + forwarding: z.string(), id: z.number().int(), lastTrafficResetTime: z.number().int(), listen: z.string(), diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index ec8b107..3186403 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -1,7 +1,9 @@ -import type { - InboundFormValues, - ShareAddrStrategy, - TrafficReset, +import { + ForwardingSpecSchema, + type ForwardingSpec, + type InboundFormValues, + type ShareAddrStrategy, + type TrafficReset, } from '@/schemas/forms/inbound-form'; import type { InboundSettings } from '@/schemas/protocols/inbound'; import { @@ -49,6 +51,7 @@ export interface RawInboundRow { trafficResetDay?: number; lastTrafficResetTime?: number; nodeId?: number | null; + forwarding?: unknown; shareAddrStrategy?: string; shareAddr?: string; subSortIndex?: number; @@ -78,6 +81,7 @@ export interface WireInboundPayload { tag: string; clientStats?: unknown; nodeId?: number | null; + forwarding: string; shareAddrStrategy: ShareAddrStrategy; shareAddr: string; subSortIndex: number; @@ -111,6 +115,11 @@ function coerceTrafficReset(v: unknown): TrafficReset { : 'never'; } +function coerceForwarding(v: unknown): ForwardingSpec { + const parsed = ForwardingSpecSchema.safeParse(coerceJsonObject(v)); + return parsed.success ? parsed.data : ForwardingSpecSchema.parse({}); +} + function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy { return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v) ? (v as ShareAddrStrategy) @@ -213,6 +222,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)), lastTrafficResetTime: row.lastTrafficResetTime ?? 0, nodeId: row.nodeId ?? null, + forwarding: coerceForwarding(row.forwarding), shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy), shareAddr: row.shareAddr ?? '', subSortIndex: Math.max(1, row.subSortIndex ?? 1), @@ -383,6 +393,12 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP shareAddr: values.shareAddr, subSortIndex: values.subSortIndex, disableFlow: values.disableFlow, + // Forwarding rides as a JSON string like settings; direct mode stores + // an empty string so the row carries no forwarding residue. + forwarding: + values.forwarding && values.forwarding.mode === 'forward' + ? JSON.stringify(values.forwarding) + : '', }; // nodeId is always emitted (null = local panel): the update endpoint // distinguishes an explicit null — "move the inbound back to local" — diff --git a/frontend/src/pages/inbounds/form/ForwardingTab.tsx b/frontend/src/pages/inbounds/form/ForwardingTab.tsx new file mode 100644 index 0000000..1e6495f --- /dev/null +++ b/frontend/src/pages/inbounds/form/ForwardingTab.tsx @@ -0,0 +1,173 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useFormContext, useWatch } from 'react-hook-form'; +import { Alert, Button, Form, Input, InputNumber, Radio, Select, Space } from 'antd'; + +import { FormField } from '@/components/form/rhf'; +import { HttpUtil } from '@/utils'; +import type { ForwardingSpec } from '@/schemas/forms/inbound-form'; + +interface ForwardTarget { + id: number; + remark: string; + tag: string; + protocol: string; + listen: string; + port: number; + enable: boolean; + nodeId: number | null; + nodeName: string; +} + +// Parse a landing link into manual-forwarding fields. Accepts the plain +// URL form (socks5://user:pass@host:port, also socks:// and http://) and +// the whole-link base64 variant common in share links. +export function parseLandingLink(raw: string): Partial | null { + const link = raw.trim(); + if (!link) return null; + const tryUrl = (s: string): Partial | null => { + try { + const u = new URL(s); + if (!['socks5:', 'socks:', 'http:', 'https:'].includes(u.protocol)) return null; + const port = u.port ? parseInt(u.port, 10) : 0; + if (!u.hostname || !port) return null; + return { + protocol: u.protocol === 'http:' || u.protocol === 'https:' ? 'http' : 'socks5', + address: u.hostname, + port, + username: decodeURIComponent(u.username || ''), + password: decodeURIComponent(u.password || ''), + }; + } catch { + return null; + } + }; + const direct = tryUrl(link); + if (direct) return direct; + try { + const decoded = atob(link.replace(/-/g, '+').replace(/_/g, '/')); + return tryUrl(decoded.includes('://') ? decoded : `socks5://${decoded}`); + } catch { + return null; + } +} + +export default function ForwardingTab({ currentId }: { currentId?: number }) { + const { t } = useTranslation(); + const { control, setValue } = useFormContext(); + const mode = useWatch({ control, name: 'forwarding.mode' }); + const landing = useWatch({ control, name: 'forwarding.landing' }); + const [targets, setTargets] = useState([]); + const [link, setLink] = useState(''); + const [linkError, setLinkError] = useState(''); + + useEffect(() => { + let alive = true; + HttpUtil.get('/panel/api/inbounds/forwardTargets') + .then((msg) => { + if (alive && msg?.success && Array.isArray(msg.obj)) { + setTargets(msg.obj as ForwardTarget[]); + } + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + + const applyLink = () => { + const parsed = parseLandingLink(link); + if (!parsed) { + setLinkError(t('pages.inbounds.forwarding.importFailed')); + return; + } + setLinkError(''); + const opts = { shouldDirty: true, shouldValidate: true }; + setValue('forwarding.protocol', parsed.protocol ?? 'socks5', opts); + setValue('forwarding.address', parsed.address ?? '', opts); + setValue('forwarding.port', parsed.port ?? 0, opts); + setValue('forwarding.username', parsed.username ?? '', opts); + setValue('forwarding.password', parsed.password ?? '', opts); + }; + + return ( + <> + + + + + {mode === 'forward' && ( + <> + + + + + {landing === 'managed' ? ( + + + + + + + + + + + + + + + setLink(e.target.value)} + placeholder={t('pages.inbounds.forwarding.importPlaceholder')} + /> + + + {linkError &&
{linkError}
} +
+ + )} + + + + )} + + ); +} diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 1f4a50f..3e9fe64 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -82,6 +82,7 @@ import { useSecurityActions } from './useSecurityActions'; import { useInboundFallbacks } from './useInboundFallbacks'; import FallbacksCard from './FallbacksCard'; import SniffingTab from './SniffingTab'; +import ForwardingTab from './ForwardingTab'; import type { DBInbound } from '@/models/dbinbound'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; @@ -150,6 +151,7 @@ function firstRhfValidationIssue( function tabForValidationPath(path: PropertyKey[]): string { if (path[0] === 'settings') return 'protocol'; if (path[0] === 'sniffing') return 'sniffing'; + if (path[0] === 'forwarding') return 'forwarding'; if (path[0] === 'streamSettings') { if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings') return 'security'; @@ -1078,6 +1080,10 @@ export default function InboundFormModal({ const sniffingTab = ; + const forwardingTab = ( + + ); + return ( <> {messageContextHolder} @@ -1111,6 +1117,12 @@ export default function InboundFormModal({ children: basicTab, forceRender: true, }, + { + key: 'forwarding', + label: t('pages.inbounds.forwardingTab'), + children: forwardingTab, + forceRender: true, + }, ...(( [ Protocols.VLESS, diff --git a/frontend/src/schemas/forms/inbound-form.ts b/frontend/src/schemas/forms/inbound-form.ts index e9916ce..c88b3f8 100644 --- a/frontend/src/schemas/forms/inbound-form.ts +++ b/frontend/src/schemas/forms/inbound-form.ts @@ -14,6 +14,22 @@ export type TrafficReset = z.infer; export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']); export type ShareAddrStrategy = z.infer; +// Per-inbound forwarding form (the panel-side 「转发」 section). Persisted on +// the inbound row as a JSON string; the backend compiles it into the entry +// agent's egress at config-pull time. mode 'direct' (or an absent spec) +// means no forwarding. +export const ForwardingSpecSchema = z.object({ + mode: z.enum(['direct', 'forward']).default('direct'), + landing: z.enum(['managed', 'manual']).default('managed'), + targetInboundId: z.number().int().default(0), + address: z.string().default(''), + port: z.number().int().min(0).max(65535).default(0), + protocol: z.enum(['socks5', 'http']).default('socks5'), + username: z.string().default(''), + password: z.string().default(''), +}); +export type ForwardingSpec = z.infer; + // Db-side fields layered on top of the xray slice. These mirror the // DBInbound model — they live in the SQL row, not in xray's config. export const InboundDbFieldsSchema = z.object({ @@ -24,6 +40,16 @@ export const InboundDbFieldsSchema = z.object({ trafficResetDay: z.number().int().min(1).max(31).default(1), lastTrafficResetTime: z.number().int().default(0), nodeId: z.number().int().nullable().optional(), + forwarding: ForwardingSpecSchema.default({ + mode: 'direct', + landing: 'managed', + targetInboundId: 0, + address: '', + port: 0, + protocol: 'socks5', + username: '', + password: '', + }), shareAddrStrategy: ShareAddrStrategySchema.default('node'), shareAddr: z.string().default(''), subSortIndex: z.number().int().min(1).default(1), diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 423954b..f0a0be0 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -71,6 +71,13 @@ type Inbound struct { ShareAddrStrategy string `json:"shareAddrStrategy" form:"shareAddrStrategy" gorm:"column:share_addr_strategy;default:node" validate:"omitempty,oneof=node listen custom"` ShareAddr string `json:"shareAddr" form:"shareAddr" gorm:"column:share_addr"` + // Forwarding holds the per-inbound forwarding form as JSON + // (service.ForwardingSpec). Empty means direct egress. Panel-side + // metadata only: when an agent pulls its config, the entry agent's + // outbounds/routing gain a compiled entry for this inbound — the raw + // string itself never reaches xray. + Forwarding string `json:"forwarding" form:"forwarding" gorm:"type:text"` + DisableFlow bool `json:"disableFlow" form:"disableFlow" gorm:"column:disable_flow;default:false" example:"false"` // OriginNodeGuid is the panelGuid of the node that physically hosts this diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go index 2d93101..bdae033 100644 --- a/internal/web/controller/inbound.go +++ b/internal/web/controller/inbound.go @@ -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) { diff --git a/internal/web/service/forwarding.go b/internal/web/service/forwarding.go new file mode 100644 index 0000000..f39c242 --- /dev/null +++ b/internal/web/service/forwarding.go @@ -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 + } +} diff --git a/internal/web/service/forwarding_test.go b/internal/web/service/forwarding_test.go new file mode 100644 index 0000000..a218c37 --- /dev/null +++ b/internal/web/service/forwarding_test.go @@ -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) + } +} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index c0b317a..7b10bef 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -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 diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index d95ed38..ad3de5e 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -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 } diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index cb9a441..3c8a39f 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 093ca5a..3c844d7 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -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",