feat(api): enhance IP list management with new entry operations and improved error handling
- 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:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user