CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 1m6s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m6s
Для FQDN после проверки DOMAINS выполняется live DNS (A/AAAA), каждый IP проверяется по IP_RANGES и snapshots; в ответе resolved_ips / resolved_ip, UI KPI и OpenAPI обновлены. Co-authored-by: Cursor <[email protected]>
370 lines
9.2 KiB
Go
370 lines
9.2 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"
|
|
)
|
|
|
|
// 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 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 {
|
|
fqdn, ok := normalizeFQDN(raw)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: query must be an IP address 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
|
|
}
|
|
|
|
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
|
|
}
|