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:
lxhifu 2026-06-27 00:08:09 +08:00
parent 522b1b64b0
commit 46f29504dd
6 changed files with 744 additions and 117 deletions

View file

@ -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} />
),
},
]}
/>
);
}

View file

@ -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} />,
},
]}
/>
);
}

View file

@ -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';

View 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>
</>
);
}

View 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(),
})
}

View 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
}