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 === 'proxy' && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
- {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 === 'proxy' && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {commands.length > 0 && (
+ <>
+
+ {commands.join('\n')}
+
+ }
+ onClick={handleCopy}
+ style={{ marginTop: '8px' }}
+ >
+ {t('pages.inbounds.form.copyRules')}
+
+ >
+ )}
+
+ >
+ );
+ }}
+
+ >
+ );
+}
diff --git a/internal/web/controller/inbound_portforward.go b/internal/web/controller/inbound_portforward.go
new file mode 100644
index 000000000..355795138
--- /dev/null
+++ b/internal/web/controller/inbound_portforward.go
@@ -0,0 +1,37 @@
+package controller
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/mhsanaei/3x-ui/v3/internal/web/service/portforward"
+)
+
+// GenerateHysteriaPortForwardRules generates firewall rules for Hysteria UDP port hopping
+func (a *InboundController) GenerateHysteriaPortForwardRules(c *gin.Context) {
+ var req struct {
+ BasePort int `json:"basePort" binding:"required,min=1,max=65535"`
+ PortRange string `json:"portRange" binding:"required"`
+ FirewallType string `json:"firewallType"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(400, gin.H{"error": err.Error()})
+ return
+ }
+
+ fwType := portforward.FirewallType(req.FirewallType)
+ if fwType == "" {
+ fwType = portforward.FirewallIptables
+ }
+
+ generator, err := portforward.NewGenerator(req.BasePort, req.PortRange, fwType)
+ if err != nil {
+ c.JSON(400, gin.H{"error": err.Error()})
+ return
+ }
+
+ ruleSet := generator.Generate()
+ c.JSON(200, gin.H{
+ "firewallType": string(ruleSet.FirewallType),
+ "rules": ruleSet.ToStringSlice(),
+ })
+}
diff --git a/internal/web/service/portforward/portforward.go b/internal/web/service/portforward/portforward.go
new file mode 100644
index 000000000..7b53583af
--- /dev/null
+++ b/internal/web/service/portforward/portforward.go
@@ -0,0 +1,289 @@
+// Package portforward provides utilities for generating firewall port forwarding rules
+// for Hysteria2 UDP port hopping configuration.
+package portforward
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// PortRange represents a range of ports
+type PortRange struct {
+ Start int
+ End int
+}
+
+// ParsePortRange parses a port range string in format "start-end"
+func ParsePortRange(rangeStr string) (*PortRange, error) {
+ parts := strings.Split(strings.TrimSpace(rangeStr), "-")
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid port range format: expected 'start-end', got '%s'", rangeStr)
+ }
+
+ start, err := strconv.Atoi(strings.TrimSpace(parts[0]))
+ if err != nil || start <= 0 || start > 65535 {
+ return nil, fmt.Errorf("invalid start port: %s", parts[0])
+ }
+
+ end, err := strconv.Atoi(strings.TrimSpace(parts[1]))
+ if err != nil || end <= 0 || end > 65535 {
+ return nil, fmt.Errorf("invalid end port: %s", parts[1])
+ }
+
+ if start > end {
+ return nil, fmt.Errorf("start port (%d) cannot be greater than end port (%d)", start, end)
+ }
+
+ return &PortRange{Start: start, End: end}, nil
+}
+
+// FirewallType defines the type of firewall
+type FirewallType string
+
+const (
+ FirewallIptables FirewallType = "iptables"
+ FirewallUfw FirewallType = "ufw"
+ FirewallFirewalld FirewallType = "firewalld"
+ FirewallNftables FirewallType = "nftables"
+)
+
+// Rule represents a single firewall rule
+type Rule struct {
+ Command string
+ Comment string
+}
+
+// RuleSet represents a set of firewall rules
+type RuleSet struct {
+ FirewallType FirewallType
+ Rules []Rule
+}
+
+// Generator generates firewall rules for port forwarding
+type Generator struct {
+ BasePort int
+ PortRange *PortRange
+ RuleType FirewallType
+}
+
+// NewGenerator creates a new port forwarding rule generator
+func NewGenerator(basePort int, portRangeStr string, ruleType FirewallType) (*Generator, error) {
+ if basePort <= 0 || basePort > 65535 {
+ return nil, fmt.Errorf("invalid base port: %d", basePort)
+ }
+
+ pr, err := ParsePortRange(portRangeStr)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Generator{
+ BasePort: basePort,
+ PortRange: pr,
+ RuleType: ruleType,
+ }, nil
+}
+
+// GenerateIptables generates iptables rules for UDP port forwarding
+func (g *Generator) GenerateIptables() *RuleSet {
+ rules := []Rule{}
+ rules = append(rules, Rule{
+ Comment: "Flush existing UDP port hopping rules (optional - uncomment if needed)",
+ Command: "# iptables -t nat -D PREROUTING -p udp -j HYSTERIA_PORT_HOP 2>/dev/null || true",
+ })
+ rules = append(rules, Rule{
+ Comment: "Create new chain for UDP port hopping",
+ Command: "iptables -t nat -N HYSTERIA_PORT_HOP 2>/dev/null || true",
+ })
+ rules = append(rules, Rule{
+ Comment: "IPv6 version",
+ Command: "ip6tables -t nat -N HYSTERIA_PORT_HOP 2>/dev/null || true",
+ })
+
+ // Add rules for each port in the range
+ for port := g.PortRange.Start; port <= g.PortRange.End; port++ {
+ rules = append(rules, Rule{
+ Command: fmt.Sprintf("iptables -t nat -A HYSTERIA_PORT_HOP -p udp --dport %d -j REDIRECT --to-port %d", port, g.BasePort),
+ })
+ rules = append(rules, Rule{
+ Command: fmt.Sprintf("ip6tables -t nat -A HYSTERIA_PORT_HOP -p udp --dport %d -j REDIRECT --to-port %d", port, g.BasePort),
+ })
+ }
+
+ // Add jump rule to the main PREROUTING chain
+ rules = append(rules, Rule{
+ Comment: "Jump to custom chain from PREROUTING",
+ Command: "iptables -t nat -I PREROUTING 1 -j HYSTERIA_PORT_HOP",
+ })
+ rules = append(rules, Rule{
+ Command: "ip6tables -t nat -I PREROUTING 1 -j HYSTERIA_PORT_HOP",
+ })
+
+ // Save rules
+ rules = append(rules, Rule{
+ Comment: "Save rules permanently (Debian/Ubuntu)",
+ Command: "iptables-save > /etc/iptables/rules.v4",
+ })
+ rules = append(rules, Rule{
+ Command: "ip6tables-save > /etc/iptables/rules.v6",
+ })
+
+ return &RuleSet{
+ FirewallType: FirewallIptables,
+ Rules: rules,
+ }
+}
+
+// GenerateUfw generates UFW rules for UDP port forwarding
+func (g *Generator) GenerateUfw() *RuleSet {
+ rules := []Rule{}
+ rules = append(rules, Rule{
+ Comment: "Allow base port",
+ Command: fmt.Sprintf("ufw allow %d/udp", g.BasePort),
+ })
+
+ rules = append(rules, Rule{
+ Comment: "Allow ports in the hopping range",
+ Command: fmt.Sprintf("ufw allow %d:%d/udp", g.PortRange.Start, g.PortRange.End),
+ })
+
+ rules = append(rules, Rule{
+ Comment: "Enable UFW if not already enabled",
+ Command: "ufw enable",
+ })
+
+ return &RuleSet{
+ FirewallType: FirewallUfw,
+ Rules: rules,
+ }
+}
+
+// GenerateFirewalld generates firewalld rules for UDP port forwarding
+func (g *Generator) GenerateFirewalld() *RuleSet {
+ rules := []Rule{}
+ rules = append(rules, Rule{
+ Comment: "Add service zone",
+ Command: "firewall-cmd --permanent --new-service=hysteria2 2>/dev/null || true",
+ })
+
+ rules = append(rules, Rule{
+ Comment: "Set service port",
+ Command: fmt.Sprintf("firewall-cmd --permanent --service=hysteria2 --set-port=%d:udp", g.BasePort),
+ })
+
+ rules = append(rules, Rule{
+ Comment: "Add service to public zone",
+ Command: "firewall-cmd --permanent --zone=public --add-service=hysteria2",
+ })
+
+ // Add rules for port forwarding
+ for port := g.PortRange.Start; port <= g.PortRange.End; port++ {
+ if port != g.BasePort {
+ rules = append(rules, Rule{
+ Command: fmt.Sprintf("firewall-cmd --permanent --zone=public --add-forward-port=port=%d:proto=udp:toport=%d", port, g.BasePort),
+ })
+ }
+ }
+
+ rules = append(rules, Rule{
+ Comment: "Reload firewall to apply rules",
+ Command: "firewall-cmd --reload",
+ })
+
+ return &RuleSet{
+ FirewallType: FirewallFirewalld,
+ Rules: rules,
+ }
+}
+
+// GenerateNftables generates nftables rules for UDP port forwarding
+func (g *Generator) GenerateNftables() *RuleSet {
+ rules := []Rule{}
+
+ // Build port list
+ var portList []string
+ for port := g.PortRange.Start; port <= g.PortRange.End; port++ {
+ portList = append(portList, fmt.Sprintf("%d", port))
+ }
+ ports := strings.Join(portList, ", ")
+
+ rules = append(rules, Rule{
+ Comment: "Create table if it doesn't exist",
+ Command: "nft add table ip nat 2>/dev/null || true",
+ })
+ rules = append(rules, Rule{
+ Command: "nft add table ip6 nat 2>/dev/null || true",
+ })
+
+ rules = append(rules, Rule{
+ Comment: "Create chain if it doesn't exist",
+ Command: "nft add chain ip nat prerouting '{ type nat hook prerouting priority dstnat; policy accept; }' 2>/dev/null || true",
+ })
+ rules = append(rules, Rule{
+ Command: "nft add chain ip6 nat prerouting '{ type nat hook prerouting priority dstnat; policy accept; }' 2>/dev/null || true",
+ })
+
+ rules = append(rules, Rule{
+ Comment: "Add redirect rule for IPv4",
+ Command: fmt.Sprintf("nft add rule ip nat prerouting udp dport { %s } redirect to %d", ports, g.BasePort),
+ })
+ rules = append(rules, Rule{
+ Comment: "Add redirect rule for IPv6",
+ Command: fmt.Sprintf("nft add rule ip6 nat prerouting udp dport { %s } redirect to %d", ports, g.BasePort),
+ })
+
+ rules = append(rules, Rule{
+ Comment: "Save rules to file for persistence",
+ Command: "nft list ruleset > /etc/nftables.conf",
+ })
+
+ return &RuleSet{
+ FirewallType: FirewallNftables,
+ Rules: rules,
+ }
+}
+
+// Generate generates port forwarding rules based on the configured firewall type
+func (g *Generator) Generate() *RuleSet {
+ switch g.RuleType {
+ case FirewallUfw:
+ return g.GenerateUfw()
+ case FirewallFirewalld:
+ return g.GenerateFirewalld()
+ case FirewallNftables:
+ return g.GenerateNftables()
+ case FirewallIptables:
+ fallthrough
+ default:
+ return g.GenerateIptables()
+ }
+}
+
+// ToStringSlice converts the rule set to a slice of command strings
+func (rs *RuleSet) ToStringSlice() []string {
+ var result []string
+ for _, rule := range rs.Rules {
+ if rule.Comment != "" {
+ result = append(result, fmt.Sprintf("# %s", rule.Comment))
+ }
+ result = append(result, rule.Command)
+ }
+ return result
+}
+
+// ToString returns the rules as a formatted string
+func (rs *RuleSet) ToString() string {
+ return strings.Join(rs.ToStringSlice(), "\n")
+}
+
+// ValidatePortRange validates that a port range string is valid
+func ValidatePortRange(rangeStr string) error {
+ if !regexp.MustCompile(`^\d+-\d+$`).MatchString(strings.TrimSpace(rangeStr)) {
+ return fmt.Errorf("invalid port range format: expected 'start-end', got '%s'", rangeStr)
+ }
+
+ _, err := ParsePortRange(rangeStr)
+ return err
+}