feat(api, web): enhance list entry management and UI consistency
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m57s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Updated API to support structured list entry inputs, allowing for nested list references.
- Improved error handling for list operations to prevent cyclic references.
- Refactored UI components to ensure consistent labeling and navigation for IP lists.
- Enhanced list detail and catalog pages with better filtering and entry management features.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 23:25:05 +07:00
co-authored by Cursor
parent 3f7672ab7c
commit c505ab82e8
13 changed files with 952 additions and 518 deletions
+5 -2
View File
@@ -300,7 +300,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
})
if (body.entries?.length && isManualListType(type)) {
try {
await addListEntries(app.db, id, body.entries)
await addListEntries(app.db, id, { values: body.entries })
} catch (err) {
repos.deleteIpList(app.db, id)
throw new AppError(
@@ -335,7 +335,10 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
const body = listEntriesBodySchema.parse(req.body)
try {
const result = await addListEntries(app.db, l.id, body.values)
const result = await addListEntries(app.db, l.id, {
values: body.values,
items: body.items,
})
return mapListDetail(app.db, l.id) ?? result
} catch (err) {
throw new AppError(
@@ -0,0 +1,93 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
import {
addListEntries,
deleteListEntry,
wouldCreateListCycle,
rebuildListCascade,
} from './entries.js'
function insertManual(
db: ReturnType<typeof createMemoryDb>['db'],
id: string,
name: string,
) {
const now = new Date().toISOString()
repos.insertIpList(db, {
id,
name,
type: 'static',
configJson: '{}',
createdAt: now,
updatedAt: now,
})
}
describe('nested lists', () => {
let db: ReturnType<typeof createMemoryDb>['db']
beforeEach(() => {
const mem = createMemoryDb()
runMigrations(mem.sqlite)
db = mem.db
})
it('adds nested list and materializes child CIDRs into parent', async () => {
insertManual(db, 'child', 'Child')
insertManual(db, 'parent', 'Parent')
await addListEntries(db, 'child', { values: ['8.8.8.8', '10.0.0.0/8'] })
await addListEntries(db, 'parent', {
items: [{ kind: 'list', value: 'child' }],
})
const parentCidrs = repos.listIpListEntries(db, 'parent').map((e) => e.cidr)
expect(parentCidrs).toContain('8.8.8.8/32')
expect(parentCidrs).toContain('10.0.0.0/8')
})
it('rejects self-reference', async () => {
insertManual(db, 'a', 'A')
await expect(
addListEntries(db, 'a', { items: [{ kind: 'list', value: 'a' }] }),
).rejects.toThrow(/себя/)
})
it('rejects cycles A→B→A', async () => {
insertManual(db, 'a', 'A')
insertManual(db, 'b', 'B')
await addListEntries(db, 'a', { items: [{ kind: 'list', value: 'b' }] })
expect(wouldCreateListCycle(db, 'b', 'a')).toBe(true)
await expect(
addListEntries(db, 'b', { items: [{ kind: 'list', value: 'a' }] }),
).rejects.toThrow(/цикл/i)
})
it('cascades rebuild when child changes', async () => {
insertManual(db, 'child', 'Child')
insertManual(db, 'parent', 'Parent')
await addListEntries(db, 'child', { values: ['1.1.1.1'] })
await addListEntries(db, 'parent', {
items: [{ kind: 'list', value: 'child' }],
})
expect(
repos.listIpListEntries(db, 'parent').map((e) => e.cidr),
).toContain('1.1.1.1/32')
await addListEntries(db, 'child', { values: ['9.9.9.9'] })
await rebuildListCascade(db, 'child')
const parentCidrs = repos.listIpListEntries(db, 'parent').map((e) => e.cidr)
expect(parentCidrs).toContain('1.1.1.1/32')
expect(parentCidrs).toContain('9.9.9.9/32')
await deleteListEntry(db, 'child', '1.1.1.1')
const afterDelete = repos
.listIpListEntries(db, 'parent')
.map((e) => e.cidr)
expect(afterDelete).not.toContain('1.1.1.1/32')
expect(afterDelete).toContain('9.9.9.9/32')
})
})
+167 -35
View File
@@ -2,11 +2,13 @@ import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
import {
isManualListType,
listNestedChildIds,
normalizeItemToCidrs,
parseListEntry,
readManualItems,
splitListPlaintext,
type ListConfigItem,
type ListEntryInput,
} from '@evofw/shared'
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
@@ -24,11 +26,67 @@ export function getListConfig(list: {
}
}
async function expandItem(item: ListConfigItem): Promise<string[]> {
/** True if adding parent→child would create a cycle. */
export function wouldCreateListCycle(
db: Db,
parentId: string,
childId: string,
): boolean {
if (parentId === childId) return true
const stack = [childId]
const seen = new Set<string>()
while (stack.length) {
const id = stack.pop()!
if (id === parentId) return true
if (seen.has(id)) continue
seen.add(id)
const list = repos.getIpList(db, id)
if (!list || !isManualListType(list.type)) continue
for (const nested of listNestedChildIds(list.configJson)) {
stack.push(nested)
}
}
return false
}
/** Manual lists that reference childId via kind=list. */
export function findParentListIds(db: Db, childId: string): string[] {
const parents: string[] = []
for (const list of repos.listIpLists(db)) {
if (!isManualListType(list.type)) continue
if (list.id === childId) continue
if (listNestedChildIds(list.configJson).includes(childId)) {
parents.push(list.id)
}
}
return parents
}
async function expandItem(
db: Db,
item: ListConfigItem,
visiting: Set<string>,
): Promise<string[]> {
if (item.kind === 'hostname') {
if (item.resolved_cidrs?.length) return item.resolved_cidrs
return resolveHostnameToCidrs(item.value)
}
if (item.kind === 'list') {
const childId = item.value
if (visiting.has(childId)) {
throw new Error(`Циклическая ссылка списков: ${childId}`)
}
const child = repos.getIpList(db, childId)
if (!child) {
throw new Error(`Вложенный список не найден: ${childId}`)
}
// Prefer materialized CIDRs; for manual children rebuild if empty.
let entries = repos.listIpListEntries(db, childId).map((e) => e.cidr)
if (entries.length === 0 && isManualListType(child.type)) {
entries = await rebuildManualListEntries(db, childId, visiting)
}
return entries
}
return normalizeItemToCidrs(item)
}
@@ -36,42 +94,93 @@ async function expandItem(item: ListConfigItem): Promise<string[]> {
export async function rebuildManualListEntries(
db: Db,
listId: string,
visiting: Set<string> = new Set(),
): Promise<string[]> {
const list = repos.getIpList(db, listId)
if (!list || !isManualListType(list.type)) return []
const config = getListConfig(list)
const items = readManualItems(list.configJson)
const nextItems: ListConfigItem[] = []
const all: string[] = []
if (visiting.has(listId)) {
throw new Error(`Циклическая ссылка списков: ${listId}`)
}
visiting.add(listId)
for (const item of items) {
try {
const cidrs = await expandItem({
...item,
resolved_cidrs: undefined,
})
nextItems.push({ ...item, resolved_cidrs: cidrs })
all.push(...cidrs)
} catch {
nextItems.push({ ...item, resolved_cidrs: item.resolved_cidrs ?? [] })
if (item.resolved_cidrs?.length) all.push(...item.resolved_cidrs)
try {
const config = getListConfig(list)
const items = readManualItems(list.configJson)
const nextItems: ListConfigItem[] = []
const all: string[] = []
for (const item of items) {
try {
const cidrs = await expandItem(
db,
{ ...item, resolved_cidrs: undefined },
visiting,
)
nextItems.push({ ...item, resolved_cidrs: cidrs })
all.push(...cidrs)
} catch {
nextItems.push({ ...item, resolved_cidrs: item.resolved_cidrs ?? [] })
if (item.resolved_cidrs?.length) all.push(...item.resolved_cidrs)
}
}
config.items = nextItems
delete config.domains
repos.updateIpList(db, listId, { configJson: JSON.stringify(config) })
const cidrs = uniq(all)
repos.replaceIpListEntries(db, listId, cidrs)
return cidrs
} finally {
visiting.delete(listId)
}
}
/** Rebuild list and all ancestor lists that nest it; bump agents. */
export async function rebuildListCascade(
db: Db,
listId: string,
): Promise<string[]> {
const affected: string[] = []
const queue = [listId]
const seen = new Set<string>()
while (queue.length) {
const id = queue.shift()!
if (seen.has(id)) continue
seen.add(id)
const list = repos.getIpList(db, id)
if (!list) continue
if (isManualListType(list.type)) {
await rebuildManualListEntries(db, id)
}
affected.push(id)
for (const parentId of findParentListIds(db, id)) {
if (!seen.has(parentId)) queue.push(parentId)
}
}
config.items = nextItems
delete config.domains
repos.updateIpList(db, listId, { configJson: JSON.stringify(config) })
for (const id of affected) {
repos.bumpAgentsForList(db, id)
}
return affected
}
const cidrs = uniq(all)
repos.replaceIpListEntries(db, listId, cidrs)
return cidrs
function normalizeInputItem(input: ListEntryInput): ListConfigItem {
if (input.kind === 'list') {
return { kind: 'list', value: input.value.trim() }
}
if (input.kind === 'hostname') {
return { kind: 'hostname', value: input.value.trim().toLowerCase() }
}
return { kind: input.kind, value: input.value.trim() }
}
export async function addListEntries(
db: Db,
listId: string,
values: string[],
input: { values?: string[]; items?: ListEntryInput[] },
): Promise<{ items: ListConfigItem[]; entries: string[] }> {
const list = repos.getIpList(db, listId)
if (!list) throw new Error('List not found')
@@ -81,16 +190,37 @@ export async function addListEntries(
const config = getListConfig(list)
let items = readManualItems(list.configJson)
const existing = new Set(items.map((i) => i.value.toLowerCase()))
const existing = new Set(items.map((i) => `${i.kind}:${i.value.toLowerCase()}`))
const toAdd: ListConfigItem[] = []
for (const token of (input.values ?? []).flatMap((v) => splitListPlaintext(v))) {
toAdd.push(parseListEntry(token))
}
for (const raw of input.items ?? []) {
toAdd.push(normalizeInputItem(raw))
}
const tokens = values.flatMap((v) => splitListPlaintext(v))
const added: ListConfigItem[] = []
for (const token of tokens) {
const parsed = parseListEntry(token)
const key = parsed.value.toLowerCase()
for (const parsed of toAdd) {
if (parsed.kind === 'list') {
if (parsed.value === listId) {
throw new Error('Список не может ссылаться на себя')
}
const child = repos.getIpList(db, parsed.value)
if (!child) {
throw new Error(`Список не найден: ${parsed.value}`)
}
if (wouldCreateListCycle(db, listId, parsed.value)) {
throw new Error('Добавление создаст циклическую ссылку списков')
}
}
const key = `${parsed.kind}:${parsed.value.toLowerCase()}`
if (existing.has(key)) continue
const resolved = await expandItem(parsed)
const resolved = await expandItem(db, parsed, new Set([listId]))
const item: ListConfigItem = { ...parsed, resolved_cidrs: resolved }
items.push(item)
existing.add(key)
@@ -110,16 +240,15 @@ export async function addListEntries(
configJson: JSON.stringify(config),
})
const entries = await rebuildManualListEntries(db, listId)
await rebuildListCascade(db, listId)
repos.updateIpList(db, listId, {
refreshedAt: new Date().toISOString(),
lastError: null,
})
repos.bumpAgentsForList(db, listId)
return {
items: readManualItems(repos.getIpList(db, listId)!.configJson),
entries,
entries: repos.listIpListEntries(db, listId).map((e) => e.cidr),
}
}
@@ -152,16 +281,15 @@ export async function deleteListEntry(
configJson: JSON.stringify(config),
})
const entries = await rebuildManualListEntries(db, listId)
await rebuildListCascade(db, listId)
repos.updateIpList(db, listId, {
refreshedAt: new Date().toISOString(),
lastError: null,
})
repos.bumpAgentsForList(db, listId)
return {
items: readManualItems(repos.getIpList(db, listId)!.configJson),
entries,
entries: repos.listIpListEntries(db, listId).map((e) => e.cidr),
}
}
@@ -177,9 +305,12 @@ export function mapListDetail(db: Db, listId: string) {
item.resolved_cidrs?.length
? item.resolved_cidrs
: normalizeItemToCidrs(item)
const child =
item.kind === 'list' ? repos.getIpList(db, item.value) : null
return {
kind: item.kind,
value: item.value,
list_name: child?.name ?? null,
resolved_count: resolved.length,
resolved_cidrs: resolved,
}
@@ -187,6 +318,7 @@ export function mapListDetail(db: Db, listId: string) {
: entries.map((cidr) => ({
kind: 'cidr' as const,
value: cidr,
list_name: null as string | null,
resolved_count: 1,
resolved_cidrs: [cidr],
}))
+15 -2
View File
@@ -2,7 +2,11 @@ import { createHash } from 'node:crypto'
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
import { refreshAllHostnameRules } from '../policy/resolve-hostname.js'
import { rebuildManualListEntries } from './entries.js'
import {
findParentListIds,
rebuildListCascade,
rebuildManualListEntries,
} from './entries.js'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
@@ -126,7 +130,16 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
lastError: null,
})
repos.bumpAgentsForList(db, listId)
// Cascade to parents that nest this list (skip self rebuild for non-manual —
// CIDRs already replaced above).
if (list.type === 'static' || list.type === 'domains') {
await rebuildListCascade(db, listId)
} else {
repos.bumpAgentsForList(db, listId)
for (const parentId of findParentListIds(db, listId)) {
await rebuildListCascade(db, parentId)
}
}
} catch (err) {
repos.updateIpList(db, listId, {
lastError: err instanceof Error ? err.message : String(err),