mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-04 15:17:27 +00:00
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
This commit is contained in:
parent
522b1b64b0
commit
46f29504dd
6 changed files with 744 additions and 117 deletions
|
|
@ -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 (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'quic',
|
||||
label: t('pages.inbounds.form.quicSettings'),
|
||||
children: (
|
||||
<QuicUdpHopForm basePort={port} form={form} />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.version')}
|
||||
name={['streamSettings', 'hysteriaSettings', 'version']}
|
||||
>
|
||||
<InputNumber min={2} max={2} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.udpIdleTimeout')}
|
||||
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
|
||||
>
|
||||
<InputNumber min={2} max={600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.inbounds.form.masquerade')}>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const m = form.getFieldValue(MASQ_PATH);
|
||||
return (
|
||||
<Switch
|
||||
checked={!!m}
|
||||
onChange={(checked) =>
|
||||
form.setFieldValue(
|
||||
MASQ_PATH,
|
||||
checked
|
||||
? {
|
||||
type: '', dir: '', url: '',
|
||||
rewriteHost: false, insecure: false,
|
||||
content: '', headers: {}, statusCode: 0,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
|
||||
if (!m) return null;
|
||||
return (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: t('pages.inbounds.form.basic'),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.type')}
|
||||
name={[...MASQ_PATH, 'type']}
|
||||
label={t('pages.inbounds.form.version')}
|
||||
name={['streamSettings', 'hysteriaSettings', 'version']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'default (404 page)' },
|
||||
{ value: 'proxy', label: 'proxy (reverse proxy)' },
|
||||
{ value: 'file', label: 'file (serve directory)' },
|
||||
{ value: 'string', label: 'string (fixed body)' },
|
||||
]}
|
||||
/>
|
||||
<InputNumber min={2} max={2} disabled />
|
||||
</Form.Item>
|
||||
{m.type === 'proxy' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.upstreamUrl')}
|
||||
name={[...MASQ_PATH, 'url']}
|
||||
>
|
||||
<Input placeholder="https://www.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.rewriteHost')}
|
||||
name={[...MASQ_PATH, 'rewriteHost']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.skipTlsVerify')}
|
||||
name={[...MASQ_PATH, 'insecure']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{m.type === 'file' && (
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.directory')}
|
||||
name={[...MASQ_PATH, 'dir']}
|
||||
>
|
||||
<Input placeholder="/var/www/html" />
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.udpIdleTimeout')}
|
||||
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
|
||||
>
|
||||
<InputNumber min={2} max={600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.inbounds.form.masquerade')}>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const m = form.getFieldValue(MASQ_PATH);
|
||||
return (
|
||||
<Switch
|
||||
checked={!!m}
|
||||
onChange={(checked) =>
|
||||
form.setFieldValue(
|
||||
MASQ_PATH,
|
||||
checked
|
||||
? {
|
||||
type: '',
|
||||
dir: '',
|
||||
url: '',
|
||||
rewriteHost: false,
|
||||
insecure: false,
|
||||
content: '',
|
||||
headers: {},
|
||||
statusCode: 0,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
)}
|
||||
{m.type === 'string' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.statusCode')}
|
||||
name={[...MASQ_PATH, 'statusCode']}
|
||||
>
|
||||
<InputNumber min={0} max={599} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.body')}
|
||||
name={[...MASQ_PATH, 'content']}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 3 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.headers')}
|
||||
name={[...MASQ_PATH, 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
|
||||
if (!m) return null;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.type')}
|
||||
name={[...MASQ_PATH, 'type']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'default (404 page)' },
|
||||
{ value: 'proxy', label: 'proxy (reverse proxy)' },
|
||||
{ value: 'file', label: 'file (serve directory)' },
|
||||
{ value: 'string', label: 'string (fixed body)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{m.type === 'proxy' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.upstreamUrl')}
|
||||
name={[...MASQ_PATH, 'url']}
|
||||
>
|
||||
<Input placeholder="https://www.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.rewriteHost')}
|
||||
name={[...MASQ_PATH, 'rewriteHost']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.skipTlsVerify')}
|
||||
name={[...MASQ_PATH, 'insecure']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{m.type === 'file' && (
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.directory')}
|
||||
name={[...MASQ_PATH, 'dir']}
|
||||
>
|
||||
<Input placeholder="/var/www/html" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{m.type === 'string' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.statusCode')}
|
||||
name={[...MASQ_PATH, 'statusCode']}
|
||||
>
|
||||
<InputNumber min={0} max={599} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.body')}
|
||||
name={[...MASQ_PATH, 'content']}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 3 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.headers')}
|
||||
name={[...MASQ_PATH, 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'transport',
|
||||
label: t('pages.inbounds.form.transport'),
|
||||
children: <HysteriaTransportSettings form={form} port={port} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
253
frontend/src/pages/inbounds/form/transport/quic-udp-hop.tsx
Normal file
253
frontend/src/pages/inbounds/form/transport/quic-udp-hop.tsx
Normal file
|
|
@ -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<string>('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 (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.enableUdpHop')}>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const enabled = !!form?.getFieldValue(UDP_HOP_PATH);
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
type={enabled ? 'primary' : 'default'}
|
||||
onClick={() => handleToggleUdpHop(!enabled)}
|
||||
>
|
||||
{enabled ? t('pages.inbounds.form.disableUdpHop') : t('pages.inbounds.form.enableUdpHop')}
|
||||
</Button>
|
||||
<Tooltip title={t('pages.inbounds.form.udpHopHelp')}>
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const enabled = !!form?.getFieldValue(UDP_HOP_PATH);
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.portRange')}
|
||||
name={[...UDP_HOP_PATH, 'ports']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('pages.inbounds.form.portRangeRequired'),
|
||||
},
|
||||
{
|
||||
pattern: /^\d+-\d+$/,
|
||||
message: t('pages.inbounds.form.portRangeFormat'),
|
||||
},
|
||||
]}
|
||||
tooltip={t('pages.inbounds.form.portRangeExample')}
|
||||
>
|
||||
<Input placeholder="20000-50000" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.hopInterval')}
|
||||
name={[...UDP_HOP_PATH, 'interval']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('pages.inbounds.form.hopIntervalRequired'),
|
||||
},
|
||||
{
|
||||
pattern: /^\d+-\d+$/,
|
||||
message: t('pages.inbounds.form.hopIntervalFormat'),
|
||||
},
|
||||
]}
|
||||
tooltip={t('pages.inbounds.form.hopIntervalExample')}
|
||||
>
|
||||
<Input placeholder="5-10" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.inbounds.form.portForwardingRules')}>
|
||||
<Alert
|
||||
type="info"
|
||||
message={t('pages.inbounds.form.portForwardingInfo')}
|
||||
showIcon
|
||||
style={{ marginBottom: '12px' }}
|
||||
/>
|
||||
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.firewallType')}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
style={{ marginBottom: '12px' }}
|
||||
value={selectedFirewall}
|
||||
onChange={setSelectedFirewall}
|
||||
options={[
|
||||
{ value: 'iptables', label: 'iptables (Linux)' },
|
||||
{ value: 'ufw', label: 'UFW (Debian/Ubuntu)' },
|
||||
{ value: 'firewalld', label: 'firewalld (RHEL/CentOS)' },
|
||||
{ value: 'nftables', label: 'nftables (Modern Linux)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{commands.length > 0 && (
|
||||
<>
|
||||
<pre
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
maxHeight: '200px',
|
||||
overflow: 'auto',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{commands.join('\n')}
|
||||
</pre>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={handleCopy}
|
||||
style={{ marginTop: '8px' }}
|
||||
>
|
||||
{t('pages.inbounds.form.copyRules')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
internal/web/controller/inbound_portforward.go
Normal file
37
internal/web/controller/inbound_portforward.go
Normal file
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
289
internal/web/service/portforward/portforward.go
Normal file
289
internal/web/service/portforward/portforward.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue