feat(api): enhance IP list management with new entry operations and improved error handling
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m50s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added endpoints for adding and deleting entries in IP lists.
- Refactored list creation logic to handle manual list types more effectively.
- Updated refresh logic to rebuild manual list entries.
- Improved error handling for entry operations to ensure data integrity.
- Enhanced response structure for list detail retrieval.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 23:01:23 +07:00
co-authored by Cursor
parent 7f0c1009de
commit 3f7672ab7c
11 changed files with 805 additions and 205 deletions
+67 -28
View File
@@ -9,9 +9,17 @@ import {
putAgentPolicySetsBodySchema,
patchAgentBodySchema,
cloneFromBodySchema,
listEntriesBodySchema,
deleteListEntryBodySchema,
isManualListType,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import { refreshIpList } from '../services/lists/refresh.js'
import {
addListEntries,
deleteListEntry,
mapListDetail,
} from '../services/lists/entries.js'
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
import {
resolveAndStoreHostnameRule,
@@ -279,19 +287,29 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app.post('/lists', async (req) => {
const body = createIpListBodySchema.parse(req.body)
const type =
body.type === 'domains' ? 'static' : body.type
const id = crypto.randomUUID()
const list = repos.insertIpList(app.db, {
id,
name: body.name,
type: body.type,
type,
configJson: JSON.stringify(body.config ?? {}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
if (body.entries?.length) {
repos.replaceIpListEntries(app.db, id, body.entries)
}
if (body.type !== 'static') {
if (body.entries?.length && isManualListType(type)) {
try {
await addListEntries(app.db, id, body.entries)
} catch (err) {
repos.deleteIpList(app.db, id)
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
} else if (!isManualListType(type)) {
await refreshIpList(app.db, id)
}
return {
@@ -305,33 +323,54 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
})
app.get<{ Params: { id: string } }>('/lists/:id', async (req) => {
const l = repos.getIpList(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
return {
id: l.id,
name: l.name,
type: l.type,
config_json: l.configJson,
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entries: repos.listIpListEntries(app.db, l.id).map((e) => e.cidr),
created_at: l.createdAt,
updated_at: l.updatedAt,
}
const detail = mapListDetail(app.db, req.params.id)
if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404)
return detail
})
app.post<{ Params: { id: string } }>(
'/lists/:id/entries',
async (req) => {
const l = repos.getIpList(app.db, req.params.id)
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)
return mapListDetail(app.db, l.id) ?? result
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
},
)
app.delete<{ Params: { id: string } }>(
'/lists/:id/entries',
async (req) => {
const l = repos.getIpList(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
const body = deleteListEntryBodySchema.parse(req.body)
try {
await deleteListEntry(app.db, l.id, body.value)
return mapListDetail(app.db, l.id)
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
},
)
app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => {
await refreshIpList(app.db, req.params.id)
const l = repos.getIpList(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
return {
id: l.id,
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entry_count: repos.listIpListEntries(app.db, l.id).length,
}
const detail = mapListDetail(app.db, req.params.id)
if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404)
return detail
})
app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => {
+208
View File
@@ -0,0 +1,208 @@
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
import {
isManualListType,
normalizeItemToCidrs,
parseListEntry,
readManualItems,
splitListPlaintext,
type ListConfigItem,
} from '@evofw/shared'
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
export function getListConfig(list: {
configJson: string
}): Record<string, unknown> {
try {
return JSON.parse(list.configJson || '{}') as Record<string, unknown>
} catch {
return {}
}
}
async function expandItem(item: ListConfigItem): Promise<string[]> {
if (item.kind === 'hostname') {
if (item.resolved_cidrs?.length) return item.resolved_cidrs
return resolveHostnameToCidrs(item.value)
}
return normalizeItemToCidrs(item)
}
/** Rebuild ip_list_entries from config.items (manual lists). */
export async function rebuildManualListEntries(
db: Db,
listId: string,
): 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[] = []
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)
}
}
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
}
export async function addListEntries(
db: Db,
listId: string,
values: string[],
): Promise<{ items: ListConfigItem[]; entries: string[] }> {
const list = repos.getIpList(db, listId)
if (!list) throw new Error('List not found')
if (!isManualListType(list.type)) {
throw new Error('Записи можно добавлять только в ручной список')
}
const config = getListConfig(list)
let items = readManualItems(list.configJson)
const existing = new Set(items.map((i) => i.value.toLowerCase()))
const tokens = values.flatMap((v) => splitListPlaintext(v))
const added: ListConfigItem[] = []
for (const token of tokens) {
const parsed = parseListEntry(token)
const key = parsed.value.toLowerCase()
if (existing.has(key)) continue
const resolved = await expandItem(parsed)
const item: ListConfigItem = { ...parsed, resolved_cidrs: resolved }
items.push(item)
existing.add(key)
added.push(item)
}
if (added.length === 0) {
throw new Error('Нет новых записей для добавления')
}
config.items = items
delete config.domains
const type = list.type === 'domains' ? 'static' : list.type
repos.updateIpList(db, listId, {
type,
configJson: JSON.stringify(config),
})
const entries = await rebuildManualListEntries(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,
}
}
export async function deleteListEntry(
db: Db,
listId: string,
value: string,
): Promise<{ items: ListConfigItem[]; entries: string[] }> {
const list = repos.getIpList(db, listId)
if (!list) throw new Error('List not found')
if (!isManualListType(list.type)) {
throw new Error('Записи можно удалять только из ручного списка')
}
const config = getListConfig(list)
const needle = value.trim().toLowerCase()
let items = readManualItems(list.configJson)
const before = items.length
items = items.filter((i) => i.value.toLowerCase() !== needle)
if (items.length === before) {
throw new Error('Запись не найдена')
}
config.items = items
delete config.domains
const type = list.type === 'domains' ? 'static' : list.type
repos.updateIpList(db, listId, {
type,
configJson: JSON.stringify(config),
})
const entries = await rebuildManualListEntries(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,
}
}
export function mapListDetail(db: Db, listId: string) {
const l = repos.getIpList(db, listId)
if (!l) return null
const entries = repos.listIpListEntries(db, listId).map((e) => e.cidr)
const items = isManualListType(l.type)
? readManualItems(l.configJson).map((item) => {
const resolved =
item.resolved_cidrs?.length
? item.resolved_cidrs
: normalizeItemToCidrs(item)
return {
kind: item.kind,
value: item.value,
resolved_count: resolved.length,
resolved_cidrs: resolved,
}
})
: entries.map((cidr) => ({
kind: 'cidr' as const,
value: cidr,
resolved_count: 1,
resolved_cidrs: [cidr],
}))
return {
id: l.id,
name: l.name,
type: l.type,
config_json: l.configJson,
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entries,
items,
entry_count: entries.length,
created_at: l.createdAt,
updated_at: l.updatedAt,
}
}
+5 -39
View File
@@ -1,7 +1,8 @@
import { createHash } from 'node:crypto'
import { resolve4, resolve6 } from 'node:dns/promises'
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
import { refreshAllHostnameRules } from '../policy/resolve-hostname.js'
import { rebuildManualListEntries } from './entries.js'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
@@ -46,27 +47,6 @@ async function fetchJsonUrl(url: string): Promise<string[]> {
return uniq(out)
}
async function resolveDomains(domains: string[]): Promise<string[]> {
const out: string[] = []
for (const d of domains) {
const host = d.trim().replace(/\.$/, '')
if (!host) continue
try {
const a = await resolve4(host)
out.push(...a.map((ip) => `${ip}/32`))
} catch {
/* ignore */
}
try {
const aaaa = await resolve6(host)
out.push(...aaaa.map((ip) => `${ip}/128`))
} catch {
/* ignore */
}
}
return uniq(out)
}
async function fetchEvobgpCommunity(
apiUrl: string,
token: string,
@@ -118,21 +98,13 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
try {
let cidrs: string[] = []
if (list.type === 'static') {
cidrs = repos.listIpListEntries(db, listId).map((e) => e.cidr)
if (list.type === 'static' || list.type === 'domains') {
cidrs = await rebuildManualListEntries(db, listId)
} else if (list.type === 'json_url') {
const url = String(config.url ?? '')
if (!url) throw new Error('config.url required')
cidrs = await fetchJsonUrl(url)
repos.replaceIpListEntries(db, listId, cidrs)
} else if (list.type === 'domains') {
const domains = Array.isArray(config.domains)
? (config.domains as string[])
: String(config.domains ?? '')
.split(/[\s,]+/)
.filter(Boolean)
cidrs = await resolveDomains(domains)
repos.replaceIpListEntries(db, listId, cidrs)
} else if (list.type === 'evobgp_community') {
const apiUrl =
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url')
@@ -154,10 +126,7 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
lastError: null,
})
// Bump all agents so they re-fetch policy
for (const a of repos.listAgents(db)) {
if (a.status === 'approved') repos.bumpAgentGeneration(db, a.id)
}
repos.bumpAgentsForList(db, listId)
} catch (err) {
repos.updateIpList(db, listId, {
lastError: err instanceof Error ? err.message : String(err),
@@ -166,11 +135,8 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
}
}
import { refreshAllHostnameRules } from '../policy/resolve-hostname.js'
export async function refreshAllLists(db: Db): Promise<void> {
for (const list of repos.listIpLists(db)) {
if (list.type === 'static') continue
await refreshIpList(db, list.id)
}
await refreshAllHostnameRules(db)