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, putAgentPolicySetsBodySchema,
patchAgentBodySchema, patchAgentBodySchema,
cloneFromBodySchema, cloneFromBodySchema,
listEntriesBodySchema,
deleteListEntryBodySchema,
isManualListType,
} from '@evofw/shared' } from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js' import { AppError } from '../plugins/error-handler.js'
import { refreshIpList } from '../services/lists/refresh.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 { evaluateAgentPolicy } from '../services/policy/evaluate.js'
import { import {
resolveAndStoreHostnameRule, resolveAndStoreHostnameRule,
@@ -279,19 +287,29 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app.post('/lists', async (req) => { app.post('/lists', async (req) => {
const body = createIpListBodySchema.parse(req.body) const body = createIpListBodySchema.parse(req.body)
const type =
body.type === 'domains' ? 'static' : body.type
const id = crypto.randomUUID() const id = crypto.randomUUID()
const list = repos.insertIpList(app.db, { const list = repos.insertIpList(app.db, {
id, id,
name: body.name, name: body.name,
type: body.type, type,
configJson: JSON.stringify(body.config ?? {}), configJson: JSON.stringify(body.config ?? {}),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}) })
if (body.entries?.length) { if (body.entries?.length && isManualListType(type)) {
repos.replaceIpListEntries(app.db, id, body.entries) try {
} await addListEntries(app.db, id, body.entries)
if (body.type !== 'static') { } 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) await refreshIpList(app.db, id)
} }
return { return {
@@ -305,33 +323,54 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
}) })
app.get<{ Params: { id: string } }>('/lists/:id', async (req) => { app.get<{ Params: { id: string } }>('/lists/:id', async (req) => {
const l = repos.getIpList(app.db, req.params.id) const detail = mapListDetail(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404)
return { return detail
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,
}
}) })
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) => { app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => {
await refreshIpList(app.db, req.params.id) await refreshIpList(app.db, req.params.id)
const l = repos.getIpList(app.db, req.params.id) const detail = mapListDetail(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404)
return { return detail
id: l.id,
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entry_count: repos.listIpListEntries(app.db, l.id).length,
}
}) })
app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => { 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 { createHash } from 'node:crypto'
import { resolve4, resolve6 } from 'node:dns/promises'
import type { Db } from '@evofw/db' import type { Db } from '@evofw/db'
import { repos } 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[] { function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort() 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) 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( async function fetchEvobgpCommunity(
apiUrl: string, apiUrl: string,
token: string, token: string,
@@ -118,21 +98,13 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
try { try {
let cidrs: string[] = [] let cidrs: string[] = []
if (list.type === 'static') { if (list.type === 'static' || list.type === 'domains') {
cidrs = repos.listIpListEntries(db, listId).map((e) => e.cidr) cidrs = await rebuildManualListEntries(db, listId)
} else if (list.type === 'json_url') { } else if (list.type === 'json_url') {
const url = String(config.url ?? '') const url = String(config.url ?? '')
if (!url) throw new Error('config.url required') if (!url) throw new Error('config.url required')
cidrs = await fetchJsonUrl(url) cidrs = await fetchJsonUrl(url)
repos.replaceIpListEntries(db, listId, cidrs) 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') { } else if (list.type === 'evobgp_community') {
const apiUrl = const apiUrl =
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url') 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, lastError: null,
}) })
// Bump all agents so they re-fetch policy repos.bumpAgentsForList(db, listId)
for (const a of repos.listAgents(db)) {
if (a.status === 'approved') repos.bumpAgentGeneration(db, a.id)
}
} catch (err) { } catch (err) {
repos.updateIpList(db, listId, { repos.updateIpList(db, listId, {
lastError: err instanceof Error ? err.message : String(err), 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> { export async function refreshAllLists(db: Db): Promise<void> {
for (const list of repos.listIpLists(db)) { for (const list of repos.listIpLists(db)) {
if (list.type === 'static') continue
await refreshIpList(db, list.id) await refreshIpList(db, list.id)
} }
await refreshAllHostnameRules(db) await refreshAllHostnameRules(db)
+13 -5
View File
@@ -26,9 +26,13 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
revoked: 'secondary', revoked: 'secondary',
disabled: 'secondary', disabled: 'secondary',
static: 'secondary', static: 'secondary',
domains: 'info-light', domains: 'secondary',
manual: 'secondary',
json_url: 'info-light', json_url: 'info-light',
evobgp_community: 'info-light', evobgp_community: 'info-light',
ip: 'info-light',
cidr: 'secondary',
hostname: 'warning-light',
unknown: 'outline', unknown: 'outline',
} }
@@ -63,10 +67,14 @@ const STATUS_LABELS: Record<string, string> = {
degraded: 'Slow', degraded: 'Slow',
down: 'Down', down: 'Down',
expired: 'Истёк', expired: 'Истёк',
static: 'static', static: 'Ручной',
domains: 'domains', domains: 'Ручной',
json_url: 'json_url', manual: 'Ручной',
evobgp_community: 'evobgp_community', json_url: 'JSON по URL',
evobgp_community: 'EvoBGP community',
ip: 'IP',
cidr: 'CIDR',
hostname: 'Домен',
unknown: 'Неизвестно', unknown: 'Неизвестно',
} }
+19 -5
View File
@@ -36,11 +36,25 @@ export const listQueryOptions = (id: string) =>
queryOptions({ queryOptions({
queryKey: ['lists', id], queryKey: ['lists', id],
queryFn: () => queryFn: () =>
apiFetch< apiFetch<{
IpList & { id: string
entries: string[] name: string
} type: IpList['type']
>(`/api/v1/lists/${id}`), config_json: string
content_hash?: string | null
refreshed_at?: string | null
last_error?: string | null
entries: string[]
items: {
kind: 'ip' | 'cidr' | 'hostname'
value: string
resolved_count: number
resolved_cidrs: string[]
}[]
entry_count: number
created_at: string
updated_at: string
}>(`/api/v1/lists/${id}`),
}) })
export const policySetsQueryOptions = () => export const policySetsQueryOptions = () =>
+243 -63
View File
@@ -1,34 +1,63 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { HashIcon, RefreshCwIcon, TagIcon } from 'lucide-react' import { HashIcon, RefreshCwIcon, TagIcon, Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { PageShell, DetailPanel, ResourcePage } from '@/components/reui-kit' import { PageShell, DetailPanel, ResourcePage } from '@/components/reui-kit'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { listQueryOptions } from '@/queries' import { ConfirmDialog } from '@/components/confirm-dialog'
import { listQueryOptions, listsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Textarea } from '@evofw/ui/components/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import { Skeleton } from '@evofw/ui/components/skeleton' import { Skeleton } from '@evofw/ui/components/skeleton'
import { isManualListType } from '@evofw/shared'
export const Route = createFileRoute('/_auth/lists/$id')({ export const Route = createFileRoute('/_auth/lists/$id')({
component: ListDetailPage, component: ListDetailPage,
}) })
type EntryRow = { id: string; cidr: string } type ListItem = {
kind: 'ip' | 'cidr' | 'hostname'
value: string
resolved_count: number
resolved_cidrs: string[]
}
/** /**
* IP list detail — entries as DataGrid table. * List detail — tabular entries + switcher.
* Preview: https://reui.io/preview/base/data-grid-filtering-1 · empty-state-12 * Preview: https://reui.io/preview/base/data-grid-filtering-2 · sheet-8 · empty-state-12
*/ */
function ListDetailPage() { function ListDetailPage() {
const { id } = Route.useParams() const { id } = Route.useParams()
const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const listQ = useQuery(listQueryOptions(id)) const listQ = useQuery(listQueryOptions(id))
const listsQ = useQuery(listsQueryOptions())
const [filters, setFilters] = useState<Filter[]>([]) const [filters, setFilters] = useState<Filter[]>([])
const [addOpen, setAddOpen] = useState(false)
const [plaintext, setPlaintext] = useState('')
const [deleteValue, setDeleteValue] = useState<string | null>(null)
const refresh = useMutation({ const refresh = useMutation({
mutationFn: () => mutationFn: () =>
@@ -40,40 +69,108 @@ function ListDetailPage() {
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}) })
const entries: EntryRow[] = useMemo( const addEntries = useMutation({
() => mutationFn: () =>
(listQ.data?.entries ?? []).map((cidr, i) => ({ apiFetch(`/api/v1/lists/${id}/entries`, {
id: `${i}-${cidr}`, method: 'POST',
cidr, body: JSON.stringify({ values: [plaintext] }),
})), }),
[listQ.data?.entries], onSuccess: () => {
) toast.success('Добавлено')
setPlaintext('')
setAddOpen(false)
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
})
const removeEntry = useMutation({
mutationFn: (value: string) =>
apiFetch(`/api/v1/lists/${id}/entries`, {
method: 'DELETE',
body: JSON.stringify({ value }),
}),
onSuccess: () => {
toast.success('Удалено')
setDeleteValue(null)
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
})
const list = listQ.data
const manual = list ? isManualListType(list.type) : false
const items: ListItem[] = list?.items ?? []
const filterFields: FilterFieldConfig[] = useMemo( const filterFields: FilterFieldConfig[] = useMemo(
() => [ () => [
{ {
key: 'cidr', key: 'value',
label: 'Entry', label: 'Значение',
type: 'text', type: 'text',
placeholder: 'Поиск CIDR / домен…', placeholder: 'Поиск…',
},
{
key: 'kind',
label: 'Вид',
type: 'select',
options: [
{ value: 'ip', label: 'IP' },
{ value: 'cidr', label: 'CIDR' },
{ value: 'hostname', label: 'Домен' },
],
}, },
], ],
[], [],
) )
const columns: ColumnDef<EntryRow>[] = useMemo( const columns: ColumnDef<ListItem>[] = useMemo(
() => [ () => [
{ {
accessorKey: 'cidr', accessorKey: 'value',
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Entry" /> <DataGridColumnHeader column={column} title="Значение" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell accent="mono" title={row.original.cidr} /> <DataGridPrimaryCell
accent="mono"
title={row.original.value}
subtitle={
row.original.resolved_count > 0
? `${row.original.resolved_count} CIDR`
: undefined
}
/>
), ),
}, },
{
accessorKey: 'kind',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Вид" />
),
cell: ({ row }) => <StatusBadge status={row.original.kind} />,
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) =>
manual ? (
<div className="flex justify-end">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
onClick={() => setDeleteValue(row.original.value)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
) : null,
},
], ],
[], [manual],
) )
if (listQ.isLoading) { if (listQ.isLoading) {
@@ -85,7 +182,7 @@ function ListDetailPage() {
) )
} }
if (!listQ.data) { if (!list) {
return ( return (
<PageShell> <PageShell>
<DetailPanel> <DetailPanel>
@@ -102,30 +199,50 @@ function ListDetailPage() {
) )
} }
const list = listQ.data
return ( return (
<PageShell> <PageShell>
<DetailPanel> <DetailPanel>
<DetailPanel.Header <DetailPanel.Header
title={list.name} title={list.name}
description={`Тип ${list.type}`} description="Содержимое списка для правил политики"
actions={ actions={
<> <>
<Select
value={id}
onValueChange={(v) => {
if (v && v !== id) {
void navigate({ to: '/lists/$id', params: { id: v } })
}
}}
>
<SelectTrigger className="w-[200px]" size="sm">
<SelectValue placeholder="Список" />
</SelectTrigger>
<SelectContent>
{(listsQ.data?.items ?? []).map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
<StatusBadge status={list.type} /> <StatusBadge status={list.type} />
{list.type !== 'static' ? ( <Button
<Button size="sm"
size="sm" variant="outline"
variant="outline" disabled={refresh.isPending}
disabled={refresh.isPending} onClick={() => refresh.mutate()}
onClick={() => refresh.mutate()} >
> <RefreshCwIcon
<RefreshCwIcon className={
className={ refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5' }
} />
/> Refresh
Refresh </Button>
{manual ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
Добавить
</Button> </Button>
) : null} ) : null}
<Button variant="outline" size="sm" render={<Link to="/lists" />}> <Button variant="outline" size="sm" render={<Link to="/lists" />}>
@@ -140,59 +257,122 @@ function ListDetailPage() {
{ {
id: 'count', id: 'count',
icon: <HashIcon aria-hidden />, icon: <HashIcon aria-hidden />,
label: 'Entries', label: 'Записей',
description: String(entries.length), description: String(items.length),
}, },
{ {
id: 'type', id: 'type',
icon: <TagIcon aria-hidden />, icon: <TagIcon aria-hidden />,
label: 'Тип', label: 'Источник',
description: list.type, description:
list.type === 'json_url'
? 'JSON по URL'
: list.type === 'evobgp_community'
? 'EvoBGP community'
: 'Ручной',
}, },
{ {
id: 'refreshed', id: 'cidrs',
icon: <RefreshCwIcon aria-hidden />, icon: <RefreshCwIcon aria-hidden />,
label: 'Refresh', label: 'CIDR в политике',
description: list.last_error description: String(list.entry_count ?? list.entries.length),
? list.last_error
: (list.refreshed_at ?? '—'),
}, },
]} ]}
/> />
<DetailPanel.Section <DetailPanel.Section
title="Содержимое" title="Содержимое"
description="CIDR / resolved prefixes из списка." description={
manual
? 'IP, CIDR и домены в одном списке — вид определяется автоматически.'
: 'Записи из внешнего источника (только чтение).'
}
> >
<ResourcePage <ResourcePage
title="Entries" title="Entries"
hideHeader hideHeader
data={entries} data={items}
columns={columns} columns={columns}
getRowId={(r) => r.id} getRowId={(r) => r.value}
filterFields={filterFields} filterFields={filterFields}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={setFilters}
onClearFilters={() => setFilters([])} onClearFilters={() => setFilters([])}
getFilterFieldValue={(item, field) => getFilterFieldValue={(item, field) => {
field === 'cidr' ? item.cidr : undefined if (field === 'value') return item.value
} if (field === 'kind') return item.kind
return undefined
}}
emptyState={{ emptyState={{
title: 'Нет entries', title: 'Нет записей',
description: description: manual
list.type === 'static' ? 'Добавьте IP, CIDR или домен — по одной строке или списком.'
? 'Добавьте CIDR при создании или обновите список.' : 'Нажмите Refresh или проверьте источник.',
: 'Нажмите Refresh или проверьте источник.', action: manual ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
Добавить
</Button>
) : undefined,
}} }}
/> />
</DetailPanel.Section> </DetailPanel.Section>
{list.last_error ? ( {list.last_error ? (
<DataGridMutedCell className="text-destructive px-1"> <p className="text-destructive text-sm">{list.last_error}</p>
{list.last_error}
</DataGridMutedCell>
) : null} ) : null}
</DetailPanel> </DetailPanel>
<ConfirmDialog
open={deleteValue !== null}
onOpenChange={(open) => {
if (!open) setDeleteValue(null)
}}
title="Удалить запись?"
description={
deleteValue
? `Будет удалено: ${deleteValue}`
: 'Запись будет удалена из списка.'
}
onConfirm={() => {
if (deleteValue) removeEntry.mutate(deleteValue)
}}
disabled={removeEntry.isPending}
/>
<Sheet open={addOpen} onOpenChange={setAddOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Добавить записи</SheetTitle>
<SheetDescription>
Вставьте IP, CIDR или домены система определит вид сама.
Несколько строк через перевод строки или пробел.
</SheetDescription>
</SheetHeader>
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
<Field>
<FieldLabel htmlFor="entries-plain">Записи</FieldLabel>
<Textarea
id="entries-plain"
rows={6}
value={plaintext}
placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'}
onChange={(e) => setPlaintext(e.target.value)}
/>
</Field>
</div>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setAddOpen(false)}>
Отмена
</Button>
<Button
disabled={!plaintext.trim() || addEntries.isPending}
onClick={() => addEntries.mutate()}
>
Добавить
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</PageShell> </PageShell>
) )
} }
+76 -65
View File
@@ -7,7 +7,10 @@ import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit' import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' import {
DataGridMutedCell,
DataGridPrimaryCell,
} from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { listsQueryOptions } from '@/queries' import { listsQueryOptions } from '@/queries'
@@ -15,7 +18,6 @@ import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field' import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input' import { Input } from '@evofw/ui/components/input'
import { Textarea } from '@evofw/ui/components/textarea'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -31,16 +33,23 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@evofw/ui/components/sheet' } from '@evofw/ui/components/sheet'
import type { IpList } from '@evofw/shared' import {
guessListSourceFromInput,
isManualListType,
type IpList,
} from '@evofw/shared'
export const Route = createFileRoute('/_auth/lists/')({ export const Route = createFileRoute('/_auth/lists/')({
component: ListsPage, component: ListsPage,
}) })
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
/** /**
* IP lists — ResourcePage. * IP lists catalog — ResourcePage.
* Preview: https://reui.io/preview/base/data-grid-filtering-2 * Preview: https://reui.io/preview/base/data-grid-filtering-2
* Empty: https://reui.io/preview/base/empty-state-12 * Empty: https://reui.io/preview/base/empty-state-12
* Sheet: https://reui.io/preview/base/sheet-8
*/ */
function ListsPage() { function ListsPage() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -48,9 +57,7 @@ function ListsPage() {
const listsQ = useQuery(listsQueryOptions()) const listsQ = useQuery(listsQueryOptions())
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [name, setName] = useState('') const [name, setName] = useState('')
const [type, setType] = useState< const [source, setSource] = useState<CreateSource>('static')
'static' | 'json_url' | 'domains' | 'evobgp_community'
>('static')
const [extra, setExtra] = useState('') const [extra, setExtra] = useState('')
const [filters, setFilters] = useState<Filter[]>([]) const [filters, setFilters] = useState<Filter[]>([])
const [activeTab, setActiveTab] = useState('all') const [activeTab, setActiveTab] = useState('all')
@@ -59,31 +66,21 @@ function ListsPage() {
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
const config: Record<string, unknown> = {} const config: Record<string, unknown> = {}
let entries: string[] | undefined if (source === 'json_url') {
if (type === 'static') {
entries = extra
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
} else if (type === 'json_url') {
config.url = extra.trim() config.url = extra.trim()
} else if (type === 'domains') { } else if (source === 'evobgp_community') {
config.domains = extra
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
} else {
config.community_id = extra.trim() config.community_id = extra.trim()
} }
return apiFetch<{ id: string }>('/api/v1/lists', { return apiFetch<{ id: string }>('/api/v1/lists', {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, type, config, entries }), body: JSON.stringify({ name, type: source, config }),
}) })
}, },
onSuccess: (row) => { onSuccess: (row) => {
toast.success('Список создан') toast.success('Список создан')
setName('') setName('')
setExtra('') setExtra('')
setSource('static')
setSheetOpen(false) setSheetOpen(false)
void qc.invalidateQueries({ queryKey: ['lists'] }) void qc.invalidateQueries({ queryKey: ['lists'] })
void navigate({ to: '/lists/$id', params: { id: row.id } }) void navigate({ to: '/lists/$id', params: { id: row.id } })
@@ -117,13 +114,13 @@ function ListsPage() {
{ key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' }, { key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' },
{ {
key: 'type', key: 'type',
label: 'Тип', label: 'Источник',
type: 'select', type: 'select',
options: [ options: [
{ value: 'static', label: 'static' }, { value: 'static', label: 'Ручной' },
{ value: 'json_url', label: 'json_url' }, { value: 'json_url', label: 'JSON по URL' },
{ value: 'domains', label: 'domains' }, { value: 'evobgp_community', label: 'EvoBGP community' },
{ value: 'evobgp_community', label: 'evobgp_community' }, { value: 'domains', label: 'Ручной' },
], ],
}, },
], ],
@@ -138,6 +135,7 @@ function ListsPage() {
const tabFilter = useCallback((item: IpList, tabId: string) => { const tabFilter = useCallback((item: IpList, tabId: string) => {
if (tabId === 'all') return true if (tabId === 'all') return true
if (tabId === 'manual') return isManualListType(item.type)
return item.type === tabId return item.type === tabId
}, []) }, [])
@@ -161,14 +159,14 @@ function ListsPage() {
{ {
accessorKey: 'type', accessorKey: 'type',
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Тип" /> <DataGridColumnHeader column={column} title="Источник" />
), ),
cell: ({ row }) => <StatusBadge status={row.original.type} />, cell: ({ row }) => <StatusBadge status={row.original.type} />,
}, },
{ {
accessorKey: 'entry_count', accessorKey: 'entry_count',
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Entries" /> <DataGridColumnHeader column={column} title="Записей" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="tabular-nums">{row.original.entry_count ?? 0}</span> <span className="tabular-nums">{row.original.entry_count ?? 0}</span>
@@ -177,7 +175,7 @@ function ListsPage() {
{ {
id: 'refresh', id: 'refresh',
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Refresh" /> <DataGridColumnHeader column={column} title="Обновление" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridMutedCell> <DataGridMutedCell>
@@ -196,13 +194,11 @@ function ListsPage() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
render={ render={<Link to="/lists/$id" params={{ id: l.id }} />}
<Link to="/lists/$id" params={{ id: l.id }} />
}
> >
Открыть Открыть
</Button> </Button>
{l.type !== 'static' ? ( {!isManualListType(l.type) ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -228,11 +224,15 @@ function ListsPage() {
[refresh], [refresh],
) )
const canCreate =
Boolean(name.trim()) &&
(source === 'static' || Boolean(extra.trim()))
return ( return (
<PageShell> <PageShell>
<PageHeader <PageHeader
title="Списки IP" title="Списки IP"
description="static · JSON URL · domains · EvoBGP community" description="Ручные plaintext-списки (IP, CIDR, домены) и внешние источники"
actions={ actions={
<Button size="sm" onClick={() => setSheetOpen(true)}> <Button size="sm" onClick={() => setSheetOpen(true)}>
Новый список Новый список
@@ -253,10 +253,9 @@ function ListsPage() {
getFilterFieldValue={getFilterFieldValue} getFilterFieldValue={getFilterFieldValue}
tabs={[ tabs={[
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
{ id: 'static', label: 'static' }, { id: 'manual', label: 'Ручной' },
{ id: 'domains', label: 'domains' }, { id: 'json_url', label: 'JSON по URL' },
{ id: 'json_url', label: 'json_url' }, { id: 'evobgp_community', label: 'EvoBGP' },
{ id: 'evobgp_community', label: 'evobgp' },
]} ]}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
@@ -282,7 +281,7 @@ function ListsPage() {
if (!open) setDeleteId(null) if (!open) setDeleteId(null)
}} }}
title="Удалить список?" title="Удалить список?"
description="Entries и ссылки из правил останутся неконсистентны — удаляйте осторожно." description="Записи списка будут удалены."
onConfirm={() => { onConfirm={() => {
if (deleteId) remove.mutate(deleteId) if (deleteId) remove.mutate(deleteId)
}} }}
@@ -293,7 +292,10 @@ function ListsPage() {
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md"> <SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0"> <SheetHeader className="shrink-0">
<SheetTitle>Новый список</SheetTitle> <SheetTitle>Новый список</SheetTitle>
<SheetDescription>Источник префиксов для правил</SheetDescription> <SheetDescription>
Записи IP / CIDR / домены добавляются в таблице на странице
списка.
</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4"> <div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
<Field> <Field>
@@ -305,50 +307,59 @@ function ListsPage() {
/> />
</Field> </Field>
<Field> <Field>
<FieldLabel>Тип</FieldLabel> <FieldLabel>Источник</FieldLabel>
<Select <Select
value={type} value={source}
onValueChange={(v) => { onValueChange={(v) => {
if (v) setType(v as typeof type) if (v) setSource(v as CreateSource)
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="static">static</SelectItem> <SelectItem value="static">Ручной</SelectItem>
<SelectItem value="json_url">json_url</SelectItem> <SelectItem value="json_url">JSON по URL</SelectItem>
<SelectItem value="domains">domains</SelectItem>
<SelectItem value="evobgp_community"> <SelectItem value="evobgp_community">
evobgp_community EvoBGP community
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
<Field> {source === 'json_url' ? (
<FieldLabel htmlFor="list-extra"> <Field>
{type === 'static' <FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
? 'CIDR (через пробел/запятую)' <Input
: type === 'json_url' id="list-url"
? 'URL JSON' value={extra}
: type === 'domains' placeholder="https://…"
? 'Домены' onChange={(e) => {
: 'Community ID'} const v = e.target.value
</FieldLabel> setExtra(v)
<Textarea if (guessListSourceFromInput(v) === 'json_url') {
id="list-extra" setSource('json_url')
value={extra} }
onChange={(e) => setExtra(e.target.value)} }}
rows={3} />
/> </Field>
</Field> ) : null}
{source === 'evobgp_community' ? (
<Field>
<FieldLabel htmlFor="list-comm">Community ID</FieldLabel>
<Input
id="list-comm"
value={extra}
onChange={(e) => setExtra(e.target.value)}
/>
</Field>
) : null}
</div> </div>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t"> <SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setSheetOpen(false)}> <Button variant="outline" onClick={() => setSheetOpen(false)}>
Отмена Отмена
</Button> </Button>
<Button <Button
disabled={!name || create.isPending} disabled={!canCreate || create.isPending}
onClick={() => create.mutate()} onClick={() => create.mutate()}
> >
Создать Создать
+16
View File
@@ -69,6 +69,21 @@ export function bumpAllApprovedAgents(db: Db) {
for (const r of rows) bumpAgentGeneration(db, r.id) for (const r of rows) bumpAgentGeneration(db, r.id)
} }
/** Bump agents that have a policy rule referencing this IP list. */
export function bumpAgentsForList(db: Db, listId: string) {
const rules = db
.select({ setId: policyRules.setId })
.from(policyRules)
.where(eq(policyRules.listId, listId))
.all()
const setIds = [...new Set(rules.map((r) => r.setId))]
if (setIds.length === 0) {
bumpAllApprovedAgents(db)
return
}
for (const setId of setIds) bumpAgentsForSet(db, setId)
}
export function listIpLists(db: Db) { export function listIpLists(db: Db) {
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all() return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
} }
@@ -429,6 +444,7 @@ export const repos = {
bumpAgentGeneration, bumpAgentGeneration,
bumpAgentsForSet, bumpAgentsForSet,
bumpAllApprovedAgents, bumpAllApprovedAgents,
bumpAgentsForList,
listIpLists, listIpLists,
getIpList, getIpList,
insertIpList, insertIpList,
+1
View File
@@ -85,6 +85,7 @@ export const ipOverrideSchema = z.object({
export const createIpListBodySchema = z.object({ export const createIpListBodySchema = z.object({
name: z.string().min(1), name: z.string().min(1),
/** Prefer static | json_url | evobgp_community; domains accepted for legacy. */
type: ipListTypeSchema, type: ipListTypeSchema,
config: z.record(z.string(), z.unknown()).optional(), config: z.record(z.string(), z.unknown()).optional(),
entries: z.array(z.string()).optional(), entries: z.array(z.string()).optional(),
+1
View File
@@ -1,3 +1,4 @@
export * from './contracts.js' export * from './contracts.js'
export * from './list-entries.js'
export * from './permissions.js' export * from './permissions.js'
export * from './app-switcher.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',
])