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,
|
"enable": true,
|
||||||
"expiryTime": 0,
|
"expiryTime": 0,
|
||||||
"fallbackParent": null,
|
"fallbackParent": null,
|
||||||
|
"forwarding": "",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"lastTrafficResetTime": 0,
|
"lastTrafficResetTime": 0,
|
||||||
"listen": "",
|
"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.",
|
"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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user