feat: add firewall rule import/export with JSON validation and alerts (#10706)

This commit is contained in:
KOMATA
2025-10-22 18:31:26 +08:00
committed by GitHub
parent da23b8e6d9
commit f462591e9c
16 changed files with 1157 additions and 0 deletions
+13
View File
@@ -2872,6 +2872,19 @@ const message = {
forwardHelper2: 'Leave the destination IP blank to forward to the local port.',
forwardHelper3: 'Only support IPv4 port forwarding.',
forwardInboundInterface: 'Forward Inbound Network Interface',
importHelper:
'Upload a JSON file to import firewall rules. The system will automatically compare and show differences',
importNew: 'New rules',
importConflict: 'Conflicting rules',
importDuplicate: 'Duplicate rules',
new: 'New',
conflict: 'Conflict',
duplicate: 'Duplicate',
errImportFormat: 'Import file format error, please check the JSON file format',
errImport: 'Import failed: ',
selectImportRules: 'Please select rules to import',
importSuccess: 'Successfully imported {0} rules',
importPartialSuccess: 'Import completed: {0} succeeded, {1} failed',
},
runtime: {
runtime: 'Runtime',
+13
View File
@@ -2841,6 +2841,19 @@ const message = {
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',
importHelper:
'Sube un archivo JSON para importar reglas del firewall. El sistema comparará automáticamente y mostrará las diferencias',
importNew: 'Reglas nuevas',
importConflict: 'Reglas en conflicto',
importDuplicate: 'Reglas duplicadas',
new: 'Nueva',
conflict: 'Conflicto',
duplicate: 'Duplicada',
errImportFormat: 'Error en el formato del archivo importado, comprueba el formato del archivo JSON',
errImport: 'Error al importar: ',
selectImportRules: 'Selecciona las reglas que deseas importar',
importSuccess: 'Se importaron correctamente {0} reglas',
importPartialSuccess: 'Importación completada: {0} correctas, {1} fallidas',
},
runtime: {
runtime: 'Runtime',
+13
View File
@@ -2785,6 +2785,19 @@ const message = {
forwardHelper2: '宛先IPを空白のままにしてローカルポートに転送します',
forwardHelper3: 'IPv4ポート転送のみをサポートします',
forwardInboundInterface: '転送入站ネットワークインターフェース',
importHelper:
'JSON ファイルをアップロードしてファイアウォールルールをインポートしますシステムが自動で比較し差分を表示します',
importNew: '新規ルール',
importConflict: '競合ルール',
importDuplicate: '重複ルール',
new: '新規',
conflict: '競合',
duplicate: '重複',
errImportFormat: 'インポートファイルの形式エラーですJSON ファイルの形式を確認してください',
errImport: 'インポートに失敗しました: ',
selectImportRules: 'インポートするルールを選択してください',
importSuccess: '{0} 件のルールを正常にインポートしました',
importPartialSuccess: 'インポート完了: {0} 件成功{1} 件失敗',
},
runtime: {
runtime: 'ランタイム',
+12
View File
@@ -2734,6 +2734,18 @@ const message = {
forwardHelper2: '대상 IP 를 비워두면 로컬 포트로 전달됩니다.',
forwardHelper3: 'IPv4 포트 전달만 지원됩니다.',
forwardInboundInterface: '포워딩 인바운드 네트워크 인터페이스',
importHelper: 'JSON 파일을 업로드하여 방화벽 규칙을 가져옵니다. 시스템이 자동으로 비교하고 차이를 표시합니다',
importNew: '새 규칙',
importConflict: '충돌 규칙',
importDuplicate: '중복 규칙',
new: '신규',
conflict: '충돌',
duplicate: '중복',
errImportFormat: '가져오기 파일 형식 오류입니다. JSON 파일 형식을 확인하세요',
errImport: '가져오기에 실패했습니다: ',
selectImportRules: '가져올 규칙을 선택하세요',
importSuccess: '{0}개의 규칙을 성공적으로 가져왔습니다',
importPartialSuccess: '가져오기 완료: 성공 {0}건, 실패 {1}건',
},
runtime: {
runtime: '실행 환경',
+13
View File
@@ -2849,6 +2849,19 @@ const message = {
forwardHelper2: 'Biarkan IP sasaran kosong untuk memajukan ke port tempatan.',
forwardHelper3: 'Hanya menyokong pemajuan port IPv4.',
forwardInboundInterface: 'Antara Muka Rangkaian Masukan Penerusan',
importHelper:
'Muat naik fail JSON untuk mengimport peraturan firewall. Sistem akan membanding dan memaparkan perbezaan secara automatik',
importNew: 'Peraturan baharu',
importConflict: 'Peraturan konflik',
importDuplicate: 'Peraturan duplikat',
new: 'Baharu',
conflict: 'Konflik',
duplicate: 'Duplikat',
errImportFormat: 'Ralat format fail import, sila semak format fail JSON',
errImport: 'Import gagal: ',
selectImportRules: 'Sila pilih peraturan untuk diimport',
importSuccess: '{0} peraturan berjaya diimport',
importPartialSuccess: 'Import selesai: {0} berjaya, {1} gagal',
},
runtime: {
runtime: 'Runtime',
+13
View File
@@ -2855,6 +2855,19 @@ const message = {
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',
importHelper:
'Envie um arquivo JSON para importar as regras do firewall. O sistema irá comparar e mostrar as diferenças automaticamente',
importNew: 'Novas regras',
importConflict: 'Regras em conflito',
importDuplicate: 'Regras duplicadas',
new: 'Nova',
conflict: 'Conflito',
duplicate: 'Duplicada',
errImportFormat: 'Erro no formato do arquivo de importação, verifique o formato do arquivo JSON',
errImport: 'Falha na importação: ',
selectImportRules: 'Selecione as regras que deseja importar',
importSuccess: '{0} regras importadas com sucesso',
importPartialSuccess: 'Importação concluída: {0} sucesso, {1} falha',
},
runtime: {
runtime: 'Runtime',
+13
View File
@@ -2851,6 +2851,19 @@ const message = {
forwardHelper2: 'Оставьте целевой IP пустым для перенаправления на локальный порт.',
forwardHelper3: 'Поддерживается только переадресация портов IPv4.',
forwardInboundInterface: '转发入站Сетевой интерфейс для пересылки входящего трафика网卡',
importHelper:
'Загрузите JSON-файл, чтобы импортировать правила брандмауэра. Система автоматически сравнит их и покажет различия',
importNew: 'Новые правила',
importConflict: 'Конфликтующие правила',
importDuplicate: 'Дублирующиеся правила',
new: 'Новое',
conflict: 'Конфликт',
duplicate: 'Дубликат',
errImportFormat: 'Ошибка формата импортируемого файла, проверьте формат JSON',
errImport: 'Не удалось импортировать: ',
selectImportRules: 'Выберите правила для импорта',
importSuccess: 'Успешно импортировано {0} правил',
importPartialSuccess: 'Импорт завершён: {0} успешно, {1} с ошибкой',
},
runtime: {
runtime: 'Среда выполнения',
+13
View File
@@ -2912,6 +2912,19 @@ const message = {
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ü',
importHelper:
'Güvenlik duvarı kurallarını içe aktarmak için bir JSON dosyası yükleyin. Sistem otomatik olarak karşılaştırıp farkları gösterecektir',
importNew: 'Yeni kurallar',
importConflict: 'Çakışan kurallar',
importDuplicate: 'Yinelenen kurallar',
new: 'Yeni',
conflict: 'Çakışma',
duplicate: 'Yinelenen',
errImportFormat: 'İçe aktarma dosya biçimi hatalı, lütfen JSON dosya biçimini kontrol edin',
errImport: 'İçe aktarma başarısız: ',
selectImportRules: 'Lütfen içe aktarılacak kuralları seçin',
importSuccess: '{0} kural başarıyla içe aktarıldı',
importPartialSuccess: 'İçe aktarma tamamlandı: {0} başarılı, {1} başarısız',
},
runtime: {
runtime: 'Çalışma Zamanı',
+12
View File
@@ -2668,6 +2668,18 @@ const message = {
forwardHelper2: '如果目標 IP 不填寫預設為本機埠轉發',
forwardHelper3: '目前僅支援 IPv4 的埠轉發',
forwardInboundInterface: '轉發入站網路介面',
importHelper: '上傳 JSON 檔案以匯入防火牆規則系統會自動比對並顯示差異',
importNew: '新增規則',
importConflict: '衝突規則',
importDuplicate: '重複規則',
new: '新增',
conflict: '衝突',
duplicate: '重複',
errImportFormat: '匯入檔案格式錯誤請檢查 JSON 檔案格式',
errImport: '匯入失敗',
selectImportRules: '請選擇要匯入的規則',
importSuccess: '成功匯入 {0} 條規則',
importPartialSuccess: '匯入完成成功 {0} 失敗 {1} ',
},
runtime: {
runtime: '執行環境',
+12
View File
@@ -2661,6 +2661,18 @@ const message = {
forwardHelper2: '如果目标IP不填写则默认为本机端口转发',
forwardHelper3: '当前仅支持 IPv4 的端口转发',
forwardInboundInterface: '转发入站网卡',
importHelper: '上传 JSON 文件以导入防火墙规则系统会自动比对并显示差异',
importNew: '新增规则',
importConflict: '冲突规则',
importDuplicate: '重复规则',
new: '新规则',
conflict: '冲突',
duplicate: '重复',
errImportFormat: '导入文件格式错误请检查 JSON 文件格式',
errImport: '导入失败',
selectImportRules: '请选择要导入的规则',
importSuccess: '成功导入 {0} 条规则',
importPartialSuccess: '导入完成成功 {0} 失败 {1} ',
},
runtime: {
runtime: '运行环境',
@@ -0,0 +1,326 @@
<template>
<DialogPro v-model="visible" :title="$t('commons.button.import')" size="large">
<div>
<el-alert :closable="false" show-icon type="info">
<template #default>
<div>{{ $t('firewall.importHelper') }}</div>
</template>
</el-alert>
<el-upload
action="#"
:auto-upload="false"
ref="uploadRef"
class="float-left mt-2"
:show-file-list="false"
:limit="1"
accept=".json"
:on-change="fileOnChange"
:on-exceed="handleExceed"
v-model:file-list="uploaderFiles"
>
<el-button class="float-left" type="primary">{{ $t('commons.button.upload') }}</el-button>
</el-upload>
<el-card class="mt-2 w-full" v-loading="loading">
<div v-if="compareResult.new.length > 0 || compareResult.conflict.length > 0">
<el-alert
:closable="false"
show-icon
type="success"
class="mb-2"
v-if="compareResult.new.length > 0"
>
<template #default>
<span>
{{ $t('firewall.importNew') }}:
<strong>{{ compareResult.new.length }}</strong>
</span>
</template>
</el-alert>
<el-alert
:closable="false"
show-icon
type="warning"
class="mb-2"
v-if="compareResult.conflict.length > 0"
>
<template #default>
<span>
{{ $t('firewall.importConflict') }}:
<strong>{{ compareResult.conflict.length }}</strong>
</span>
</template>
</el-alert>
<el-alert
:closable="false"
show-icon
type="info"
class="mb-2"
v-if="compareResult.duplicate.length > 0"
>
<template #default>
<span>
{{ $t('firewall.importDuplicate') }}:
<strong>{{ compareResult.duplicate.length }}</strong>
</span>
</template>
</el-alert>
</div>
<el-table :data="displayData" @selection-change="handleSelectionChange">
<el-table-column type="selection" fix />
<el-table-column :label="$t('commons.table.status')" :min-width="80">
<template #default="{ row }">
<el-tag v-if="row.status === 'new'" type="success">{{ $t('firewall.new') }}</el-tag>
<el-tag v-else-if="row.status === 'conflict'" type="warning">
{{ $t('firewall.conflict') }}
</el-tag>
<el-tag v-else type="info">{{ $t('firewall.duplicate') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.protocol')" :min-width="70" prop="protocol" />
<el-table-column :label="$t('firewall.sourcePort')" :min-width="70" prop="port" />
<el-table-column :label="$t('firewall.targetIP')" :min-width="100" prop="targetIP" />
<el-table-column :label="$t('firewall.targetPort')" :min-width="70" prop="targetPort" />
<el-table-column
v-if="currentFireName === 'ufw'"
:label="$t('firewall.forwardInboundInterface')"
:min-width="100"
prop="interface"
>
<template #default="{ row }">
<span>
{{
row.interface === '' || row.interface === 'all'
? $t('commons.table.all')
: row.interface
}}
</span>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" :disabled="selects.length === 0" @click="onImport">
{{ $t('commons.button.import') }}
</el-button>
</span>
</template>
</DialogPro>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { genFileId, UploadFile, UploadFiles, UploadProps, UploadRawFile } from 'element-plus';
import { MsgError, MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
import { operateForwardRule, searchFireRule, getNetworkOptions } from '@/api/modules/host';
import { Host } from '@/api/interface/host';
const emit = defineEmits<{ (e: 'search'): void }>();
const visible = ref(false);
const loading = ref(false);
const selects = ref<any>([]);
const displayData = ref<any>([]);
const currentRules = ref<Host.RuleInfo[]>([]);
const currentFireName = ref('');
const availableInterfaces = ref<string[]>([]);
const uploadRef = ref();
const uploaderFiles = ref();
interface CompareResult {
new: any[];
conflict: any[];
duplicate: any[];
}
const compareResult = ref<CompareResult>({
new: [],
conflict: [],
duplicate: [],
});
const acceptParams = async (fireName: string): Promise<void> => {
visible.value = true;
displayData.value = [];
selects.value = [];
compareResult.value = { new: [], conflict: [], duplicate: [] };
currentFireName.value = fireName;
// Fetch all current rules for comparison
loading.value = true;
try {
const res = await searchFireRule({
type: 'forward',
status: '',
strategy: '',
info: '',
page: 1,
pageSize: 10000, // Fetch all rules
});
currentRules.value = res.data.items || [];
// Fetch available network interfaces (UFW only)
if (fireName === 'ufw') {
const networkRes = await getNetworkOptions();
availableInterfaces.value = networkRes.data || [];
}
} catch (error) {
MsgError(i18n.global.t('commons.msg.searchFailed'));
} finally {
loading.value = false;
}
};
const handleSelectionChange = (val: any) => {
selects.value = val;
};
const fileOnChange = (_uploadFile: UploadFile, uploadFiles: UploadFiles) => {
loading.value = true;
displayData.value = [];
compareResult.value = { new: [], conflict: [], duplicate: [] };
uploaderFiles.value = uploadFiles;
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result as string;
const parsed = JSON.parse(content);
if (!Array.isArray(parsed)) {
MsgError(i18n.global.t('firewall.errImportFormat'));
loading.value = false;
return;
}
for (const item of parsed) {
if (!checkDataFormat(item)) {
MsgError(i18n.global.t('firewall.errImportFormat'));
loading.value = false;
return;
}
}
compareRules(parsed);
loading.value = false;
} catch (error) {
MsgError(i18n.global.t('firewall.errImport') + error.message);
loading.value = false;
}
};
reader.readAsText(_uploadFile.raw);
};
const handleExceed: UploadProps['onExceed'] = (files) => {
uploadRef.value!.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
uploadRef.value!.handleStart(file);
};
const checkDataFormat = (item: any): boolean => {
if (!item.protocol || !item.port || !item.targetIP || !item.targetPort) {
return false;
}
if (!['tcp', 'udp', 'tcp/udp'].includes(item.protocol)) {
return false;
}
// Validate network interface (UFW only)
if (currentFireName.value === 'ufw' && item.interface !== undefined && item.interface !== null) {
const interfaceValue = item.interface;
// Allow empty string or 'all' (represents all interfaces)
if (interfaceValue !== '' && interfaceValue !== 'all') {
// Must be in available interfaces list
if (!availableInterfaces.value.includes(interfaceValue)) {
return false;
}
}
}
return true;
};
const compareRules = (importedRules: any[]) => {
const newRules: any[] = [];
const conflictRules: any[] = [];
const duplicateRules: any[] = [];
for (const importedRule of importedRules) {
const key = `${importedRule.protocol}:${importedRule.port}:${importedRule.targetIP}:${importedRule.targetPort}`;
const existingRule = currentRules.value.find((rule) => {
const existingKey = `${rule.protocol}:${rule.port}:${rule.targetIP}:${rule.targetPort}`;
return existingKey === key;
});
if (!existingRule) {
newRules.push({ ...importedRule, status: 'new' });
} else {
duplicateRules.push({ ...importedRule, status: 'duplicate' });
}
}
compareResult.value = {
new: newRules,
conflict: conflictRules,
duplicate: duplicateRules,
};
displayData.value = [...newRules, ...conflictRules, ...duplicateRules];
};
const onImport = async () => {
if (selects.value.length === 0) {
MsgError(i18n.global.t('firewall.selectImportRules'));
return;
}
loading.value = true;
let successCount = 0;
let errorCount = 0;
const rules: Host.RuleForward[] = [];
for (const rule of selects.value) {
rules.push({
operation: 'add',
protocol: rule.protocol,
port: rule.port,
targetIP: rule.targetIP,
targetPort: rule.targetPort,
interface: rule.interface || '',
});
}
try {
await operateForwardRule({ rules });
successCount = rules.length;
} catch (error) {
errorCount = rules.length;
console.error('Failed to import rules:', error);
}
loading.value = false;
if (errorCount === 0) {
MsgSuccess(i18n.global.t('firewall.importSuccess', [successCount]));
visible.value = false;
emit('search');
} else {
MsgError(i18n.global.t('firewall.importPartialSuccess', [successCount, errorCount]));
emit('search');
}
};
defineExpose({
acceptParams,
});
</script>
@@ -24,6 +24,14 @@
<el-button @click="onDelete(null)" plain :disabled="selects.length === 0">
{{ $t('commons.button.delete') }}
</el-button>
<el-button-group>
<el-button @click="onImport">
{{ $t('commons.button.import') }}
</el-button>
<el-button :disabled="!data || data.length === 0" @click="onExport">
{{ $t('commons.button.export') }}
</el-button>
</el-button-group>
</template>
<template #rightToolBar>
<TableSearch @search="search()" v-model:searchName="searchName" />
@@ -82,18 +90,21 @@
</template>
</OpDialog>
<OperateDialog @search="search" ref="dialogRef" />
<ImportDialog @search="search" ref="dialogImportRef" />
</div>
</template>
<script lang="ts" setup>
import FireRouter from '@/views/host/firewall/index.vue';
import OperateDialog from './operate/index.vue';
import ImportDialog from './import/index.vue';
import FireStatus from '@/views/host/firewall/status/index.vue';
import { onMounted, reactive, ref } from 'vue';
import { operateForwardRule, searchFireRule } from '@/api/modules/host';
import { Host } from '@/api/interface/host';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { downloadWithContent, getCurrentDateFormatted } from '@/utils/util';
const loading = ref();
const activeTag = ref('forward');
@@ -108,6 +119,7 @@ const fireName = ref();
const fireStatusRef = ref();
const opRef = ref();
const dialogImportRef = ref();
const forceDelete = ref(false);
const operateRules = ref();
@@ -213,6 +225,24 @@ const onSubmitDelete = async () => {
});
};
const onImport = () => {
dialogImportRef.value.acceptParams(fireName.value);
};
const onExport = () => {
const exportData = data.value.map((item: Host.RuleInfo) => ({
family: item.family,
protocol: item.protocol,
port: item.port,
targetIP: item.targetIP,
targetPort: item.targetPort,
interface: item.interface,
}));
const content = JSON.stringify(exportData, null, 2);
const fileName = `1panel-firewall-forward-${getCurrentDateFormatted()}.json`;
downloadWithContent(content, fileName);
};
const buttons = [
{
label: i18n.global.t('commons.button.edit'),
@@ -0,0 +1,304 @@
<template>
<DialogPro v-model="visible" :title="$t('commons.button.import')" size="large">
<div>
<el-alert :closable="false" show-icon type="info">
<template #default>
<div>{{ $t('firewall.importHelper') }}</div>
</template>
</el-alert>
<el-upload
action="#"
:auto-upload="false"
ref="uploadRef"
class="float-left mt-2"
:show-file-list="false"
:limit="1"
accept=".json"
:on-change="fileOnChange"
:on-exceed="handleExceed"
v-model:file-list="uploaderFiles"
>
<el-button class="float-left" type="primary">{{ $t('commons.button.upload') }}</el-button>
</el-upload>
<el-card class="mt-2 w-full" v-loading="loading">
<div v-if="compareResult.new.length > 0 || compareResult.conflict.length > 0">
<el-alert
:closable="false"
show-icon
type="success"
class="mb-2"
v-if="compareResult.new.length > 0"
>
<template #default>
<span>
{{ $t('firewall.importNew') }}:
<strong>{{ compareResult.new.length }}</strong>
</span>
</template>
</el-alert>
<el-alert
:closable="false"
show-icon
type="warning"
class="mb-2"
v-if="compareResult.conflict.length > 0"
>
<template #default>
<span>
{{ $t('firewall.importConflict') }}:
<strong>{{ compareResult.conflict.length }}</strong>
</span>
</template>
</el-alert>
<el-alert
:closable="false"
show-icon
type="info"
class="mb-2"
v-if="compareResult.duplicate.length > 0"
>
<template #default>
<span>
{{ $t('firewall.importDuplicate') }}:
<strong>{{ compareResult.duplicate.length }}</strong>
</span>
</template>
</el-alert>
</div>
<el-table :data="displayData" @selection-change="handleSelectionChange">
<el-table-column type="selection" fix />
<el-table-column :label="$t('commons.table.status')" :min-width="80">
<template #default="{ row }">
<el-tag v-if="row.status === 'new'" type="success">{{ $t('firewall.new') }}</el-tag>
<el-tag v-else-if="row.status === 'conflict'" type="warning">
{{ $t('firewall.conflict') }}
</el-tag>
<el-tag v-else type="info">{{ $t('firewall.duplicate') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('firewall.address')" :min-width="100">
<template #default="{ row }">
<span v-if="row.address && row.address !== 'Anywhere'">{{ row.address }}</span>
<span v-else>{{ $t('firewall.allIP') }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('firewall.strategy')" :min-width="80" prop="strategy">
<template #default="{ row }">
<el-tag v-if="row.strategy === 'accept'" type="success">{{ $t('firewall.allow') }}</el-tag>
<el-tag v-else type="danger">{{ $t('firewall.deny') }}</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('commons.table.description')"
:min-width="120"
prop="description"
show-overflow-tooltip
/>
</el-table>
</el-card>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" :disabled="selects.length === 0" @click="onImport">
{{ $t('commons.button.import') }}
</el-button>
</span>
</template>
</DialogPro>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { genFileId, UploadFile, UploadFiles, UploadProps, UploadRawFile } from 'element-plus';
import { MsgError, MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
import { operateIPRule, searchFireRule } from '@/api/modules/host';
import { Host } from '@/api/interface/host';
const emit = defineEmits<{ (e: 'search'): void }>();
const visible = ref(false);
const loading = ref(false);
const selects = ref<any>([]);
const displayData = ref<any>([]);
const currentRules = ref<Host.RuleInfo[]>([]);
const uploadRef = ref();
const uploaderFiles = ref();
interface CompareResult {
new: any[];
conflict: any[];
duplicate: any[];
}
const compareResult = ref<CompareResult>({
new: [],
conflict: [],
duplicate: [],
});
const acceptParams = async (): Promise<void> => {
visible.value = true;
displayData.value = [];
selects.value = [];
compareResult.value = { new: [], conflict: [], duplicate: [] };
loading.value = true;
try {
const res = await searchFireRule({
type: 'address',
status: '',
strategy: '',
info: '',
page: 1,
pageSize: 10000,
});
currentRules.value = res.data.items || [];
} catch (error) {
MsgError(i18n.global.t('commons.msg.searchFailed'));
} finally {
loading.value = false;
}
};
const handleSelectionChange = (val: any) => {
selects.value = val;
};
const fileOnChange = (_uploadFile: UploadFile, uploadFiles: UploadFiles) => {
loading.value = true;
displayData.value = [];
compareResult.value = { new: [], conflict: [], duplicate: [] };
uploaderFiles.value = uploadFiles;
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result as string;
const parsed = JSON.parse(content);
if (!Array.isArray(parsed)) {
MsgError(i18n.global.t('firewall.errImportFormat'));
loading.value = false;
return;
}
for (const item of parsed) {
if (!checkDataFormat(item)) {
MsgError(i18n.global.t('firewall.errImportFormat'));
loading.value = false;
return;
}
}
compareRules(parsed);
loading.value = false;
} catch (error) {
MsgError(i18n.global.t('firewall.errImport') + error.message);
loading.value = false;
}
};
reader.readAsText(_uploadFile.raw);
};
const handleExceed: UploadProps['onExceed'] = (files) => {
uploadRef.value!.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
uploadRef.value!.handleStart(file);
};
const checkDataFormat = (item: any): boolean => {
if (!item.address || !item.strategy) {
return false;
}
if (!['accept', 'drop'].includes(item.strategy)) {
return false;
}
return true;
};
const compareRules = (importedRules: any[]) => {
const newRules: any[] = [];
const conflictRules: any[] = [];
const duplicateRules: any[] = [];
for (const importedRule of importedRules) {
const key = `${importedRule.address || 'Anywhere'}`;
const existingRule = currentRules.value.find((rule) => {
const existingKey = `${rule.address || 'Anywhere'}`;
return existingKey === key;
});
if (!existingRule) {
newRules.push({ ...importedRule, status: 'new' });
} else if (existingRule.strategy !== importedRule.strategy) {
conflictRules.push({
...importedRule,
status: 'conflict',
existingStrategy: existingRule.strategy,
});
} else {
duplicateRules.push({ ...importedRule, status: 'duplicate' });
}
}
compareResult.value = {
new: newRules,
conflict: conflictRules,
duplicate: duplicateRules,
};
displayData.value = [...newRules, ...conflictRules, ...duplicateRules];
};
const onImport = async () => {
if (selects.value.length === 0) {
MsgError(i18n.global.t('firewall.selectImportRules'));
return;
}
loading.value = true;
let successCount = 0;
let errorCount = 0;
for (const rule of selects.value) {
try {
const params: Host.RuleIP = {
operation: 'add',
address: rule.address || 'Anywhere',
strategy: rule.strategy,
description: rule.description || '',
};
await operateIPRule(params);
successCount++;
} catch (error) {
errorCount++;
console.error('Failed to import rule:', rule, error);
}
}
loading.value = false;
if (errorCount === 0) {
MsgSuccess(i18n.global.t('firewall.importSuccess', [successCount]));
visible.value = false;
emit('search');
} else {
MsgError(i18n.global.t('firewall.importPartialSuccess', [successCount, errorCount]));
emit('search');
}
};
defineExpose({
acceptParams,
});
</script>
@@ -25,6 +25,14 @@
<el-button @click="onDelete(null)" plain :disabled="selects.length === 0">
{{ $t('commons.button.delete') }}
</el-button>
<el-button-group>
<el-button @click="onImport">
{{ $t('commons.button.import') }}
</el-button>
<el-button :disabled="!data || data.length === 0" @click="onExport">
{{ $t('commons.button.export') }}
</el-button>
</el-button-group>
</template>
<template #rightToolBar>
<el-select v-model="searchStrategy" @change="search()" clearable class="p-w-200">
@@ -92,11 +100,13 @@
<OpDialog ref="opRef" @search="search" />
<OperateDialog @search="search" ref="dialogRef" />
<ImportDialog @search="search" ref="dialogImportRef" />
</div>
</template>
<script lang="ts" setup>
import OperateDialog from '@/views/host/firewall/ip/operate/index.vue';
import ImportDialog from '@/views/host/firewall/ip/import/index.vue';
import FireRouter from '@/views/host/firewall/index.vue';
import FireStatus from '@/views/host/firewall/status/index.vue';
import { onMounted, reactive, ref } from 'vue';
@@ -105,6 +115,7 @@ import { Host } from '@/api/interface/host';
import { ElMessageBox } from 'element-plus';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { downloadWithContent, getCurrentDateFormatted } from '@/utils/util';
const loading = ref();
const activeTag = ref('address');
@@ -118,6 +129,7 @@ const isActive = ref(false);
const fireStatusRef = ref();
const opRef = ref();
const dialogImportRef = ref();
const data = ref();
const paginationConfig = reactive({
@@ -248,6 +260,22 @@ const onDelete = async (row: Host.RuleIP | null) => {
});
};
const onImport = () => {
dialogImportRef.value.acceptParams();
};
const onExport = () => {
const exportData = data.value.map((item: Host.RuleInfo) => ({
family: item.family,
address: item.address,
strategy: item.strategy,
description: item.description,
}));
const content = JSON.stringify(exportData, null, 2);
const fileName = `1panel-firewall-ip-${getCurrentDateFormatted()}.json`;
downloadWithContent(content, fileName);
};
const buttons = [
{
label: i18n.global.t('commons.button.edit'),
@@ -0,0 +1,312 @@
<template>
<DialogPro v-model="visible" :title="$t('commons.button.import')" size="large">
<div>
<el-alert :closable="false" show-icon type="info">
<template #default>
<div>{{ $t('firewall.importHelper') }}</div>
</template>
</el-alert>
<el-upload
action="#"
:auto-upload="false"
ref="uploadRef"
class="float-left mt-2"
:show-file-list="false"
:limit="1"
accept=".json"
:on-change="fileOnChange"
:on-exceed="handleExceed"
v-model:file-list="uploaderFiles"
>
<el-button class="float-left" type="primary">{{ $t('commons.button.upload') }}</el-button>
</el-upload>
<el-card class="mt-2 w-full" v-loading="loading">
<div v-if="compareResult.new.length > 0 || compareResult.conflict.length > 0">
<el-alert
:closable="false"
show-icon
type="success"
class="mb-2"
v-if="compareResult.new.length > 0"
>
<template #default>
<span>
{{ $t('firewall.importNew') }}:
<strong>{{ compareResult.new.length }}</strong>
</span>
</template>
</el-alert>
<el-alert
:closable="false"
show-icon
type="warning"
class="mb-2"
v-if="compareResult.conflict.length > 0"
>
<template #default>
<span>
{{ $t('firewall.importConflict') }}:
<strong>{{ compareResult.conflict.length }}</strong>
</span>
</template>
</el-alert>
<el-alert
:closable="false"
show-icon
type="info"
class="mb-2"
v-if="compareResult.duplicate.length > 0"
>
<template #default>
<span>
{{ $t('firewall.importDuplicate') }}:
<strong>{{ compareResult.duplicate.length }}</strong>
</span>
</template>
</el-alert>
</div>
<el-table :data="displayData" @selection-change="handleSelectionChange">
<el-table-column type="selection" fix />
<el-table-column :label="$t('commons.table.status')" :min-width="80">
<template #default="{ row }">
<el-tag v-if="row.status === 'new'" type="success">{{ $t('firewall.new') }}</el-tag>
<el-tag v-else-if="row.status === 'conflict'" type="warning">
{{ $t('firewall.conflict') }}
</el-tag>
<el-tag v-else type="info">{{ $t('firewall.duplicate') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.protocol')" :min-width="70" prop="protocol" />
<el-table-column :label="$t('commons.table.port')" :min-width="70" prop="port" />
<el-table-column :label="$t('firewall.address')" :min-width="80">
<template #default="{ row }">
<span v-if="row.address && row.address !== 'Anywhere'">{{ row.address }}</span>
<span v-else>{{ $t('firewall.allIP') }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('firewall.strategy')" :min-width="80" prop="strategy">
<template #default="{ row }">
<el-tag v-if="row.strategy === 'accept'" type="success">{{ $t('firewall.accept') }}</el-tag>
<el-tag v-else type="danger">{{ $t('firewall.drop') }}</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('commons.table.description')"
:min-width="120"
prop="description"
show-overflow-tooltip
/>
</el-table>
</el-card>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" :disabled="selects.length === 0" @click="onImport">
{{ $t('commons.button.import') }}
</el-button>
</span>
</template>
</DialogPro>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { genFileId, UploadFile, UploadFiles, UploadProps, UploadRawFile } from 'element-plus';
import { MsgError, MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
import { operatePortRule, searchFireRule } from '@/api/modules/host';
import { Host } from '@/api/interface/host';
const emit = defineEmits<{ (e: 'search'): void }>();
const visible = ref(false);
const loading = ref(false);
const selects = ref<any>([]);
const displayData = ref<any>([]);
const currentRules = ref<Host.RuleInfo[]>([]);
const uploadRef = ref();
const uploaderFiles = ref();
interface CompareResult {
new: any[];
conflict: any[];
duplicate: any[];
}
const compareResult = ref<CompareResult>({
new: [],
conflict: [],
duplicate: [],
});
const acceptParams = async (): Promise<void> => {
visible.value = true;
displayData.value = [];
selects.value = [];
compareResult.value = { new: [], conflict: [], duplicate: [] };
loading.value = true;
try {
const res = await searchFireRule({
type: 'port',
status: '',
strategy: '',
info: '',
page: 1,
pageSize: 10000,
});
currentRules.value = res.data.items || [];
} catch (error) {
MsgError(i18n.global.t('commons.msg.searchFailed'));
} finally {
loading.value = false;
}
};
const handleSelectionChange = (val: any) => {
selects.value = val;
};
const fileOnChange = (_uploadFile: UploadFile, uploadFiles: UploadFiles) => {
loading.value = true;
displayData.value = [];
compareResult.value = { new: [], conflict: [], duplicate: [] };
uploaderFiles.value = uploadFiles;
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result as string;
const parsed = JSON.parse(content);
if (!Array.isArray(parsed)) {
MsgError(i18n.global.t('firewall.errImportFormat'));
loading.value = false;
return;
}
for (const item of parsed) {
if (!checkDataFormat(item)) {
MsgError(i18n.global.t('firewall.errImportFormat'));
loading.value = false;
return;
}
}
compareRules(parsed);
loading.value = false;
} catch (error) {
MsgError(i18n.global.t('firewall.errImport') + error.message);
loading.value = false;
}
};
reader.readAsText(_uploadFile.raw);
};
const handleExceed: UploadProps['onExceed'] = (files) => {
uploadRef.value!.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
uploadRef.value!.handleStart(file);
};
const checkDataFormat = (item: any): boolean => {
if (!item.port || !item.protocol || !item.strategy) {
return false;
}
if (!['tcp', 'udp', 'tcp/udp'].includes(item.protocol)) {
return false;
}
if (!['accept', 'drop'].includes(item.strategy)) {
return false;
}
return true;
};
const compareRules = (importedRules: any[]) => {
const newRules: any[] = [];
const conflictRules: any[] = [];
const duplicateRules: any[] = [];
for (const importedRule of importedRules) {
const key = `${importedRule.address || 'Anywhere'}:${importedRule.port}:${importedRule.protocol}`;
const existingRule = currentRules.value.find((rule) => {
const existingKey = `${rule.address || 'Anywhere'}:${rule.port}:${rule.protocol}`;
return existingKey === key;
});
if (!existingRule) {
newRules.push({ ...importedRule, status: 'new' });
} else if (existingRule.strategy !== importedRule.strategy) {
conflictRules.push({
...importedRule,
status: 'conflict',
existingStrategy: existingRule.strategy,
});
} else {
duplicateRules.push({ ...importedRule, status: 'duplicate' });
}
}
compareResult.value = {
new: newRules,
conflict: conflictRules,
duplicate: duplicateRules,
};
displayData.value = [...newRules, ...conflictRules, ...duplicateRules];
};
const onImport = async () => {
if (selects.value.length === 0) {
MsgError(i18n.global.t('firewall.selectImportRules'));
return;
}
loading.value = true;
let successCount = 0;
let errorCount = 0;
for (const rule of selects.value) {
try {
const params: Host.RulePort = {
operation: 'add',
address: rule.address || 'Anywhere',
port: rule.port,
source: '',
protocol: rule.protocol,
strategy: rule.strategy,
description: rule.description || '',
};
await operatePortRule(params);
successCount++;
} catch (error) {
errorCount++;
console.error('Failed to import rule:', rule, error);
}
}
loading.value = false;
if (errorCount === 0) {
MsgSuccess(i18n.global.t('firewall.importSuccess', [successCount]));
visible.value = false;
emit('search');
} else {
MsgError(i18n.global.t('firewall.importPartialSuccess', [successCount, errorCount]));
emit('search');
}
};
defineExpose({
acceptParams,
});
</script>
@@ -41,6 +41,14 @@
<el-button @click="onDelete(null)" plain :disabled="selects.length === 0">
{{ $t('commons.button.delete') }}
</el-button>
<el-button-group>
<el-button @click="onImport">
{{ $t('commons.button.import') }}
</el-button>
<el-button :disabled="!data || data.length === 0" @click="onExport">
{{ $t('commons.button.export') }}
</el-button>
</el-button-group>
</template>
<template #rightToolBar>
<el-select v-model="searchStatus" @change="search()" clearable class="p-w-200">
@@ -142,12 +150,14 @@
<OpDialog ref="opRef" @search="search" />
<OperateDialog @search="search" ref="dialogRef" />
<ImportDialog @search="search" ref="dialogImportRef" />
</div>
</template>
<script lang="ts" setup>
import FireRouter from '@/views/host/firewall/index.vue';
import OperateDialog from '@/views/host/firewall/port/operate/index.vue';
import ImportDialog from '@/views/host/firewall/port/import/index.vue';
import FireStatus from '@/views/host/firewall/status/index.vue';
import { onMounted, reactive, ref } from 'vue';
import { batchOperateRule, searchFireRule, updateFirewallDescription, updatePortRule } from '@/api/modules/host';
@@ -156,6 +166,7 @@ import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { ElMessageBox } from 'element-plus';
import { routerToName } from '@/utils/router';
import { downloadWithContent, getCurrentDateFormatted } from '@/utils/util';
const loading = ref();
const activeTag = ref('port');
@@ -170,6 +181,7 @@ const fireName = ref();
const fireStatusRef = ref();
const opRef = ref();
const dialogImportRef = ref();
const data = ref();
const paginationConfig = reactive({
@@ -315,6 +327,24 @@ const onDelete = async (row: Host.RuleInfo | null) => {
});
};
const onImport = () => {
dialogImportRef.value.acceptParams();
};
const onExport = () => {
const exportData = data.value.map((item: Host.RuleInfo) => ({
family: item.family,
address: item.address,
port: item.port,
protocol: item.protocol,
strategy: item.strategy,
description: item.description,
}));
const content = JSON.stringify(exportData, null, 2);
const fileName = `1panel-firewall-port-${getCurrentDateFormatted()}.json`;
downloadWithContent(content, fileName);
};
const buttons = [
{
label: i18n.global.t('commons.button.edit'),