feat(lookup): resolve domain to IPs for membership check
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]>
This commit is contained in:
Denozordec
2026-07-18 01:01:54 +07:00
co-authored by Cursor
parent 57bcfcd9e1
commit 54b7ea3bd5
7 changed files with 188 additions and 22 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "query parameter q is required")
return
}
res, err := lookup.Lookup(s.store, a.TenantID, q)
res, err := lookup.Lookup(r.Context(), s.store, a.TenantID, q)
if err != nil {
if errors.Is(err, store.ErrInvalidInput) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "query must be an IP address or FQDN")
+89 -10
View File
@@ -3,7 +3,9 @@
package lookup
import (
"context"
"fmt"
"net"
"net/netip"
"strings"
"unicode"
@@ -49,20 +51,37 @@ type Match struct {
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"`
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).
func Lookup(st store.Backend, tenantID, q string) (*Result, error) {
// 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)
@@ -87,7 +106,7 @@ func Lookup(st store.Backend, tenantID, q string) (*Result, error) {
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 {
if err := lookupIP(st, tenantID, addr, out, commByID, ""); err != nil {
return nil, err
}
} else {
@@ -100,6 +119,12 @@ func Lookup(st store.Backend, tenantID, q string) (*Result, error) {
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)
@@ -107,7 +132,59 @@ func Lookup(st store.Backend, tenantID, q string) (*Result, error) {
return out, nil
}
func lookupIP(st store.Backend, tenantID string, addr netip.Addr, out *Result, commByID map[string]*store.Community) error {
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
@@ -137,6 +214,7 @@ func lookupIP(st store.Backend, tenantID string, addr netip.Addr, out *Result, c
MatchedValue: e.Prefix,
EntryID: e.ID,
CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID),
ResolvedIP: resolvedIP,
}, commByID))
}
}
@@ -165,6 +243,7 @@ func lookupIP(st store.Backend, tenantID string, addr netip.Addr, out *Result, c
MatchedValue: row.Prefix,
Source: row.Source,
CommunityID: row.CommunityID,
ResolvedIP: resolvedIP,
}, commByID))
}
}
@@ -218,7 +297,7 @@ func lookupDomain(st store.Backend, tenantID, fqdn string, out *Result, commByID
}
out.Matches = append(out.Matches, decorateMatch(Match{
Layer: LayerSnapshot,
ModuleID: mod.ID,
ModuleID: mid,
ModuleName: mod.Name,
ModuleType: mod.Type,
MatchKind: MatchPrefix,
+63 -7
View File
@@ -1,7 +1,9 @@
package lookup
import (
"context"
"errors"
"net/netip"
"testing"
"evobgp/internal/store"
@@ -39,7 +41,7 @@ func TestLookupIPEntryAndSnapshot(t *testing.T) {
t.Fatal(err)
}
res, err := Lookup(m, tenant, "203.0.113.10")
res, err := Lookup(context.Background(), m, tenant, "203.0.113.10")
if err != nil {
t.Fatal(err)
}
@@ -80,7 +82,7 @@ func TestLookupIPCommunityFallback(t *testing.T) {
t.Fatal(err)
}
res, err := Lookup(m, tenant, "10.1.2.3")
res, err := Lookup(context.Background(), m, tenant, "10.1.2.3")
if err != nil {
t.Fatal(err)
}
@@ -134,7 +136,8 @@ func TestLookupDomainEntryAndSnapshot(t *testing.T) {
t.Fatal(err)
}
res, err := Lookup(m, tenant, "example.com")
noDNS := func(context.Context, string) ([]netip.Addr, error) { return nil, nil }
res, err := LookupWithResolver(context.Background(), m, tenant, "example.com", noDNS)
if err != nil {
t.Fatal(err)
}
@@ -165,12 +168,64 @@ func TestLookupDomainEntryAndSnapshot(t *testing.T) {
}
}
func TestLookupDomainResolvedIPAgainstRanges(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: "203.0.113.0/24",
CommunityID: &cid,
}); err != nil {
t.Fatal(err)
}
if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash-r", []store.PrefixRow{
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"},
}); err != nil {
t.Fatal(err)
}
fake := func(_ context.Context, host string) ([]netip.Addr, error) {
if host != "google.com" {
t.Fatalf("unexpected host %q", host)
}
return []netip.Addr{netip.MustParseAddr("203.0.113.50")}, nil
}
res, err := LookupWithResolver(context.Background(), m, tenant, "google.com", fake)
if err != nil {
t.Fatal(err)
}
if res.QueryKind != KindDomain {
t.Fatalf("kind: %+v", res)
}
if len(res.ResolvedIPs) != 1 || res.ResolvedIPs[0] != "203.0.113.50" {
t.Fatalf("resolved_ips: %+v", res.ResolvedIPs)
}
if !res.Matched {
t.Fatalf("expected IP membership via resolve, got %+v", res)
}
var viaResolve bool
for _, hit := range res.Matches {
if hit.ResolvedIP == "203.0.113.50" && hit.MatchedValue == "203.0.113.0/24" {
viaResolve = true
}
}
if !viaResolve {
t.Fatalf("missing resolved-ip match: %+v", res.Matches)
}
}
func TestLookupNoMatch(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
res, err := Lookup(m, tenant, "192.0.2.1")
res, err := Lookup(context.Background(), m, tenant, "192.0.2.1")
if err != nil {
t.Fatal(err)
}
@@ -183,16 +238,17 @@ func TestLookupInvalid(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
ctx := context.Background()
_, err := Lookup(m, tenant, "")
_, err := Lookup(ctx, m, tenant, "")
if !errors.Is(err, store.ErrInvalidInput) {
t.Fatalf("empty: %v", err)
}
_, err = Lookup(m, tenant, "not a host")
_, err = Lookup(ctx, m, tenant, "not a host")
if !errors.Is(err, store.ErrInvalidInput) {
t.Fatalf("spaces: %v", err)
}
_, err = Lookup(m, tenant, "localhost")
_, err = Lookup(ctx, m, tenant, "localhost")
if !errors.Is(err, store.ErrInvalidInput) {
t.Fatalf("single label: %v", err)
}