Initial commit for firewall_manager module

This commit is contained in:
2026-08-07 00:56:53 -04:00
commit 6a065376e1
15 changed files with 546 additions and 0 deletions
+27
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
{
"name": "ktxm/firewall_manager",
"type": "project",
"autoload": {
"psr-4": {
"KTXM\\FirewallManager\\": "lib/"
}
}
}
+41
View File
@@ -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',
];
}
}
+28
View File
@@ -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"
}
}
+13
View File
@@ -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
+7
View File
@@ -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) {} }
+241
View File
@@ -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>
+8
View File
@@ -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
+62
View File
@@ -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')
}
+39
View File
@@ -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 }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -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/*"]
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+15
View File
@@ -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"]
}
}
+35
View File
@@ -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]' },
},
},
})