generated from Nodarx/template
Initial commit for firewall_manager module
This commit is contained in:
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Frontend development
|
||||||
|
node_modules/
|
||||||
|
*.local
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.cache/
|
||||||
|
.vite/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
/static/
|
||||||
|
|
||||||
|
# Backend development
|
||||||
|
/vendor/
|
||||||
|
/lib/vendor/
|
||||||
|
coverage/
|
||||||
|
*.cache
|
||||||
|
.phpactor/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "ktxm/firewall_manager",
|
||||||
|
"type": "project",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"KTXM\\FirewallManager\\": "lib/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\FirewallManager;
|
||||||
|
|
||||||
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
|
final class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||||
|
{
|
||||||
|
public function handle(): string { return 'firewall_manager'; }
|
||||||
|
public function label(): string { return 'Firewall Manager'; }
|
||||||
|
public function author(): string { return 'Ktrix'; }
|
||||||
|
public function description(): string { return 'Administrative interface for firewall rules, logs, metrics, and settings'; }
|
||||||
|
public function version(): string { return '0.0.1'; }
|
||||||
|
|
||||||
|
public function permissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'firewall_manager.access' => [
|
||||||
|
'label' => 'Access Firewall Manager',
|
||||||
|
'description' => 'Access the firewall administration interface',
|
||||||
|
'group' => 'Firewall Management',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerBI(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'handle' => $this->handle(),
|
||||||
|
'namespace' => 'FirewallManager',
|
||||||
|
'version' => $this->version(),
|
||||||
|
'label' => $this->label(),
|
||||||
|
'author' => $this->author(),
|
||||||
|
'description' => $this->description(),
|
||||||
|
'boot' => 'static/module.mjs',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "firewall_manager",
|
||||||
|
"description": "Ktrix Firewall Manager Module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"author": "Ktrix",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --mode production --config vite.config.ts",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^3.0.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^5.0.0",
|
||||||
|
"vuetify": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"@vue/tsconfig": "^0.9.0",
|
||||||
|
"sass": "^1.77.1",
|
||||||
|
"typescript": "~6.0.0",
|
||||||
|
"vite": "^8.0.0",
|
||||||
|
"vue-tsc": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ModuleIntegrations } from '@KTXC/types/moduleTypes'
|
||||||
|
|
||||||
|
const integrations: ModuleIntegrations = {
|
||||||
|
admin_settings_menu: [{
|
||||||
|
id: 'firewall_manager',
|
||||||
|
label: 'Firewall',
|
||||||
|
path: '/firewall',
|
||||||
|
icon: 'mdi-shield-lock-outline',
|
||||||
|
priority: 90,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default integrations
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import routes from '@/routes'
|
||||||
|
import integrations from '@/integrations'
|
||||||
|
import type { App as Vue } from 'vue'
|
||||||
|
|
||||||
|
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
||||||
|
export { routes, integrations }
|
||||||
|
export default { install(_app: Vue) {} }
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useUserStore } from '@KTXC/stores/userStore'
|
||||||
|
import {
|
||||||
|
configuration, createRule, deleteRule, listLogs, listRules, maintenance,
|
||||||
|
metrics, updateConfiguration, updateRule,
|
||||||
|
} from '@/services/firewallService'
|
||||||
|
import type { FirewallConfiguration, FirewallLog, FirewallRule, RuleStatus, Scope } from '@/types/firewall'
|
||||||
|
|
||||||
|
const user = useUserStore()
|
||||||
|
const tab = ref('rules')
|
||||||
|
const scope = ref<Scope>('tenant')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const notice = ref('')
|
||||||
|
const rules = ref<FirewallRule[]>([])
|
||||||
|
const logs = ref<FirewallLog[]>([])
|
||||||
|
const totalRules = ref(0)
|
||||||
|
const totalLogs = ref(0)
|
||||||
|
const ruleOffset = ref(0)
|
||||||
|
const logOffset = ref(0)
|
||||||
|
const blockedRequests = ref<number | null>(null)
|
||||||
|
const maintenanceState = ref<Record<string, unknown> | null>(null)
|
||||||
|
const status = ref<RuleStatus>('active')
|
||||||
|
const typeFilter = ref('')
|
||||||
|
const actionFilter = ref('')
|
||||||
|
const eventFilter = ref('')
|
||||||
|
const resultFilter = ref('')
|
||||||
|
const ipFilter = ref('')
|
||||||
|
|
||||||
|
const canTenantRead = computed(() => user.hasPermission('firewall.tenant.rules.read'))
|
||||||
|
const canTenantManage = computed(() => user.hasPermission('firewall.tenant.rules.manage'))
|
||||||
|
const canSystemRead = computed(() => user.hasPermission('firewall.system.rules.read'))
|
||||||
|
const canSystemManage = computed(() => user.hasPermission('firewall.system.rules.manage'))
|
||||||
|
const canReadLogs = computed(() => user.hasPermission(`firewall.${scope.value}.logs.read`))
|
||||||
|
const canManageRules = computed(() => scope.value === 'system' ? canSystemManage.value : canTenantManage.value)
|
||||||
|
const canReadSettings = computed(() => user.hasPermission('firewall.tenant.settings.read'))
|
||||||
|
const canManageSettings = computed(() => user.hasPermission('firewall.tenant.settings.manage'))
|
||||||
|
const canReadMaintenance = computed(() => user.hasPermission('firewall.system.maintenance.read'))
|
||||||
|
const scopeOptions = computed(() => [
|
||||||
|
...(canTenantRead.value ? [{ title: 'Tenant rules', value: 'tenant' }] : []),
|
||||||
|
...(canSystemRead.value ? [{ title: 'System-wide rules', value: 'system' }] : []),
|
||||||
|
])
|
||||||
|
|
||||||
|
const createOpen = ref(false)
|
||||||
|
const createForm = reactive({ type: 'ip', action: 'block', value: '', reason: '', durationSeconds: null as number | null, confirmCurrentIp: false })
|
||||||
|
const actionOpen = ref(false)
|
||||||
|
const selectedRule = ref<FirewallRule | null>(null)
|
||||||
|
const selectedOperation = ref<'disable' | 'enable' | 'extend' | 'delete'>('disable')
|
||||||
|
const actionReason = ref('')
|
||||||
|
const extensionSeconds = ref<number | null>(3600)
|
||||||
|
const confirmCurrentIp = ref(false)
|
||||||
|
const settingsForm = reactive<FirewallConfiguration & { reason: string }>({
|
||||||
|
enabled: true, maxAuthFailures: 5, authFailureWindow: 300, autoBlockDuration: 3600, reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function message(value: string, failure = false) {
|
||||||
|
if (failure) error.value = value
|
||||||
|
else notice.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRules() {
|
||||||
|
if (scope.value === 'system' ? !canSystemRead.value : !canTenantRead.value) return
|
||||||
|
loading.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const page = await listRules(scope.value, status.value, typeFilter.value || undefined, actionFilter.value || undefined, ruleOffset.value)
|
||||||
|
rules.value = page.items; totalRules.value = page.total
|
||||||
|
} catch (e) { message(e instanceof Error ? e.message : 'Unable to load rules', true) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
if (!canReadLogs.value) return
|
||||||
|
loading.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const filters: Record<string, string> = {}
|
||||||
|
if (eventFilter.value) filters.eventType = eventFilter.value
|
||||||
|
if (resultFilter.value) filters.result = resultFilter.value
|
||||||
|
if (ipFilter.value) filters.ipAddress = ipFilter.value
|
||||||
|
const page = await listLogs(scope.value, filters, logOffset.value)
|
||||||
|
logs.value = page.items; totalLogs.value = page.total
|
||||||
|
} catch (e) { message(e instanceof Error ? e.message : 'Unable to load logs', true) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverview() {
|
||||||
|
try { blockedRequests.value = (await metrics(scope.value)).blockedRequests } catch { blockedRequests.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
if (!canReadSettings.value) return
|
||||||
|
try { Object.assign(settingsForm, await configuration(), { reason: '' }) }
|
||||||
|
catch (e) { message(e instanceof Error ? e.message : 'Unable to load configuration', true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMaintenance() {
|
||||||
|
if (!canReadMaintenance.value) return
|
||||||
|
try { maintenanceState.value = await maintenance() }
|
||||||
|
catch (e) { message(e instanceof Error ? e.message : 'Unable to load maintenance status', true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
loading.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
await createRule(scope.value, createForm)
|
||||||
|
createOpen.value = false
|
||||||
|
Object.assign(createForm, { type: 'ip', action: 'block', value: '', reason: '', durationSeconds: null, confirmCurrentIp: false })
|
||||||
|
message('Firewall rule created.'); await loadRules()
|
||||||
|
} catch (e) { message(e instanceof Error ? e.message : 'Unable to create rule', true) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAction(rule: FirewallRule, operation: typeof selectedOperation.value) {
|
||||||
|
selectedRule.value = rule; selectedOperation.value = operation; actionReason.value = ''
|
||||||
|
extensionSeconds.value = 3600; confirmCurrentIp.value = false; actionOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAction() {
|
||||||
|
if (!selectedRule.value) return
|
||||||
|
loading.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
if (selectedOperation.value === 'delete') await deleteRule(scope.value, selectedRule.value.id, actionReason.value)
|
||||||
|
else await updateRule(scope.value, selectedRule.value.id, {
|
||||||
|
operation: selectedOperation.value, reason: actionReason.value,
|
||||||
|
durationSeconds: selectedOperation.value === 'extend' ? extensionSeconds.value : null,
|
||||||
|
confirmCurrentIp: confirmCurrentIp.value,
|
||||||
|
})
|
||||||
|
actionOpen.value = false; message(`Rule ${selectedOperation.value} operation completed.`); await loadRules()
|
||||||
|
} catch (e) { message(e instanceof Error ? e.message : 'Unable to update rule', true) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
loading.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const result = await updateConfiguration(settingsForm)
|
||||||
|
Object.assign(settingsForm, result.configuration, { reason: '' }); message('Firewall configuration updated.')
|
||||||
|
} catch (e) { message(e instanceof Error ? e.message : 'Unable to update configuration', true) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) : 'Never'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(scope, () => { ruleOffset.value = 0; logOffset.value = 0; loadRules(); loadOverview(); if (tab.value === 'logs') loadLogs() })
|
||||||
|
watch([status, typeFilter, actionFilter], () => { ruleOffset.value = 0; loadRules() })
|
||||||
|
watch(tab, value => { if (value === 'logs') loadLogs(); if (value === 'settings') loadSettings(); if (value === 'operations') loadMaintenance() })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!canTenantRead.value && canSystemRead.value) scope.value = 'system'
|
||||||
|
loadRules(); loadOverview()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-container fluid class="pa-4 pa-md-6 firewall-page">
|
||||||
|
<div class="d-flex align-center flex-wrap ga-3 mb-6">
|
||||||
|
<v-avatar color="primary" rounded="lg" size="46"><v-icon icon="mdi-shield-lock-outline" color="white" /></v-avatar>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-h5 font-weight-bold">Firewall Manager</h1>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-0">Manage policy, investigate events, and monitor protection.</p>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-select v-if="scopeOptions.length > 1" v-model="scope" :items="scopeOptions" density="compact" variant="outlined" hide-details class="scope-select" />
|
||||||
|
<v-btn icon="mdi-refresh" variant="tonal" :loading="loading" aria-label="Refresh" @click="tab === 'logs' ? loadLogs() : loadRules()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert v-if="error" type="error" variant="tonal" closable class="mb-4" @click:close="error = ''">{{ error }}</v-alert>
|
||||||
|
<v-alert v-if="notice" type="success" variant="tonal" closable class="mb-4" @click:close="notice = ''">{{ notice }}</v-alert>
|
||||||
|
|
||||||
|
<v-row class="mb-3">
|
||||||
|
<v-col cols="12" sm="6" md="3"><v-card rounded="lg" variant="tonal"><v-card-text><div class="text-caption text-medium-emphasis">Blocked requests</div><div class="text-h4 font-weight-bold">{{ blockedRequests ?? '—' }}</div></v-card-text></v-card></v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3"><v-card rounded="lg" variant="tonal"><v-card-text><div class="text-caption text-medium-emphasis">Rules in view</div><div class="text-h4 font-weight-bold">{{ totalRules }}</div></v-card-text></v-card></v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3"><v-card rounded="lg" variant="tonal"><v-card-text><div class="text-caption text-medium-emphasis">Policy scope</div><div class="text-h6 font-weight-bold text-capitalize">{{ scope }}</div></v-card-text></v-card></v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-card rounded="lg" border>
|
||||||
|
<v-tabs v-model="tab" color="primary">
|
||||||
|
<v-tab value="rules" prepend-icon="mdi-format-list-bulleted">Rules</v-tab>
|
||||||
|
<v-tab v-if="canReadLogs" value="logs" prepend-icon="mdi-text-box-search-outline">Logs</v-tab>
|
||||||
|
<v-tab v-if="canReadSettings" value="settings" prepend-icon="mdi-tune-variant">Settings</v-tab>
|
||||||
|
<v-tab v-if="canReadMaintenance" value="operations" prepend-icon="mdi-wrench-clock">Operations</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
<v-divider />
|
||||||
|
<v-window v-model="tab">
|
||||||
|
<v-window-item value="rules">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex ga-3 flex-wrap align-center mb-4">
|
||||||
|
<v-select v-model="status" :items="['active','disabled','expired','all']" label="Status" density="compact" variant="outlined" hide-details class="filter" />
|
||||||
|
<v-select v-model="typeFilter" :items="[{title:'All types',value:''},{title:'IP address',value:'ip'},{title:'CIDR range',value:'ip_range'},{title:'Device',value:'device'}]" label="Type" density="compact" variant="outlined" hide-details class="filter" />
|
||||||
|
<v-select v-model="actionFilter" :items="[{title:'All actions',value:''},{title:'Block',value:'block'},{title:'Allow',value:'allow'}]" label="Action" density="compact" variant="outlined" hide-details class="filter" />
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn v-if="canManageRules" color="primary" prepend-icon="mdi-plus" @click="createOpen = true">Create rule</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-progress-linear v-if="loading" indeterminate class="mb-3" />
|
||||||
|
<v-table hover>
|
||||||
|
<thead><tr><th>Rule</th><th>Action</th><th>Reason</th><th>Expires</th><th>Status</th><th v-if="canManageRules" class="text-right">Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="rule in rules" :key="rule.id">
|
||||||
|
<td><div class="font-weight-medium">{{ rule.value }}</div><div class="text-caption text-medium-emphasis">{{ rule.type.replace('_', ' ') }}</div></td>
|
||||||
|
<td><v-chip :color="rule.action === 'block' ? 'error' : 'success'" size="small" variant="tonal">{{ rule.action }}</v-chip></td>
|
||||||
|
<td>{{ rule.reason }}</td><td>{{ formatDate(rule.expiresAt) }}</td>
|
||||||
|
<td><v-chip :color="rule.enabled ? 'success' : 'warning'" size="small" variant="tonal">{{ rule.enabled ? 'Enabled' : 'Disabled' }}</v-chip></td>
|
||||||
|
<td v-if="canManageRules" class="text-right text-no-wrap">
|
||||||
|
<v-btn v-if="rule.enabled" icon="mdi-pause" size="small" variant="text" aria-label="Disable" @click="openAction(rule, 'disable')" />
|
||||||
|
<v-btn v-else icon="mdi-play" size="small" variant="text" aria-label="Enable" @click="openAction(rule, 'enable')" />
|
||||||
|
<v-btn v-if="rule.expiresAt" icon="mdi-clock-plus-outline" size="small" variant="text" aria-label="Extend" @click="openAction(rule, 'extend')" />
|
||||||
|
<v-btn icon="mdi-delete-outline" color="error" size="small" variant="text" aria-label="Delete" @click="openAction(rule, 'delete')" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!rules.length"><td :colspan="canManageRules ? 6 : 5" class="text-center text-medium-emphasis py-8">No rules match these filters.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
<div class="d-flex justify-end align-center ga-2 mt-4"><span class="text-caption">{{ totalRules ? ruleOffset + 1 : 0 }}–{{ Math.min(ruleOffset + 25, totalRules) }} of {{ totalRules }}</span><v-btn icon="mdi-chevron-left" size="small" :disabled="ruleOffset === 0" @click="ruleOffset -= 25; loadRules()" /><v-btn icon="mdi-chevron-right" size="small" :disabled="ruleOffset + 25 >= totalRules" @click="ruleOffset += 25; loadRules()" /></div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-window-item>
|
||||||
|
|
||||||
|
<v-window-item value="logs"><v-card-text>
|
||||||
|
<div class="d-flex ga-3 flex-wrap mb-4"><v-text-field v-model="ipFilter" label="IP address" density="compact" variant="outlined" hide-details class="filter" /><v-text-field v-model="eventFilter" label="Event type" density="compact" variant="outlined" hide-details class="filter" /><v-select v-model="resultFilter" :items="[{title:'All results',value:''},{title:'Blocked',value:'blocked'},{title:'Allowed',value:'allowed'},{title:'Recorded',value:'recorded'}]" label="Result" density="compact" variant="outlined" hide-details class="filter" /><v-btn prepend-icon="mdi-magnify" variant="tonal" @click="logOffset = 0; loadLogs()">Apply</v-btn></div>
|
||||||
|
<v-table hover><thead><tr><th>Time</th><th>Event</th><th>Result</th><th>IP</th><th>Rule</th><th>Actor</th></tr></thead><tbody><tr v-for="entry in logs" :key="entry.id"><td>{{ formatDate(entry.timestamp) }}</td><td>{{ entry.eventType }}</td><td><v-chip size="small" variant="tonal" :color="entry.result === 'blocked' ? 'error' : entry.result === 'allowed' ? 'success' : 'info'">{{ entry.result }}</v-chip></td><td>{{ entry.ipAddress || '—' }}</td><td>{{ entry.ruleId || '—' }}</td><td>{{ entry.identityId || '—' }}</td></tr><tr v-if="!logs.length"><td colspan="6" class="text-center text-medium-emphasis py-8">No audit events match these filters.</td></tr></tbody></v-table>
|
||||||
|
<div class="d-flex justify-end align-center ga-2 mt-4"><span class="text-caption">{{ totalLogs ? logOffset + 1 : 0 }}–{{ Math.min(logOffset + 25, totalLogs) }} of {{ totalLogs }}</span><v-btn icon="mdi-chevron-left" size="small" :disabled="logOffset === 0" @click="logOffset -= 25; loadLogs()" /><v-btn icon="mdi-chevron-right" size="small" :disabled="logOffset + 25 >= totalLogs" @click="logOffset += 25; loadLogs()" /></div>
|
||||||
|
</v-card-text></v-window-item>
|
||||||
|
|
||||||
|
<v-window-item value="settings"><v-card-text><v-alert v-if="!canManageSettings" type="info" variant="tonal" class="mb-4">You can view these settings but cannot change them.</v-alert><v-form @submit.prevent="saveSettings"><v-switch v-model="settingsForm.enabled" label="Enable tenant firewall rules" color="primary" :disabled="!canManageSettings" /><v-row><v-col cols="12" md="4"><v-number-input v-model="settingsForm.maxAuthFailures" label="Maximum authentication failures" :min="1" :max="1000" variant="outlined" :disabled="!canManageSettings" /></v-col><v-col cols="12" md="4"><v-number-input v-model="settingsForm.authFailureWindow" label="Failure window (seconds)" :min="1" :max="86400" variant="outlined" :disabled="!canManageSettings" /></v-col><v-col cols="12" md="4"><v-number-input v-model="settingsForm.autoBlockDuration" label="Automatic block duration (seconds)" :min="1" :max="31536000" variant="outlined" :disabled="!canManageSettings" /></v-col></v-row><v-textarea v-if="canManageSettings" v-model="settingsForm.reason" label="Reason for this change" rows="2" counter="1000" variant="outlined" /><v-btn v-if="canManageSettings" type="submit" color="primary" :disabled="!settingsForm.reason.trim()" :loading="loading">Save settings</v-btn></v-form></v-card-text></v-window-item>
|
||||||
|
|
||||||
|
<v-window-item value="operations"><v-card-text><h2 class="text-h6 mb-3">Maintenance status</h2><v-list v-if="maintenanceState" lines="two" border rounded="lg"><v-list-item title="Status" :subtitle="String(maintenanceState.status ?? 'never_run')" prepend-icon="mdi-database-clock" /><v-list-item title="Last completed" :subtitle="formatDate(maintenanceState.completedAt as string | undefined)" prepend-icon="mdi-clock-check-outline" /><v-list-item v-if="maintenanceState.error" title="Last error" :subtitle="String(maintenanceState.error)" prepend-icon="mdi-alert-circle-outline" /></v-list><v-empty-state v-else icon="mdi-wrench-clock" title="No maintenance result" text="Maintenance has not run yet." /></v-card-text></v-window-item>
|
||||||
|
</v-window>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-dialog v-model="createOpen" max-width="620"><v-card title="Create firewall rule" prepend-icon="mdi-shield-plus-outline"><v-card-text><v-row><v-col cols="6"><v-select v-model="createForm.type" :items="[{title:'IP address',value:'ip'},{title:'CIDR range',value:'ip_range'},{title:'Device',value:'device'}]" label="Type" variant="outlined" /></v-col><v-col cols="6"><v-select v-model="createForm.action" :items="[{title:'Block',value:'block'},{title:'Allow',value:'allow'}]" label="Action" variant="outlined" /></v-col></v-row><v-text-field v-model="createForm.value" label="Value" variant="outlined" /><v-number-input v-if="createForm.action === 'block' && ['ip','device'].includes(createForm.type)" v-model="createForm.durationSeconds" label="Temporary duration in seconds (optional)" :min="1" variant="outlined" /><v-textarea v-model="createForm.reason" label="Administrative reason" rows="2" counter="1000" variant="outlined" /><v-checkbox v-if="createForm.action === 'block' && ['ip','ip_range'].includes(createForm.type)" v-model="createForm.confirmCurrentIp" label="I understand this may block my current IP address" color="warning" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="createOpen = false">Cancel</v-btn><v-btn color="primary" :loading="loading" :disabled="!createForm.value.trim() || !createForm.reason.trim()" @click="submitCreate">Create</v-btn></v-card-actions></v-card></v-dialog>
|
||||||
|
|
||||||
|
<v-dialog v-model="actionOpen" max-width="560"><v-card :title="`${selectedOperation[0].toUpperCase() + selectedOperation.slice(1)} rule`"><v-card-text><v-alert v-if="selectedOperation === 'delete'" type="warning" variant="tonal" class="mb-4">Deletion permanently removes the rule. Its audit history remains available.</v-alert><p class="mb-4"><strong>{{ selectedRule?.value }}</strong></p><v-number-input v-if="selectedOperation === 'extend'" v-model="extensionSeconds" label="Extension in seconds" :min="1" variant="outlined" /><v-textarea v-model="actionReason" label="Administrative reason" rows="2" counter="1000" variant="outlined" /><v-checkbox v-if="selectedOperation === 'enable' && selectedRule?.action === 'block'" v-model="confirmCurrentIp" label="Confirm even if this blocks my current IP address" color="warning" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="actionOpen = false">Cancel</v-btn><v-btn :color="selectedOperation === 'delete' ? 'error' : 'primary'" :disabled="!actionReason.trim() || (selectedOperation === 'extend' && !extensionSeconds)" :loading="loading" @click="submitAction">Confirm</v-btn></v-card-actions></v-card></v-dialog>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.firewall-page { max-width: 1600px; margin: 0 auto; }
|
||||||
|
.scope-select { min-width: 210px; max-width: 260px; }
|
||||||
|
.filter { min-width: 170px; max-width: 240px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const routes = [{
|
||||||
|
name: 'firewall_manager',
|
||||||
|
path: '/firewall',
|
||||||
|
component: () => import('@/pages/Main.vue'),
|
||||||
|
meta: { requiresAuth: true, permission: 'firewall_manager.access' },
|
||||||
|
}]
|
||||||
|
|
||||||
|
export default routes
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { FirewallConfiguration, FirewallLog, FirewallMetrics, FirewallRule, Page, RuleStatus, Scope } from '@/types/firewall'
|
||||||
|
|
||||||
|
function endpoint(scope: Scope, suffix: string): string {
|
||||||
|
return scope === 'system' ? `/firewall/system${suffix}` : `/firewall${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(url, { credentials: 'include', ...init })
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = data.error
|
||||||
|
throw new Error(typeof error === 'string' ? error : error?.message || `Request failed (${response.status})`)
|
||||||
|
}
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(values: Record<string, string | number | boolean | null | undefined>): RequestInit {
|
||||||
|
return {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(Object.fromEntries(Object.entries(values).filter(([, value]) => value !== null && value !== undefined))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRules(scope: Scope, status: RuleStatus, type?: string, action?: string, offset = 0): Promise<Page<FirewallRule>> {
|
||||||
|
const query = new URLSearchParams({ status, limit: '25', offset: String(offset) })
|
||||||
|
if (type) query.set('type', type)
|
||||||
|
if (action) query.set('action', action)
|
||||||
|
return request(`${endpoint(scope, '/rules')}?${query}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRule(scope: Scope, values: Record<string, string | number | boolean | null>): Promise<{ rule: FirewallRule }> {
|
||||||
|
return request(endpoint(scope, '/rules'), { method: 'POST', ...json(values) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRule(scope: Scope, ruleId: string, values: Record<string, string | number | boolean | null>): Promise<{ rule: FirewallRule }> {
|
||||||
|
return request(endpoint(scope, `/rules/${encodeURIComponent(ruleId)}`), { method: 'PATCH', ...json(values) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRule(scope: Scope, ruleId: string, reason: string): Promise<{ rule: FirewallRule }> {
|
||||||
|
return request(endpoint(scope, `/rules/${encodeURIComponent(ruleId)}`), { method: 'DELETE', ...json({ reason }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listLogs(scope: Scope, filters: Record<string, string>, offset = 0): Promise<Page<FirewallLog>> {
|
||||||
|
const query = new URLSearchParams({ ...filters, limit: '25', offset: String(offset) })
|
||||||
|
return request(`${endpoint(scope, '/logs')}?${query}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function metrics(scope: Scope): Promise<FirewallMetrics> {
|
||||||
|
return request(endpoint(scope, '/metrics'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configuration(): Promise<FirewallConfiguration> {
|
||||||
|
return request('/firewall/configuration')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateConfiguration(values: FirewallConfiguration & { reason: string }): Promise<{ configuration: FirewallConfiguration }> {
|
||||||
|
return request('/firewall/configuration', { method: 'PUT', ...json(values) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maintenance(): Promise<Record<string, unknown>> {
|
||||||
|
return request('/firewall/system/maintenance')
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export type Scope = 'tenant' | 'system'
|
||||||
|
export type RuleStatus = 'active' | 'disabled' | 'expired' | 'all'
|
||||||
|
|
||||||
|
export interface FirewallRule {
|
||||||
|
id: string
|
||||||
|
tenantId: string | null
|
||||||
|
scope: Scope
|
||||||
|
type: 'ip' | 'ip_range' | 'device'
|
||||||
|
action: 'allow' | 'block'
|
||||||
|
value: string
|
||||||
|
reason: string
|
||||||
|
createdBy: string | null
|
||||||
|
createdAt: string
|
||||||
|
expiresAt: string | null
|
||||||
|
enabled: boolean
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallLog {
|
||||||
|
id: string
|
||||||
|
tenantId: string | null
|
||||||
|
ipAddress: string | null
|
||||||
|
eventType: string
|
||||||
|
result: string
|
||||||
|
ruleId: string | null
|
||||||
|
ruleScope: Scope | null
|
||||||
|
identityId: string | null
|
||||||
|
timestamp: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Page<T> { items: T[]; total: number; limit: number; offset: number }
|
||||||
|
export interface FirewallConfiguration {
|
||||||
|
enabled: boolean
|
||||||
|
maxAuthFailures: number
|
||||||
|
authFailureWindow: number
|
||||||
|
autoBlockDuration: number
|
||||||
|
}
|
||||||
|
export interface FirewallMetrics { blockedRequests: number; since?: string | null }
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["node_modules"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@KTXC/*": ["../../core/src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"include": ["vite.config.*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
{
|
||||||
|
name: 'inject-css-filename',
|
||||||
|
enforce: 'post',
|
||||||
|
generateBundle(_options, bundle) {
|
||||||
|
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
|
||||||
|
if (!cssFile) return
|
||||||
|
for (const fileName of Object.keys(bundle)) {
|
||||||
|
const chunk = bundle[fileName]
|
||||||
|
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
|
||||||
|
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resolve: { alias: { '@': path.resolve(__dirname, './src'), '@KTXC': path.resolve(__dirname, '../../core/src') } },
|
||||||
|
define: { 'process.env': {}, process: undefined },
|
||||||
|
build: {
|
||||||
|
outDir: 'static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true,
|
||||||
|
lib: { entry: path.resolve(__dirname, 'src/main.ts'), formats: ['es'], fileName: () => 'module.mjs' },
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['vue', 'vue-router', 'pinia'],
|
||||||
|
output: { assetFileNames: info => info.name?.endsWith('.css') ? 'firewall_manager-[hash].css' : '[name]-[hash][extname]' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user