feat: add lookup functionality for IP/domain verification and enhance dashboard links
Introduced a new lookup feature allowing users to quickly verify IP addresses or domains against community lists. Updated the DashboardQuickLinks component to include a new action for IP/domain checks, enhancing user navigation. Expanded API documentation to include the new lookup endpoint and its response structure, ensuring comprehensive coverage of the feature. Updated UI design documentation to reflect the integration of the lookup functionality.
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
// Package lookup implements dual-layer membership checks for IP addresses and FQDNs
|
||||
// against module entries and materialized prefix snapshots.
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Lookup checks whether q (IP or FQDN) is present in tenant lists (entries + snapshots).
|
||||
func Lookup(st store.Backend, tenantID, q string) (*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
|
||||
}
|
||||
}
|
||||
|
||||
out.MatchCount = len(out.Matches)
|
||||
out.Matched = out.MatchCount > 0
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func lookupIP(st store.Backend, tenantID string, addr netip.Addr, 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 !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),
|
||||
}, 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,
|
||||
}, 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: mod.ID,
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestLookupIPEntryAndSnapshot(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, modIP, _, _ := m.DemoIDs()
|
||||
|
||||
cid := ""
|
||||
comms, err := m.ListCommunities(tenant)
|
||||
if err != nil || len(comms) == 0 {
|
||||
t.Fatal("expected demo community")
|
||||
}
|
||||
cid = comms[0].ID
|
||||
|
||||
def := cid
|
||||
if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &def}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entryComm := cid
|
||||
e, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{
|
||||
Prefix: "203.0.113.0/24",
|
||||
CommunityID: &entryComm,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash1", []store.PrefixRow{
|
||||
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := Lookup(m, tenant, "203.0.113.10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.QueryKind != KindIP || res.Normalized != "203.0.113.10" {
|
||||
t.Fatalf("kind/normalized: %+v", res)
|
||||
}
|
||||
if !res.Matched || res.MatchCount < 2 {
|
||||
t.Fatalf("expected entry+snapshot matches, got %+v", res)
|
||||
}
|
||||
|
||||
var entryHit, snapHit bool
|
||||
for _, hit := range res.Matches {
|
||||
if hit.Layer == LayerEntry && hit.EntryID == e.ID {
|
||||
entryHit = true
|
||||
if hit.Community != "demo-comm" || hit.CommunityTitle != "Demo" {
|
||||
t.Fatalf("entry community: %+v", hit)
|
||||
}
|
||||
}
|
||||
if hit.Layer == LayerSnapshot && hit.MatchedValue == "203.0.113.0/24" {
|
||||
snapHit = true
|
||||
}
|
||||
}
|
||||
if !entryHit || !snapHit {
|
||||
t.Fatalf("missing layers entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIPCommunityFallback(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, modIP, _, _ := m.DemoIDs()
|
||||
comms, _ := m.ListCommunities(tenant)
|
||||
cid := comms[0].ID
|
||||
if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &cid}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{Prefix: "10.0.0.0/8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := Lookup(m, tenant, "10.1.2.3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Matched {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
found := false
|
||||
for _, hit := range res.Matches {
|
||||
if hit.Layer == LayerEntry {
|
||||
found = true
|
||||
if hit.CommunityID == nil || *hit.CommunityID != cid {
|
||||
t.Fatalf("expected default community, got %+v", hit)
|
||||
}
|
||||
if hit.Community != "demo-comm" {
|
||||
t.Fatalf("community value: %+v", hit)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no entry match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupDomainEntryAndSnapshot(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, _, _, _ := m.DemoIDs()
|
||||
|
||||
mod, err := m.CreateModule(tenant, &store.Module{
|
||||
Type: "DOMAINS",
|
||||
Name: "demo-domains",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
comms, _ := m.ListCommunities(tenant)
|
||||
cid := comms[0].ID
|
||||
|
||||
e, err := m.CreateDomainEntry(tenant, mod.ID, &store.DomainEntry{
|
||||
FQDN: "Example.COM.",
|
||||
CommunityID: &cid,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.SetModulePrefixSnapshot(tenant, mod.ID, "hash-d", []store.PrefixRow{
|
||||
{Prefix: "198.51.100.1/32", CommunityID: &cid, Source: "domain"},
|
||||
{Prefix: "203.0.113.9/32", CommunityID: &cid, Source: "other"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := Lookup(m, tenant, "example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.QueryKind != KindDomain || res.Normalized != "example.com" {
|
||||
t.Fatalf("kind/normalized: %+v", res)
|
||||
}
|
||||
if !res.Matched {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
|
||||
var entryHit, snapHit, otherSnap bool
|
||||
for _, hit := range res.Matches {
|
||||
if hit.Layer == LayerEntry && hit.EntryID == e.ID {
|
||||
entryHit = true
|
||||
}
|
||||
if hit.Layer == LayerSnapshot && hit.MatchedValue == "198.51.100.1/32" {
|
||||
snapHit = true
|
||||
}
|
||||
if hit.MatchedValue == "203.0.113.9/32" {
|
||||
otherSnap = true
|
||||
}
|
||||
}
|
||||
if !entryHit || !snapHit {
|
||||
t.Fatalf("entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches)
|
||||
}
|
||||
if otherSnap {
|
||||
t.Fatal("non-domain snapshot source should be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupNoMatch(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, _, _, _ := m.DemoIDs()
|
||||
|
||||
res, err := Lookup(m, tenant, "192.0.2.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Matched || res.MatchCount != 0 || len(res.Matches) != 0 {
|
||||
t.Fatalf("expected empty: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupInvalid(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, _, _, _ := m.DemoIDs()
|
||||
|
||||
_, err := Lookup(m, tenant, "")
|
||||
if !errors.Is(err, store.ErrInvalidInput) {
|
||||
t.Fatalf("empty: %v", err)
|
||||
}
|
||||
_, err = Lookup(m, tenant, "not a host")
|
||||
if !errors.Is(err, store.ErrInvalidInput) {
|
||||
t.Fatalf("spaces: %v", err)
|
||||
}
|
||||
_, err = Lookup(m, tenant, "localhost")
|
||||
if !errors.Is(err, store.ErrInvalidInput) {
|
||||
t.Fatalf("single label: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFQDN(t *testing.T) {
|
||||
got, ok := normalizeFQDN(" Example.COM. ")
|
||||
if !ok || got != "example.com" {
|
||||
t.Fatalf("got %q ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user