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
+1
View File
@@ -85,6 +85,7 @@ export const ipOverrideSchema = z.object({
export const createIpListBodySchema = z.object({
name: z.string().min(1),
/** Prefer static | json_url | evobgp_community; domains accepted for legacy. */
type: ipListTypeSchema,
config: z.record(z.string(), z.unknown()).optional(),
entries: z.array(z.string()).optional(),
+1
View File
@@ -1,3 +1,4 @@
export * from './contracts.js'
export * from './list-entries.js'
export * from './permissions.js'
export * from './app-switcher.js'
+156
View File
@@ -0,0 +1,156 @@
import { z } from 'zod'
/** DB type codes — never show raw in UI. */
export const IP_LIST_SOURCE_LABELS = {
static: 'Ручной',
domains: 'Ручной',
json_url: 'JSON по URL',
evobgp_community: 'EvoBGP community',
} as const
export type IpListDbType = keyof typeof IP_LIST_SOURCE_LABELS
export function ipListSourceLabel(type: string): string {
return (
IP_LIST_SOURCE_LABELS[type as IpListDbType] ?? type
)
}
/** Manual lists are editable plaintext (legacy `domains` included). */
export function isManualListType(type: string): boolean {
return type === 'static' || type === 'domains'
}
export const listEntryKindSchema = z.enum(['ip', 'cidr', 'hostname'])
export type ListEntryKind = z.infer<typeof listEntryKindSchema>
export const listConfigItemSchema = z.object({
kind: listEntryKindSchema,
value: z.string().min(1),
resolved_cidrs: z.array(z.string()).optional(),
})
export type ListConfigItem = z.infer<typeof listConfigItemSchema>
export const listEntryKindLabels: Record<ListEntryKind, string> = {
ip: 'IP',
cidr: 'CIDR',
hostname: 'Домен',
}
const IPV4 =
/^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/
const IPV4_CIDR =
/^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\/(?:3[0-2]|[12]?\d)$/
const IPV6 =
/^(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$|^::(?:[0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^(?:[0-9a-fA-F]{0,4}:){1,7}:$/
const IPV6_CIDR =
/^(.+)\/(?:12[0-8]|1[01]\d|[1-9]?\d)$/
const HOSTNAME =
/^(?=.{1,253}$)(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.(?!-)[a-zA-Z0-9-]{1,63}(?<!-))+$/
export function parseListEntry(raw: string): ListConfigItem {
const value = raw.trim().replace(/\.$/, '')
if (!value) throw new Error('Пустая строка')
if (IPV4_CIDR.test(value)) {
return { kind: 'cidr', value }
}
if (IPV4.test(value)) {
return { kind: 'ip', value }
}
const v6Cidr = IPV6_CIDR.exec(value)
if (v6Cidr && IPV6.test(v6Cidr[1]!)) {
return { kind: 'cidr', value }
}
if (IPV6.test(value) && !value.includes('/')) {
return { kind: 'ip', value }
}
const host = value.toLowerCase()
if (HOSTNAME.test(host) || (host.includes('.') && !host.includes(' '))) {
if (/^https?:\/\//i.test(host)) {
throw new Error('URL не является записью списка — создайте список «JSON по URL»')
}
return { kind: 'hostname', value: host }
}
throw new Error(`Не удалось распознать: ${raw.trim()}`)
}
/** Split plaintext paste into non-empty lines / tokens. */
export function splitListPlaintext(text: string): string[] {
return text
.split(/[\n,;\s]+/)
.map((s) => s.trim())
.filter(Boolean)
}
export function normalizeItemToCidrs(
item: ListConfigItem,
resolvedHostCidrs?: string[],
): string[] {
if (item.kind === 'ip') {
return [item.value.includes(':') ? `${item.value}/128` : `${item.value}/32`]
}
if (item.kind === 'cidr') {
return [item.value]
}
return resolvedHostCidrs ?? []
}
export function readManualItems(
configJson: string,
): ListConfigItem[] {
let config: Record<string, unknown> = {}
try {
config = JSON.parse(configJson || '{}') as Record<string, unknown>
} catch {
config = {}
}
if (Array.isArray(config.items)) {
const out: ListConfigItem[] = []
for (const raw of config.items) {
const parsed = listConfigItemSchema.safeParse(raw)
if (parsed.success) out.push(parsed.data)
else if (typeof raw === 'string' && raw.trim()) {
try {
out.push(parseListEntry(raw))
} catch {
/* skip */
}
}
}
if (out.length) return out
}
if (Array.isArray(config.domains)) {
return (config.domains as string[])
.map((d) => d.trim())
.filter(Boolean)
.map((value) => ({ kind: 'hostname' as const, value: value.toLowerCase() }))
}
return []
}
export function guessListSourceFromInput(extra: string): 'static' | 'json_url' {
const t = extra.trim()
if (/^https?:\/\//i.test(t)) return 'json_url'
return 'static'
}
export const listEntriesBodySchema = z.object({
values: z.array(z.string().min(1)).min(1),
})
export const deleteListEntryBodySchema = z.object({
value: z.string().min(1),
})
export const createIpListSourceSchema = z.enum([
'static',
'json_url',
'evobgp_community',
])