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,
"expiryTime": 0,
"fallbackParent": null,
"forwarding": "",
"id": 1,
"lastTrafficResetTime": 0,
"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.",
"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<string, unknown> = {
"down",
"enable",
"expiryTime",
"forwarding",
"id",
"lastTrafficResetTime",
"listen",
+1
View File
@@ -385,6 +385,7 @@ export interface Inbound {
enable: boolean;
expiryTime: number;
fallbackParent?: FallbackParentInfo | null;
forwarding: string;
id: number;
lastTrafficResetTime: number;
listen: string;
+1
View File
@@ -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(),
+20 -4
View File
@@ -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" —
@@ -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 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 = <SniffingTab />;
const forwardingTab = (
<ForwardingTab currentId={mode === 'edit' && dbInbound ? dbInbound.id : undefined} />
);
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,
@@ -14,6 +14,22 @@ export type TrafficReset = z.infer<typeof TrafficResetSchema>;
export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']);
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
// 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),