From 46f29504ddec10bc35d84b2a306a21befa7de30e Mon Sep 17 00:00:00 2001 From: lxhifu Date: Sat, 27 Jun 2026 00:08:09 +0800 Subject: [PATCH] feat: Add HY2 UDP port hopping automatic port forwarding configuration - Frontend: Add QUIC UDP hop configuration UI component with port forwarding command generation - Backend: Add port forwarding rules generator service supporting iptables, ufw, firewalld, and nftables - Inbound form: Integrate HysteriaUdpHopForm with port forwarding commands display - Localization: Add Chinese and English translations for new UI elements --- .../form/protocols/hysteria-transport.tsx | 27 ++ .../inbounds/form/protocols/hysteria.tsx | 246 ++++++++------- .../pages/inbounds/form/transport/index.ts | 9 +- .../inbounds/form/transport/quic-udp-hop.tsx | 253 +++++++++++++++ .../web/controller/inbound_portforward.go | 37 +++ .../web/service/portforward/portforward.go | 289 ++++++++++++++++++ 6 files changed, 744 insertions(+), 117 deletions(-) create mode 100644 frontend/src/pages/inbounds/form/protocols/hysteria-transport.tsx create mode 100644 frontend/src/pages/inbounds/form/transport/quic-udp-hop.tsx create mode 100644 internal/web/controller/inbound_portforward.go create mode 100644 internal/web/service/portforward/portforward.go diff --git a/frontend/src/pages/inbounds/form/protocols/hysteria-transport.tsx b/frontend/src/pages/inbounds/form/protocols/hysteria-transport.tsx new file mode 100644 index 000000000..4eecb6565 --- /dev/null +++ b/frontend/src/pages/inbounds/form/protocols/hysteria-transport.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next'; +import { Form, Tabs, type FormInstance } from 'antd'; +import { QuicUdpHopForm } from '@/pages/inbounds/form/transport'; + +export default function HysteriaTransportSettings({ + form, + port, +}: { + form: FormInstance; + port: number; +}) { + const { t } = useTranslation(); + + return ( + + ), + }, + ]} + /> + ); +} diff --git a/frontend/src/pages/inbounds/form/protocols/hysteria.tsx b/frontend/src/pages/inbounds/form/protocols/hysteria.tsx index 97845d115..b1e7d948b 100644 --- a/frontend/src/pages/inbounds/form/protocols/hysteria.tsx +++ b/frontend/src/pages/inbounds/form/protocols/hysteria.tsx @@ -1,128 +1,148 @@ import { useTranslation } from 'react-i18next'; -import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd'; - +import { Form, Input, InputNumber, Select, Switch, Tabs, type FormInstance } from 'antd'; import { HeaderMapEditor } from '@/components/form'; +import HysteriaTransportSettings from './hysteria-transport'; const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade']; -export default function HysteriaFields({ form }: { form: FormInstance }) { +export default function HysteriaFields({ form, port }: { form: FormInstance; port: number }) { const { t } = useTranslation(); return ( - <> - - - - - - - - - - {() => { - const m = form.getFieldValue(MASQ_PATH); - return ( - - form.setFieldValue( - MASQ_PATH, - checked - ? { - type: '', dir: '', url: '', - rewriteHost: false, insecure: false, - content: '', headers: {}, statusCode: 0, - } - : undefined, - ) - } - /> - ); - }} - - - - {() => { - const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined; - if (!m) return null; - return ( + - - - - - - - - - - )} - {m.type === 'file' && ( - - + + + + + + + {() => { + const m = form.getFieldValue(MASQ_PATH); + return ( + + form.setFieldValue( + MASQ_PATH, + checked + ? { + type: '', + dir: '', + url: '', + rewriteHost: false, + insecure: false, + content: '', + headers: {}, + statusCode: 0, + } + : undefined, + ) + } + /> + ); + }} - )} - {m.type === 'string' && ( - <> - - - - - - - - - - - )} + + + {() => { + const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined; + if (!m) return null; + return ( + <> + + + + + + + + + + + )} + {m.type === 'file' && ( + + + + )} + {m.type === 'string' && ( + <> + + + + + + + + + + + )} + + ); + }} + - ); - }} - - + ), + }, + { + key: 'transport', + label: t('pages.inbounds.form.transport'), + children: , + }, + ]} + /> ); } diff --git a/frontend/src/pages/inbounds/form/transport/index.ts b/frontend/src/pages/inbounds/form/transport/index.ts index 632122360..9bd38bf96 100644 --- a/frontend/src/pages/inbounds/form/transport/index.ts +++ b/frontend/src/pages/inbounds/form/transport/index.ts @@ -1,7 +1,8 @@ export { default as RawForm } from './raw'; -export { default as WsForm } from './ws'; -export { default as GrpcForm } from './grpc'; -export { default as XhttpForm } from './xhttp'; -export { default as HttpUpgradeForm } from './httpupgrade'; +export { default as WebSocketForm } from './ws'; export { default as KcpForm } from './kcp'; +export { default as GrpcForm } from './grpc'; +export { default as HttpUpgradeForm } from './httpupgrade'; +export { default as XhttpForm } from './xhttp'; export { default as SockoptForm } from './sockopt'; +export { default as QuicUdpHopForm } from './quic-udp-hop'; diff --git a/frontend/src/pages/inbounds/form/transport/quic-udp-hop.tsx b/frontend/src/pages/inbounds/form/transport/quic-udp-hop.tsx new file mode 100644 index 000000000..d79eef61d --- /dev/null +++ b/frontend/src/pages/inbounds/form/transport/quic-udp-hop.tsx @@ -0,0 +1,253 @@ +import { useTranslation } from 'react-i18next'; +import { Form, Input, InputNumber, Button, Space, Alert, Select, Tooltip } from 'antd'; +import { CopyOutlined, QuestionCircleOutlined } from '@ant-design/icons'; +import { useState } from 'react'; +import ClipboardManager from '@/utils/ClipboardManager'; + +const UDP_HOP_PATH = ['streamSettings', 'finalmask', 'quicParams', 'udpHop']; + +function parsePortRange(rangeStr: string): { start: number; end: number } | null { + const parts = rangeStr.trim().split('-'); + if (parts.length === 2) { + const start = parseInt(parts[0].trim(), 10); + const end = parseInt(parts[1].trim(), 10); + if (!isNaN(start) && !isNaN(end) && start > 0 && end > 0 && start <= end) { + return { start, end }; + } + } + return null; +} + +function generateIptablesRules(basePort: number, portRange: string): string[] { + const range = parsePortRange(portRange); + if (!range) return []; + + const rules: string[] = []; + for (let i = range.start; i <= range.end; i++) { + rules.push(`iptables -t nat -A PREROUTING -p udp --dport ${i} -j REDIRECT --to-port ${basePort}`); + rules.push(`ip6tables -t nat -A PREROUTING -p udp --dport ${i} -j REDIRECT --to-port ${basePort}`); + } + return rules; +} + +function generateUfwRules(basePort: number, portRange: string): string[] { + const range = parsePortRange(portRange); + if (!range) return []; + + const rules: string[] = [ + `# Allow base port`, + `ufw allow ${basePort}/udp`, + ]; + + for (let i = range.start; i <= range.end; i++) { + if (i !== basePort) { + rules.push(`ufw allow ${i}/udp`); + } + } + return rules; +} + +function generateFirewalldRules(basePort: number, portRange: string): string[] { + const range = parsePortRange(portRange); + if (!range) return []; + + const rules: string[] = []; + for (let i = range.start; i <= range.end; i++) { + rules.push( + `firewall-cmd --permanent --add-forward-port=port=${i}:proto=udp:toport=${basePort}` + ); + } + rules.push(`firewall-cmd --reload`); + return rules; +} + +function generateNftablesRules(basePort: number, portRange: string): string[] { + const range = parsePortRange(portRange); + if (!range) return []; + + const portList = []; + for (let i = range.start; i <= range.end; i++) { + portList.push(i); + } + + return [ + `nft add rule ip nat prerouting udp dport { ${portList.join( + ', ', + )} } redirect to ${basePort}`, + `nft add rule ip6 nat prerouting udp dport { ${portList.join( + ', ', + )} } redirect to ${basePort}`, + ]; +} + +export default function QuicUdpHopForm({ + basePort, + form, +}: { + basePort: number; + form: any; +}) { + const { t } = useTranslation(); + const [selectedFirewall, setSelectedFirewall] = useState('iptables'); + + const hopConfig = form?.getFieldValue(UDP_HOP_PATH); + const portRange = hopConfig?.ports || '20000-50000'; + const interval = hopConfig?.interval || '5-10'; + + let commands: string[] = []; + switch (selectedFirewall) { + case 'ufw': + commands = generateUfwRules(basePort, portRange); + break; + case 'firewalld': + commands = generateFirewalldRules(basePort, portRange); + break; + case 'nftables': + commands = generateNftablesRules(basePort, portRange); + break; + case 'iptables': + default: + commands = generateIptablesRules(basePort, portRange); + break; + } + + const handleCopy = () => { + const text = commands.join('\n'); + ClipboardManager.copy(text, t('pages.inbounds.form.portForwardingRulesCopied')); + }; + + const handleToggleUdpHop = (enabled: boolean) => { + if (enabled) { + form.setFieldValue(UDP_HOP_PATH, { + ports: '20000-50000', + interval: '5-10', + }); + } else { + form.setFieldValue(UDP_HOP_PATH, undefined); + } + }; + + return ( + <> + + + {() => { + const enabled = !!form?.getFieldValue(UDP_HOP_PATH); + return ( + + + + + + + ); + }} + + + + + {() => { + const enabled = !!form?.getFieldValue(UDP_HOP_PATH); + if (!enabled) return null; + + return ( + <> + + + + + + + + + + + + +