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:
@@ -419,6 +419,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"enable": true,
|
||||
"expiryTime": 0,
|
||||
"fallbackParent": null,
|
||||
"forwarding": "",
|
||||
"id": 1,
|
||||
"lastTrafficResetTime": 0,
|
||||
"listen": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -385,6 +385,7 @@ export interface Inbound {
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
fallbackParent?: FallbackParentInfo | null;
|
||||
forwarding: string;
|
||||
id: number;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user