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:
Muse
2026-10-21 17:40:42 +00:00
parent ceca96571d
commit f6d652019d
16 changed files with 874 additions and 4 deletions
+1
View File
@@ -419,6 +419,7 @@ export const EXAMPLES: Record<string, unknown> = {
"enable": true, "enable": true,
"expiryTime": 0, "expiryTime": 0,
"fallbackParent": null, "fallbackParent": null,
"forwarding": "",
"id": 1, "id": 1,
"lastTrafficResetTime": 0, "lastTrafficResetTime": 0,
"listen": "", "listen": "",
+5
View File
@@ -1647,6 +1647,10 @@ export const SCHEMAS: Record<string, unknown> = {
"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.", "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 "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": { "id": {
"description": "Unique identifier", "description": "Unique identifier",
"example": 1, "example": 1,
@@ -1757,6 +1761,7 @@ export const SCHEMAS: Record<string, unknown> = {
"down", "down",
"enable", "enable",
"expiryTime", "expiryTime",
"forwarding",
"id", "id",
"lastTrafficResetTime", "lastTrafficResetTime",
"listen", "listen",
+1
View File
@@ -385,6 +385,7 @@ export interface Inbound {
enable: boolean; enable: boolean;
expiryTime: number; expiryTime: number;
fallbackParent?: FallbackParentInfo | null; fallbackParent?: FallbackParentInfo | null;
forwarding: string;
id: number; id: number;
lastTrafficResetTime: number; lastTrafficResetTime: number;
listen: string; listen: string;
+1
View File
@@ -422,6 +422,7 @@ export const InboundSchema = z.object({
enable: z.boolean(), enable: z.boolean(),
expiryTime: z.number().int(), expiryTime: z.number().int(),
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(), fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
forwarding: z.string(),
id: z.number().int(), id: z.number().int(),
lastTrafficResetTime: z.number().int(), lastTrafficResetTime: z.number().int(),
listen: z.string(), listen: z.string(),
+20 -4
View File
@@ -1,7 +1,9 @@
import type { import {
InboundFormValues, ForwardingSpecSchema,
ShareAddrStrategy, type ForwardingSpec,
TrafficReset, type InboundFormValues,
type ShareAddrStrategy,
type TrafficReset,
} from '@/schemas/forms/inbound-form'; } from '@/schemas/forms/inbound-form';
import type { InboundSettings } from '@/schemas/protocols/inbound'; import type { InboundSettings } from '@/schemas/protocols/inbound';
import { import {
@@ -49,6 +51,7 @@ export interface RawInboundRow {
trafficResetDay?: number; trafficResetDay?: number;
lastTrafficResetTime?: number; lastTrafficResetTime?: number;
nodeId?: number | null; nodeId?: number | null;
forwarding?: unknown;
shareAddrStrategy?: string; shareAddrStrategy?: string;
shareAddr?: string; shareAddr?: string;
subSortIndex?: number; subSortIndex?: number;
@@ -78,6 +81,7 @@ export interface WireInboundPayload {
tag: string; tag: string;
clientStats?: unknown; clientStats?: unknown;
nodeId?: number | null; nodeId?: number | null;
forwarding: string;
shareAddrStrategy: ShareAddrStrategy; shareAddrStrategy: ShareAddrStrategy;
shareAddr: string; shareAddr: string;
subSortIndex: number; subSortIndex: number;
@@ -111,6 +115,11 @@ function coerceTrafficReset(v: unknown): TrafficReset {
: 'never'; : 'never';
} }
function coerceForwarding(v: unknown): ForwardingSpec {
const parsed = ForwardingSpecSchema.safeParse(coerceJsonObject(v));
return parsed.success ? parsed.data : ForwardingSpecSchema.parse({});
}
function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy { function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v) return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
? (v as ShareAddrStrategy) ? (v as ShareAddrStrategy)
@@ -213,6 +222,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)), trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
lastTrafficResetTime: row.lastTrafficResetTime ?? 0, lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
nodeId: row.nodeId ?? null, nodeId: row.nodeId ?? null,
forwarding: coerceForwarding(row.forwarding),
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy), shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
shareAddr: row.shareAddr ?? '', shareAddr: row.shareAddr ?? '',
subSortIndex: Math.max(1, row.subSortIndex ?? 1), subSortIndex: Math.max(1, row.subSortIndex ?? 1),
@@ -383,6 +393,12 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
shareAddr: values.shareAddr, shareAddr: values.shareAddr,
subSortIndex: values.subSortIndex, subSortIndex: values.subSortIndex,
disableFlow: values.disableFlow, 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 // nodeId is always emitted (null = local panel): the update endpoint
// distinguishes an explicit null — "move the inbound back to local" — // distinguishes an explicit null — "move the inbound back to local" —
@@ -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<ForwardingSpec> | null {
const link = raw.trim();
if (!link) return null;
const tryUrl = (s: string): Partial<ForwardingSpec> | 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<ForwardTarget[]>([]);
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 (
<>
<FormField name="forwarding.mode" label={t('pages.inbounds.forwarding.mode')}>
<Radio.Group
options={[
{ value: 'direct', label: t('pages.inbounds.forwarding.direct') },
{ value: 'forward', label: t('pages.inbounds.forwarding.forward') },
]}
/>
</FormField>
{mode === 'forward' && (
<>
<FormField name="forwarding.landing" label={t('pages.inbounds.forwarding.landing')}>
<Radio.Group
options={[
{ value: 'managed', label: t('pages.inbounds.forwarding.managed') },
{ value: 'manual', label: t('pages.inbounds.forwarding.manual') },
]}
/>
</FormField>
{landing === 'managed' ? (
<FormField
name="forwarding.targetInboundId"
label={t('pages.inbounds.forwarding.target')}
>
<Select
showSearch
placeholder={t('pages.inbounds.forwarding.targetPlaceholder')}
options={targets
.filter((x) => x.id !== currentId)
.map((x) => ({
value: x.id,
disabled: !x.enable,
label: `${x.nodeName || t('pages.inbounds.localPanel')} · ${x.remark || x.tag} (${x.listen || '*'}:${x.port})${x.enable ? '' : ` (${t('pages.inbounds.forwarding.targetDisabled')})`}`,
}))}
/>
</FormField>
) : (
<>
<FormField name="forwarding.address" label={t('pages.inbounds.forwarding.address')}>
<Input placeholder="10.240.64.2" />
</FormField>
<FormField name="forwarding.port" label={t('pages.inbounds.forwarding.port')}>
<InputNumber min={1} max={65535} />
</FormField>
<FormField name="forwarding.protocol" label={t('pages.inbounds.forwarding.protocol')}>
<Select
options={[
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'http', label: 'HTTP' },
]}
/>
</FormField>
<FormField name="forwarding.username" label={t('pages.inbounds.forwarding.username')}>
<Input autoComplete="off" />
</FormField>
<FormField name="forwarding.password" label={t('pages.inbounds.forwarding.password')}>
<Input.Password autoComplete="off" />
</FormField>
<Form.Item label={t('pages.inbounds.forwarding.importLink')}>
<Space.Compact style={{ width: '100%' }}>
<Input
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder={t('pages.inbounds.forwarding.importPlaceholder')}
/>
<Button onClick={applyLink}>{t('pages.inbounds.forwarding.importApply')}</Button>
</Space.Compact>
{linkError && <div style={{ color: '#ff4d4f', marginTop: 4 }}>{linkError}</div>}
</Form.Item>
</>
)}
<Alert type="info" showIcon message={t('pages.inbounds.forwarding.help')} />
</>
)}
</>
);
}
@@ -82,6 +82,7 @@ import { useSecurityActions } from './useSecurityActions';
import { useInboundFallbacks } from './useInboundFallbacks'; import { useInboundFallbacks } from './useInboundFallbacks';
import FallbacksCard from './FallbacksCard'; import FallbacksCard from './FallbacksCard';
import SniffingTab from './SniffingTab'; import SniffingTab from './SniffingTab';
import ForwardingTab from './ForwardingTab';
import type { DBInbound } from '@/models/dbinbound'; import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { NodeRecord } from '@/api/queries/useNodesQuery';
@@ -150,6 +151,7 @@ function firstRhfValidationIssue(
function tabForValidationPath(path: PropertyKey[]): string { function tabForValidationPath(path: PropertyKey[]): string {
if (path[0] === 'settings') return 'protocol'; if (path[0] === 'settings') return 'protocol';
if (path[0] === 'sniffing') return 'sniffing'; if (path[0] === 'sniffing') return 'sniffing';
if (path[0] === 'forwarding') return 'forwarding';
if (path[0] === 'streamSettings') { if (path[0] === 'streamSettings') {
if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings') if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings')
return 'security'; return 'security';
@@ -1078,6 +1080,10 @@ export default function InboundFormModal({
const sniffingTab = <SniffingTab />; const sniffingTab = <SniffingTab />;
const forwardingTab = (
<ForwardingTab currentId={mode === 'edit' && dbInbound ? dbInbound.id : undefined} />
);
return ( return (
<> <>
{messageContextHolder} {messageContextHolder}
@@ -1111,6 +1117,12 @@ export default function InboundFormModal({
children: basicTab, children: basicTab,
forceRender: true, forceRender: true,
}, },
{
key: 'forwarding',
label: t('pages.inbounds.forwardingTab'),
children: forwardingTab,
forceRender: true,
},
...(( ...((
[ [
Protocols.VLESS, Protocols.VLESS,
@@ -14,6 +14,22 @@ export type TrafficReset = z.infer<typeof TrafficResetSchema>;
export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']); export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']);
export type ShareAddrStrategy = z.infer<typeof ShareAddrStrategySchema>; export type ShareAddrStrategy = z.infer<typeof ShareAddrStrategySchema>;
// 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<typeof ForwardingSpecSchema>;
// Db-side fields layered on top of the xray slice. These mirror the // 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. // DBInbound model — they live in the SQL row, not in xray's config.
export const InboundDbFieldsSchema = z.object({ export const InboundDbFieldsSchema = z.object({
@@ -24,6 +40,16 @@ export const InboundDbFieldsSchema = z.object({
trafficResetDay: z.number().int().min(1).max(31).default(1), trafficResetDay: z.number().int().min(1).max(31).default(1),
lastTrafficResetTime: z.number().int().default(0), lastTrafficResetTime: z.number().int().default(0),
nodeId: z.number().int().nullable().optional(), 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'), shareAddrStrategy: ShareAddrStrategySchema.default('node'),
shareAddr: z.string().default(''), shareAddr: z.string().default(''),
subSortIndex: z.number().int().min(1).default(1), subSortIndex: z.number().int().min(1).default(1),
+7
View File
@@ -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"` 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"` 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"` 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 // OriginNodeGuid is the panelGuid of the node that physically hosts this
+55
View File
@@ -67,6 +67,7 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
func (a *InboundController) initRouter(g *gin.RouterGroup) { func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.getInbounds) g.GET("/list", a.getInbounds)
g.GET("/list/slim", a.getInboundsSlim) g.GET("/list/slim", a.getInboundsSlim)
g.GET("/forwardTargets", a.getForwardTargets)
g.GET("/options", a.getInboundOptions) g.GET("/options", a.getInboundOptions)
g.GET("/allLinks", a.getAllInboundLinks) g.GET("/allLinks", a.getAllInboundLinks)
g.GET("/get/:id", a.getInbound) g.GET("/get/:id", a.getInbound)
@@ -97,6 +98,60 @@ func (a *InboundController) getInbounds(c *gin.Context) {
jsonObj(c, inbounds, nil) 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 // getInboundsSlim is the list-page variant that strips full client
// payloads from settings.clients[]. Detail-view flows still use /get/:id. // payloads from settings.clients[]. Detail-view flows still use /get/:id.
func (a *InboundController) getInboundsSlim(c *gin.Context) { func (a *InboundController) getInboundsSlim(c *gin.Context) {
+274
View File
@@ -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
}
}
+240
View File
@@ -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)
}
}
+7
View File
@@ -966,6 +966,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if err := normalizeInboundShareAddressStrict(inbound); err != nil { if err := normalizeInboundShareAddressStrict(inbound); err != nil {
return inbound, false, err return inbound, false, err
} }
if err := s.validateForwarding(inbound); err != nil {
return inbound, false, err
}
tag, err := s.resolveInboundTag(inbound, 0) tag, err := s.resolveInboundTag(inbound, 0)
if err != nil { if err != nil {
@@ -1477,6 +1480,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
return inbound, false, err return inbound, false, err
} }
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex) inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
if err := s.validateForwarding(inbound); err != nil {
return inbound, false, err
}
clients, err := s.GetClients(inbound) clients, err := s.GetClients(inbound)
if err != nil { if err != nil {
@@ -1642,6 +1648,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldInbound.Settings = inbound.Settings oldInbound.Settings = inbound.Settings
oldInbound.StreamSettings = inbound.StreamSettings oldInbound.StreamSettings = inbound.StreamSettings
oldInbound.Sniffing = inbound.Sniffing oldInbound.Sniffing = inbound.Sniffing
oldInbound.Forwarding = inbound.Forwarding
if strings.TrimSpace(inbound.ShareAddrStrategy) == "" { if strings.TrimSpace(inbound.ShareAddrStrategy) == "" {
normalizeInboundShareAddress(oldInbound) normalizeInboundShareAddress(oldInbound)
inbound.ShareAddrStrategy = oldInbound.ShareAddrStrategy inbound.ShareAddrStrategy = oldInbound.ShareAddrStrategy
+8
View File
@@ -208,6 +208,9 @@ func (s *XrayService) buildXrayConfig(nodeID *int, outboundsJSON, routingRulesJS
if err != nil { if err != nil {
return nil, err 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 { for _, inbound := range inbounds {
if !inbound.Enable { if !inbound.Enable {
continue continue
@@ -394,6 +397,7 @@ func (s *XrayService) buildXrayConfig(nodeID *int, outboundsJSON, routingRulesJS
inboundConfig := inbound.GenXrayInboundConfig() inboundConfig := inbound.GenXrayInboundConfig()
xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig) xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
includedInbounds = append(includedInbounds, inbound)
} }
if nodeID != nil { if nodeID != nil {
@@ -402,6 +406,10 @@ func (s *XrayService) buildXrayConfig(nodeID *int, outboundsJSON, routingRulesJS
if err := applyAgentEgress(xrayConfig, outboundsJSON, routingRulesJSON); err != nil { if err := applyAgentEgress(xrayConfig, outboundsJSON, routingRulesJSON); err != nil {
return nil, err 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 return xrayConfig, nil
} }
+22
View File
@@ -282,6 +282,28 @@
"streamTab": "Stream", "streamTab": "Stream",
"securityTab": "Security", "securityTab": "Security",
"sniffingTab": "Sniffing", "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", "sniffingMetadataOnly": "Metadata only",
"sniffingRouteOnly": "Route only", "sniffingRouteOnly": "Route only",
"sniffingIpsExcluded": "IPs excluded", "sniffingIpsExcluded": "IPs excluded",
+22
View File
@@ -282,6 +282,28 @@
"streamTab": "传输", "streamTab": "传输",
"securityTab": "安全", "securityTab": "安全",
"sniffingTab": "嗅探", "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": "仅元数据", "sniffingMetadataOnly": "仅元数据",
"sniffingRouteOnly": "仅路由", "sniffingRouteOnly": "仅路由",
"sniffingIpsExcluded": "排除的 IP", "sniffingIpsExcluded": "排除的 IP",