feat: Add inbound interface restriction option for ufw forward rules (#10345) (#10549)

* 修复https防窜站关闭时修改默认站点报错的问题

* feat: Add inbound interface restriction option for ufw forward rules (#10345)

---------

Co-authored-by: live <Anxys@outlook.com>
This commit is contained in:
双向极端 2025-10-02 20:33:20 +08:00 committed by GitHub
parent 72a0e0200e
commit d0bb9165bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 109 additions and 40 deletions

View file

@ -37,6 +37,7 @@ type ForwardRuleOperate struct {
Operation string `json:"operation" validate:"required,oneof=add remove"`
Num string `json:"num"`
Protocol string `json:"protocol" validate:"required,oneof=tcp udp tcp/udp"`
Interface string `json:"interface"`
Port string `json:"port" validate:"required"`
TargetIP string `json:"targetIP"`
TargetPort string `json:"targetPort" validate:"required"`

View file

@ -18,4 +18,5 @@ type Forward struct {
Port string `gorm:"not null" json:"port"`
TargetIP string `gorm:"not null" json:"targetIP"`
TargetPort string `gorm:"not null" json:"targetPort"`
Interface string `json:"interface"`
}

View file

@ -329,7 +329,8 @@ func (u *FirewallService) OperateForwardRule(req dto.ForwardRuleOperate) error {
if reqRule.Port == rule.Port &&
reqRule.TargetPort == rule.TargetPort &&
reqRule.TargetIP == rule.TargetIP &&
proto == rule.Protocol {
proto == rule.Protocol &&
reqRule.Interface == rule.Interface {
shouldKeep = false
break
}
@ -353,7 +354,8 @@ func (u *FirewallService) OperateForwardRule(req dto.ForwardRuleOperate) error {
if reqRule.Port == rule.Port &&
reqRule.TargetPort == rule.TargetPort &&
reqRule.TargetIP == rule.TargetIP &&
proto == rule.Protocol {
proto == rule.Protocol &&
reqRule.Interface == rule.Interface {
return buserr.New("ErrRecordExist")
}
}
@ -383,6 +385,7 @@ func (u *FirewallService) OperateForwardRule(req dto.ForwardRuleOperate) error {
Port: r.Port,
TargetIP: r.TargetIP,
TargetPort: r.TargetPort,
Interface: r.Interface,
}, r.Operation); err != nil {
if req.ForceDelete {
global.LOG.Error(err)

View file

@ -26,7 +26,7 @@ import (
)
var AddTable = &gormigrate.Migration{
ID: "20250902-add-table",
ID: "20250930-add-table",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
&model.AppDetail{},

View file

@ -10,6 +10,7 @@ type FireInfo struct {
Num string `json:"num"`
TargetIP string `json:"targetIP"`
TargetPort string `json:"targetPort"`
Interface string `json:"interface"`
UsedStatus string `json:"usedStatus"`
Description string `json:"description"`
@ -21,12 +22,15 @@ type Forward struct {
Port string `json:"port"`
TargetIP string `json:"targetIP"`
TargetPort string `json:"targetPort"`
Interface string `json:"interface"`
}
type IptablesNatInfo struct {
Num string `json:"num"`
Target string `json:"target"`
Protocol string `json:"protocol"`
InIface string `json:"inIface"`
OutIface string `json:"outIface"`
Opt string `json:"opt"`
Source string `json:"source"`
Destination string `json:"destination"`

View file

@ -25,7 +25,7 @@ const (
const NatChain = "1PANEL"
var (
natListRegex = regexp.MustCompile(`^(\d+)\s+(.+?)\s+(.+?)\s+(.+?)\s+(.+?)\s+(.+?)(?:\s+(.+?) .+?:(\d{1,5}(?::\d+)?).+?[ :](.+-.+|(?:.+:)?\d{1,5}(?:-\d{1,5})?))?$`)
natListRegex = regexp.MustCompile(`^(\d+)\s+(.+?)\s+(.+?)\s+(.+?)\s+(.+?)\s+(.+?)\s+(.+?)\s+(.+?)\s+(.+?)\s+(.+?)(?:\s+(.+?) .+?:(\d{1,5}(?::\d+)?).+?[ :](.+-.+|(?:.+:)?\d{1,5}(?:-\d{1,5})?))?$`)
)
type Iptables struct {
@ -92,7 +92,7 @@ func (iptables *Iptables) NatList(chain ...string) ([]IptablesNatInfo, error) {
if len(chain) == 0 {
chain = append(chain, PreRoutingChain)
}
stdout, err := iptables.outf(NatTab, "-nL %s --line", chain[0])
stdout, err := iptables.outf(NatTab, "-nvL %s --line-numbers", chain[0])
if err != nil {
return nil, err
}
@ -104,18 +104,20 @@ func (iptables *Iptables) NatList(chain ...string) ([]IptablesNatInfo, error) {
})
if natListRegex.MatchString(line) {
match := natListRegex.FindStringSubmatch(line)
if !strings.Contains(match[9], ":") {
match[9] = fmt.Sprintf(":%s", match[9])
if !strings.Contains(match[13], ":") {
match[13] = fmt.Sprintf(":%s", match[13])
}
forwardList = append(forwardList, IptablesNatInfo{
Num: match[1],
Target: match[2],
Protocol: match[7],
Opt: match[4],
Source: match[5],
Destination: match[6],
SrcPort: match[8],
DestPort: match[9],
Target: match[4],
Protocol: match[11],
InIface: match[7],
OutIface: match[8],
Opt: match[6],
Source: match[9],
Destination: match[10],
SrcPort: match[12],
DestPort: match[13],
})
}
}
@ -123,16 +125,14 @@ func (iptables *Iptables) NatList(chain ...string) ([]IptablesNatInfo, error) {
return forwardList, nil
}
func (iptables *Iptables) NatAdd(protocol, srcPort, dest, destPort string, save bool) error {
func (iptables *Iptables) NatAdd(protocol, srcPort, dest, destPort, iface string, save bool) error {
if dest != "" && dest != "127.0.0.1" && dest != "localhost" {
if err := iptables.runf(NatTab, fmt.Sprintf(
"-A %s -p %s --dport %s -j DNAT --to-destination %s:%s",
PreRoutingChain,
protocol,
srcPort,
dest,
destPort,
)); err != nil {
iptablesArg := fmt.Sprintf("-A %s", PreRoutingChain)
if iface != "" {
iptablesArg += fmt.Sprintf(" -i %s", iface)
}
iptablesArg += fmt.Sprintf(" -p %s --dport %s -j DNAT --to-destination %s:%s", protocol, srcPort, dest, destPort)
if err := iptables.runf(NatTab, iptablesArg); err != nil {
return err
}
@ -166,13 +166,12 @@ func (iptables *Iptables) NatAdd(protocol, srcPort, dest, destPort string, save
return err
}
} else {
if err := iptables.runf(NatTab, fmt.Sprintf(
"-A %s -p %s --dport %s -j REDIRECT --to-port %s",
PreRoutingChain,
protocol,
srcPort,
destPort,
)); err != nil {
iptablesArg := fmt.Sprintf("-A %s", PreRoutingChain)
if iface != "" {
iptablesArg += fmt.Sprintf(" -i %s", iface)
}
iptablesArg += fmt.Sprintf(" -p %s --dport %s -j REDIRECT --to-port %s", protocol, srcPort, destPort)
if err := iptables.runf(NatTab, iptablesArg); err != nil {
return err
}
}
@ -183,12 +182,13 @@ func (iptables *Iptables) NatAdd(protocol, srcPort, dest, destPort string, save
Port: srcPort,
TargetIP: dest,
TargetPort: destPort,
Interface: iface,
}).Error
}
return nil
}
func (iptables *Iptables) NatRemove(num string, protocol, srcPort, dest, destPort string) error {
func (iptables *Iptables) NatRemove(num string, protocol, srcPort, dest, destPort, iface string) error {
if err := iptables.runf(NatTab, "-D %s %s", PreRoutingChain, num); err != nil {
return err
}
@ -226,11 +226,13 @@ func (iptables *Iptables) NatRemove(num string, protocol, srcPort, dest, destPor
}
global.DB.Where(
"protocol = ? AND port = ? AND target_ip = ? AND target_port = ?",
"protocol = ? AND port = ? AND target_ip = ? AND target_port = ? AND (interface = ? OR (interface IS NULL AND ? = ''))",
protocol,
srcPort,
dest,
destPort,
iface,
iface,
).Delete(&model.Forward{})
return nil
}
@ -249,7 +251,7 @@ func (iptables *Iptables) Reload() error {
var rules []model.Forward
global.DB.Find(&rules)
for _, forward := range rules {
if err := iptables.NatAdd(forward.Protocol, forward.Port, forward.TargetIP, forward.TargetPort, false); err != nil {
if err := iptables.NatAdd(forward.Protocol, forward.Port, forward.TargetIP, forward.TargetPort, forward.Interface, false); err != nil {
return err
}
}

View file

@ -124,6 +124,7 @@ func (f *Ufw) ListForward() ([]FireInfo, error) {
list = append(list, FireInfo{
Num: rule.Num,
Protocol: rule.Protocol,
Interface: rule.InIface,
Port: rule.SrcPort,
TargetIP: dest[0],
TargetPort: dest[1],
@ -241,9 +242,9 @@ func (f *Ufw) PortForward(info Forward, operation string) error {
}
if operation == "add" {
err = iptables.NatAdd(info.Protocol, info.Port, info.TargetIP, info.TargetPort, true)
err = iptables.NatAdd(info.Protocol, info.Port, info.TargetIP, info.TargetPort, info.Interface, true)
} else {
err = iptables.NatRemove(info.Num, info.Protocol, info.Port, info.TargetIP, info.TargetPort)
err = iptables.NatRemove(info.Num, info.Protocol, info.Port, info.TargetIP, info.TargetPort, info.Interface)
}
if err != nil {
return fmt.Errorf("%s port forward failed, err: %s", operation, err)

View file

@ -15,7 +15,7 @@ export namespace Dashboard {
title: string;
detail: string;
recommend: number;
isShow: boolean ;
isShow: boolean;
router: string;
}
export interface AppLauncher {

View file

@ -112,6 +112,7 @@ export namespace Host {
port: string;
targetIP: string;
targetPort: string;
interface: string;
}
export interface RuleIP {
operation: string;

View file

@ -246,7 +246,7 @@ export namespace Setting {
id: number;
name: string;
addr: string;
description: string;
description: string;
systemVersion: string;
securityEntrance: string;
cpuUsedPercent: number;

View file

@ -25,7 +25,7 @@ export const operateFire = (operation: string, withDockerRestart: boolean) => {
export const operatePortRule = (params: Host.RulePort) => {
return http.post<Host.RulePort>(`/hosts/firewall/port`, params, TimeoutEnum.T_40S);
};
export const operateForwardRule = (params: { rules: Host.RuleForward[]; forceDelete: boolean }) => {
export const operateForwardRule = (params: { rules: Host.RuleForward[]; forceDelete?: boolean }) => {
return http.post<Host.RulePort>(`/hosts/firewall/forward`, params, TimeoutEnum.T_40S);
};
export const operateIPRule = (params: Host.RuleIP) => {

View file

@ -2857,6 +2857,7 @@ const message = {
forwardHelper1: 'If you want to forward to the local port, the destination IP should be set to "127.0.0.1".',
forwardHelper2: 'Leave the destination IP blank to forward to the local port.',
forwardHelper3: 'Only support IPv4 port forwarding.',
forwardInboundInterface: 'Forward Inbound Network Interface',
},
runtime: {
runtime: 'Runtime',

View file

@ -2824,6 +2824,7 @@ const message = {
forwardHelper1: 'Si quieres reenviar al puerto local, la IP de destino debe ser "127.0.0.1".',
forwardHelper2: 'Deja en blanco la IP de destino para reenviar al puerto local.',
forwardHelper3: 'Solo se admite redirección de puertos IPv4.',
forwardInboundInterface: 'Interfaz de Red de Entrada para Reenvío',
},
runtime: {
runtime: 'Runtime',

View file

@ -2771,6 +2771,7 @@ const message = {
forwardHelper1: 'ローカルポートに転送する場合は宛先IPを127.0.0.1に設定する必要があります',
forwardHelper2: '宛先IPを空白のままにしてローカルポートに転送します',
forwardHelper3: 'IPv4ポート転送のみをサポートします',
forwardInboundInterface: '転送入站ネットワークインターフェース',
},
runtime: {
runtime: 'ランタイム',

View file

@ -2722,6 +2722,7 @@ const message = {
forwardHelper1: "로컬 포트로 전달하려면, 대상 IP 를 '127.0.0.1'로 설정해야 합니다.",
forwardHelper2: '대상 IP 비워두면 로컬 포트로 전달됩니다.',
forwardHelper3: 'IPv4 포트 전달만 지원됩니다.',
forwardInboundInterface: '포워딩 인바운드 네트워크 인터페이스',
},
runtime: {
runtime: '실행 환경',

View file

@ -2833,6 +2833,7 @@ const message = {
forwardHelper1: 'Jika anda ingin memajukan ke port tempatan, IP sasaran harus ditetapkan kepada "127.0.0.1".',
forwardHelper2: 'Biarkan IP sasaran kosong untuk memajukan ke port tempatan.',
forwardHelper3: 'Hanya menyokong pemajuan port IPv4.',
forwardInboundInterface: 'Antara Muka Rangkaian Masukan Penerusan',
},
runtime: {
runtime: 'Runtime',

View file

@ -2838,6 +2838,7 @@ const message = {
'Se você deseja redirecionar para a porta local, o IP de destino deve ser definido como "127.0.0.1".',
forwardHelper2: 'Deixe o IP de destino em branco para redirecionar para a porta local.',
forwardHelper3: 'Somente suporta redirecionamento de porta IPv4.',
forwardInboundInterface: 'Interface de Rede de Entrada para Encaminhamento',
},
runtime: {
runtime: 'Runtime',

View file

@ -2834,6 +2834,7 @@ const message = {
'Если вы хотите перенаправить на локальный порт, целевой IP должен быть установлен как "127.0.0.1".',
forwardHelper2: 'Оставьте целевой IP пустым для перенаправления на локальный порт.',
forwardHelper3: 'Поддерживается только переадресация портов IPv4.',
forwardInboundInterface: '转发入站Сетевой интерфейс для пересылки входящего трафика网卡',
},
runtime: {
runtime: 'Среда выполнения',

View file

@ -2895,6 +2895,7 @@ const message = {
forwardHelper1: 'Yerel porta yönlendirmek istiyorsanız, hedef IP "127.0.0.1" olarak ayarlanmalıdır.',
forwardHelper2: 'Yerel porta yönlendirmek için hedef IPyi boş bırakın.',
forwardHelper3: 'Yalnızca IPv4 port yönlendirmesini destekler.',
forwardInboundInterface: 'İletme Gelen Arayüzü',
},
runtime: {
runtime: 'Çalışma Zamanı',

View file

@ -2659,6 +2659,7 @@ const message = {
forwardHelper1: '如果是本機埠轉發目標 IP 127.0.0.1',
forwardHelper2: '如果目標 IP 不填寫預設為本機埠轉發',
forwardHelper3: '目前僅支援 IPv4 的埠轉發',
forwardInboundInterface: '轉發入站網路介面',
},
runtime: {
runtime: '執行環境',

View file

@ -2651,6 +2651,7 @@ const message = {
forwardHelper1: '如果是本机端口转发目标IP为127.0.0.1',
forwardHelper2: '如果目标IP不填写则默认为本机端口转发',
forwardHelper3: '当前仅支持 IPv4 的端口转发',
forwardInboundInterface: '转发入站网卡',
},
runtime: {
runtime: '运行环境',

View file

@ -43,6 +43,19 @@
<el-table-column :label="$t('firewall.sourcePort')" :min-width="70" prop="port" />
<el-table-column :min-width="80" :label="$t('firewall.targetIP')" prop="targetIP" />
<el-table-column :label="$t('firewall.targetPort')" :min-width="70" prop="targetPort" />
<template v-if="fireName === 'ufw'">
<el-table-column
:label="$t('firewall.forwardInboundInterface')"
:min-width="70"
prop="interface"
>
<template #default="{ row }">
<span>
{{ row.interface === '' ? $t('commons.table.all') : row.interface }}
</span>
</template>
</el-table-column>
</template>
<fu-table-operations
width="200px"
:buttons="buttons"
@ -125,7 +138,13 @@ const search = async () => {
await searchFireRule(params)
.then((res) => {
loading.value = false;
data.value = res.data.items || [];
data.value =
res.data.items?.map((item) => {
return {
...item,
interface: item.interface === '*' ? '' : item.interface,
};
}) || [];
paginationConfig.total = res.data.total;
})
.catch(() => {
@ -141,11 +160,13 @@ const onOpenDialog = async (
port: '8080',
targetIP: '',
targetPort: '',
interface: '',
},
) => {
let params = {
title,
rowData: { ...rowData },
fireName: fireName.value,
};
dialogRef.value!.acceptParams(params);
};

View file

@ -23,6 +23,19 @@
<el-form-item :label="$t('firewall.targetPort')" prop="targetPort">
<el-input clearable v-model.trim="dialogData.rowData!.targetPort" />
</el-form-item>
<template v-if="dialogData.fireName === 'ufw'">
<el-form-item :label="$t('firewall.forwardInboundInterface')" prop="interface">
<el-select class="w-full" v-model="dialogData.rowData!.interface">
<el-option
v-for="item in interfaceOptions"
:key="item.value"
:label="item.label === 'all' ? $t('commons.table.all') : item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</template>
</el-form>
<template #footer>
<span class="dialog-footer">
@ -42,15 +55,18 @@ import i18n from '@/lang';
import { ElForm } from 'element-plus';
import { MsgSuccess } from '@/utils/message';
import { Host } from '@/api/interface/host';
import { operateForwardRule } from '@/api/modules/host';
import { operateForwardRule, getNetworkOptions } from '@/api/modules/host';
import { checkCidr, checkCidrV6, checkIp, checkPort, deepCopy } from '@/utils/util';
const loading = ref();
const oldRule = ref<Host.RuleForward>();
const interfaceOptions = ref<Array<{ label: string; value: string }>>([]);
interface DialogProps {
title: string;
rowData?: Host.RuleForward;
fireName?: string;
getTableList?: () => Promise<any>;
}
const title = ref<string>('');
@ -63,6 +79,12 @@ const acceptParams = (params: DialogProps): void => {
if (dialogData.value.title === 'edit') {
oldRule.value = deepCopy(params.rowData);
}
if (dialogData.value.fireName === 'ufw') {
getNetworkOptions().then((res) => {
interfaceOptions.value = res.data.map((item) => ({ label: item, value: item }));
dialogData.value.rowData!.interface = dialogData.value.rowData!.interface || 'all';
});
}
title.value = i18n.global.t('firewall.' + dialogData.value.title);
drawerVisible.value = true;
};
@ -127,6 +149,9 @@ const onSubmit = async (formEl: FormInstance | undefined) => {
if (rowData.targetIP === '') {
rowData.targetIP = '127.0.0.1';
}
if (rowData.interface === 'all') {
rowData.interface = '';
}
rules.push(rowData);
loading.value = true;
if (dialogData.value.title === 'create') {