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.
174 lines
6.4 KiB
TypeScript
174 lines
6.4 KiB
TypeScript
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')} />
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|