Updated the lookup system to allow for CIDR queries in addition to IP and domain searches. This includes modifications to the API, frontend components, and documentation to reflect the new capabilities. The LookupSearchForm and LookupComponent were adjusted to accommodate CIDR input, and relevant tests were added to ensure functionality. Co-authored-by: Cursor <[email protected]>
458 lines
11 KiB
Go
458 lines
11 KiB
Go
// Package lookup implements dual-layer membership checks for IP addresses and FQDNs
|
|
// against module entries and materialized prefix snapshots.
|
|
package lookup
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"evobgp/internal/store"
|
|
)
|
|
|
|
// QueryKind is the normalized kind of a lookup query.
|
|
type QueryKind string
|
|
|
|
const (
|
|
KindIP QueryKind = "ip"
|
|
KindDomain QueryKind = "domain"
|
|
KindCIDR QueryKind = "cidr"
|
|
)
|
|
|
|
// Layer identifies which data source produced a match.
|
|
type Layer string
|
|
|
|
const (
|
|
LayerEntry Layer = "entry"
|
|
LayerSnapshot Layer = "snapshot"
|
|
)
|
|
|
|
// MatchKind is the concrete match type within a layer.
|
|
type MatchKind string
|
|
|
|
const (
|
|
MatchIPRange MatchKind = "ip_range"
|
|
MatchDomain MatchKind = "domain"
|
|
MatchPrefix MatchKind = "prefix"
|
|
)
|
|
|
|
// Match is one membership hit (entry or snapshot) with resolved community fields.
|
|
type Match struct {
|
|
Layer Layer `json:"layer"`
|
|
ModuleID string `json:"module_id"`
|
|
ModuleName string `json:"module_name"`
|
|
ModuleType string `json:"module_type"`
|
|
MatchKind MatchKind `json:"match_kind"`
|
|
MatchedValue string `json:"matched_value"`
|
|
EntryID string `json:"entry_id,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
CommunityID *string `json:"community_id,omitempty"`
|
|
Community string `json:"community,omitempty"`
|
|
CommunityTitle string `json:"community_title,omitempty"`
|
|
// ResolvedIP is set when the hit came from a DNS-resolved address of a domain query.
|
|
ResolvedIP string `json:"resolved_ip,omitempty"`
|
|
}
|
|
|
|
// Result is the full lookup response payload.
|
|
type Result struct {
|
|
Query string `json:"query"`
|
|
QueryKind QueryKind `json:"query_kind"`
|
|
Normalized string `json:"normalized"`
|
|
Matched bool `json:"matched"`
|
|
MatchCount int `json:"match_count"`
|
|
Matches []Match `json:"matches"`
|
|
ResolvedIPs []string `json:"resolved_ips,omitempty"`
|
|
}
|
|
|
|
// DomainResolver resolves a hostname to IP addresses (A/AAAA).
|
|
type DomainResolver func(ctx context.Context, host string) ([]netip.Addr, error)
|
|
|
|
// Lookup checks whether q (IP, CIDR, or FQDN) is present in tenant lists (entries + snapshots).
|
|
// For domains, FQDN membership is checked first, then live DNS resolve and IP membership.
|
|
func Lookup(ctx context.Context, st store.Backend, tenantID, q string) (*Result, error) {
|
|
return LookupWithResolver(ctx, st, tenantID, q, systemDNSResolver)
|
|
}
|
|
|
|
// LookupWithResolver is like Lookup but uses resolve for domain→IP (tests / alternate DNS).
|
|
func LookupWithResolver(
|
|
ctx context.Context,
|
|
st store.Backend,
|
|
tenantID, q string,
|
|
resolve DomainResolver,
|
|
) (*Result, error) {
|
|
raw := strings.TrimSpace(q)
|
|
if raw == "" {
|
|
return nil, fmt.Errorf("%w: empty query", store.ErrInvalidInput)
|
|
}
|
|
|
|
comms, err := st.ListCommunities(tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commByID := make(map[string]*store.Community, len(comms))
|
|
for _, c := range comms {
|
|
if c != nil {
|
|
commByID[c.ID] = c
|
|
}
|
|
}
|
|
|
|
out := &Result{
|
|
Query: raw,
|
|
Matches: make([]Match, 0),
|
|
}
|
|
|
|
if addr, err := netip.ParseAddr(raw); err == nil {
|
|
out.QueryKind = KindIP
|
|
out.Normalized = addr.String()
|
|
if err := lookupIP(st, tenantID, addr, out, commByID, ""); err != nil {
|
|
return nil, err
|
|
}
|
|
} else if pfx, err := netip.ParsePrefix(raw); err == nil {
|
|
masked := pfx.Masked()
|
|
out.QueryKind = KindCIDR
|
|
out.Normalized = masked.String()
|
|
if err := lookupCIDR(st, tenantID, masked, out, commByID); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
fqdn, ok := normalizeFQDN(raw)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: query must be an IP address, CIDR, or FQDN", store.ErrInvalidInput)
|
|
}
|
|
out.QueryKind = KindDomain
|
|
out.Normalized = fqdn
|
|
if err := lookupDomain(st, tenantID, fqdn, out, commByID); err != nil {
|
|
return nil, err
|
|
}
|
|
if resolve == nil {
|
|
resolve = systemDNSResolver
|
|
}
|
|
if err := lookupResolvedIPs(ctx, st, tenantID, fqdn, out, commByID, resolve); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
out.MatchCount = len(out.Matches)
|
|
out.Matched = out.MatchCount > 0
|
|
return out, nil
|
|
}
|
|
|
|
func systemDNSResolver(ctx context.Context, host string) ([]netip.Addr, error) {
|
|
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return uniqAddrs(ips), nil
|
|
}
|
|
|
|
func uniqAddrs(in []netip.Addr) []netip.Addr {
|
|
seen := make(map[netip.Addr]struct{}, len(in))
|
|
out := make([]netip.Addr, 0, len(in))
|
|
for _, a := range in {
|
|
a = a.Unmap()
|
|
if _, ok := seen[a]; ok {
|
|
continue
|
|
}
|
|
seen[a] = struct{}{}
|
|
out = append(out, a)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func lookupResolvedIPs(
|
|
ctx context.Context,
|
|
st store.Backend,
|
|
tenantID, fqdn string,
|
|
out *Result,
|
|
commByID map[string]*store.Community,
|
|
resolve DomainResolver,
|
|
) error {
|
|
ips, err := resolve(ctx, fqdn)
|
|
if err != nil {
|
|
// DNS failure must not hide FQDN-layer matches already collected.
|
|
return nil
|
|
}
|
|
out.ResolvedIPs = make([]string, 0, len(ips))
|
|
for _, ip := range ips {
|
|
out.ResolvedIPs = append(out.ResolvedIPs, ip.String())
|
|
if err := lookupIP(st, tenantID, ip, out, commByID, ip.String()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func lookupIP(
|
|
st store.Backend,
|
|
tenantID string,
|
|
addr netip.Addr,
|
|
out *Result,
|
|
commByID map[string]*store.Community,
|
|
resolvedIP string,
|
|
) error {
|
|
for _, mod := range st.ListModules(tenantID) {
|
|
if mod == nil {
|
|
continue
|
|
}
|
|
if mod.Type == "IP_RANGES" {
|
|
entries, err := st.ListIPRangeEntries(tenantID, mod.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
pfx, err := netip.ParsePrefix(strings.TrimSpace(e.Prefix))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !pfx.Contains(addr) {
|
|
continue
|
|
}
|
|
out.Matches = append(out.Matches, decorateMatch(Match{
|
|
Layer: LayerEntry,
|
|
ModuleID: mod.ID,
|
|
ModuleName: mod.Name,
|
|
ModuleType: mod.Type,
|
|
MatchKind: MatchIPRange,
|
|
MatchedValue: e.Prefix,
|
|
EntryID: e.ID,
|
|
CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID),
|
|
ResolvedIP: resolvedIP,
|
|
}, commByID))
|
|
}
|
|
}
|
|
|
|
snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mod.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok || snap == nil {
|
|
continue
|
|
}
|
|
for _, row := range snap.Prefixes {
|
|
pfx, err := netip.ParsePrefix(strings.TrimSpace(row.Prefix))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !pfx.Contains(addr) {
|
|
continue
|
|
}
|
|
out.Matches = append(out.Matches, decorateMatch(Match{
|
|
Layer: LayerSnapshot,
|
|
ModuleID: mod.ID,
|
|
ModuleName: mod.Name,
|
|
ModuleType: mod.Type,
|
|
MatchKind: MatchPrefix,
|
|
MatchedValue: row.Prefix,
|
|
Source: row.Source,
|
|
CommunityID: row.CommunityID,
|
|
ResolvedIP: resolvedIP,
|
|
}, commByID))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// prefixCoversQuery reports whether listPfx equals query or fully contains it.
|
|
func prefixCoversQuery(listPfx, query netip.Prefix) bool {
|
|
listPfx = listPfx.Masked()
|
|
query = query.Masked()
|
|
if listPfx == query {
|
|
return true
|
|
}
|
|
return listPfx.Contains(query.Addr()) && listPfx.Bits() <= query.Bits()
|
|
}
|
|
|
|
func lookupCIDR(
|
|
st store.Backend,
|
|
tenantID string,
|
|
query netip.Prefix,
|
|
out *Result,
|
|
commByID map[string]*store.Community,
|
|
) error {
|
|
for _, mod := range st.ListModules(tenantID) {
|
|
if mod == nil {
|
|
continue
|
|
}
|
|
if mod.Type == "IP_RANGES" {
|
|
entries, err := st.ListIPRangeEntries(tenantID, mod.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
pfx, err := netip.ParsePrefix(strings.TrimSpace(e.Prefix))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !prefixCoversQuery(pfx, query) {
|
|
continue
|
|
}
|
|
out.Matches = append(out.Matches, decorateMatch(Match{
|
|
Layer: LayerEntry,
|
|
ModuleID: mod.ID,
|
|
ModuleName: mod.Name,
|
|
ModuleType: mod.Type,
|
|
MatchKind: MatchIPRange,
|
|
MatchedValue: e.Prefix,
|
|
EntryID: e.ID,
|
|
CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID),
|
|
}, commByID))
|
|
}
|
|
}
|
|
|
|
snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mod.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok || snap == nil {
|
|
continue
|
|
}
|
|
for _, row := range snap.Prefixes {
|
|
pfx, err := netip.ParsePrefix(strings.TrimSpace(row.Prefix))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !prefixCoversQuery(pfx, query) {
|
|
continue
|
|
}
|
|
out.Matches = append(out.Matches, decorateMatch(Match{
|
|
Layer: LayerSnapshot,
|
|
ModuleID: mod.ID,
|
|
ModuleName: mod.Name,
|
|
ModuleType: mod.Type,
|
|
MatchKind: MatchPrefix,
|
|
MatchedValue: row.Prefix,
|
|
Source: row.Source,
|
|
CommunityID: row.CommunityID,
|
|
}, commByID))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func lookupDomain(st store.Backend, tenantID, fqdn string, out *Result, commByID map[string]*store.Community) error {
|
|
matchedModuleIDs := make(map[string]*store.Module)
|
|
|
|
for _, mod := range st.ListModules(tenantID) {
|
|
if mod == nil || mod.Type != "DOMAINS" {
|
|
continue
|
|
}
|
|
entries, err := st.ListDomainEntries(tenantID, mod.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
norm, ok := normalizeFQDN(e.FQDN)
|
|
if !ok || norm != fqdn {
|
|
continue
|
|
}
|
|
matchedModuleIDs[mod.ID] = mod
|
|
out.Matches = append(out.Matches, decorateMatch(Match{
|
|
Layer: LayerEntry,
|
|
ModuleID: mod.ID,
|
|
ModuleName: mod.Name,
|
|
ModuleType: mod.Type,
|
|
MatchKind: MatchDomain,
|
|
MatchedValue: e.FQDN,
|
|
EntryID: e.ID,
|
|
CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID),
|
|
}, commByID))
|
|
}
|
|
}
|
|
|
|
for mid, mod := range matchedModuleIDs {
|
|
snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok || snap == nil {
|
|
continue
|
|
}
|
|
for _, row := range snap.Prefixes {
|
|
if !strings.EqualFold(strings.TrimSpace(row.Source), "domain") {
|
|
continue
|
|
}
|
|
out.Matches = append(out.Matches, decorateMatch(Match{
|
|
Layer: LayerSnapshot,
|
|
ModuleID: mid,
|
|
ModuleName: mod.Name,
|
|
ModuleType: mod.Type,
|
|
MatchKind: MatchPrefix,
|
|
MatchedValue: row.Prefix,
|
|
Source: row.Source,
|
|
CommunityID: row.CommunityID,
|
|
}, commByID))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveCommunityID(entryID, defaultID *string) *string {
|
|
if entryID != nil && strings.TrimSpace(*entryID) != "" {
|
|
return entryID
|
|
}
|
|
if defaultID != nil && strings.TrimSpace(*defaultID) != "" {
|
|
return defaultID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decorateMatch(m Match, commByID map[string]*store.Community) Match {
|
|
if m.CommunityID == nil {
|
|
return m
|
|
}
|
|
c, ok := commByID[*m.CommunityID]
|
|
if !ok || c == nil {
|
|
return m
|
|
}
|
|
m.Community = c.Community
|
|
m.CommunityTitle = c.Title
|
|
return m
|
|
}
|
|
|
|
// normalizeFQDN lowercases, trims trailing dots, and validates a simple hostname shape.
|
|
func normalizeFQDN(s string) (string, bool) {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.TrimSuffix(s, ".")
|
|
s = strings.ToLower(s)
|
|
if s == "" || len(s) > 253 {
|
|
return "", false
|
|
}
|
|
if strings.ContainsAny(s, " /\\\t\n") {
|
|
return "", false
|
|
}
|
|
if _, err := netip.ParseAddr(s); err == nil {
|
|
return "", false
|
|
}
|
|
labels := strings.Split(s, ".")
|
|
if len(labels) < 2 {
|
|
return "", false
|
|
}
|
|
for _, label := range labels {
|
|
if label == "" || len(label) > 63 {
|
|
return "", false
|
|
}
|
|
if label[0] == '-' || label[len(label)-1] == '-' {
|
|
return "", false
|
|
}
|
|
for _, r := range label {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
|
|
continue
|
|
}
|
|
return "", false
|
|
}
|
|
}
|
|
return s, true
|
|
}
|