ctcloud: CertSpotter-backed CT cloud-resource monitor
Single stateless Go process that consumes CT logs via embedded (patched) CertSpotter internals, classifies cloud/database/storage DNS identifiers in-process with a zero-allocation classifier, enriches via round-robin DNS resolvers with CNAME following, and batch-writes useful findings to PostgreSQL. - third_party/certspotter: 2-hunk IdentifierSink patch, no stdout/JSON/files - internal/classify: AWS/Azure/GCP/Firebase/Cloudflare R2/DigitalOcean/Supabase/OCI - internal/dns: sharded bounded TTL cache, multi-resolver load balancing + failover - internal/pipeline: bounded queues, dedup, direct-priority load shedding - internal/store: pgx batch writer, Postgres CT checkpoints (no cert contents) - internal/probe: optional non-destructive reachability checks (off by default) - Docker: distroless non-root, read-only rootfs, no app volume, resource limits - tests enforce 0 allocs/op on the classifier and sink hot path
This commit is contained in:
1 parent
9e78a114ac
commit
e8f7d6bfdf
118 files changed
+16473
No files matched your search
@@ -0,0 +1,131 @@
|
||||
// Package dns provides a bounded, cached DNS enricher that follows CNAME chains
|
||||
// with loop detection.
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tea.karlcom.de/karl/bucketeer/internal/metrics"
|
||||
)
|
||||
|
||||
// ErrCNAMELoop is returned when a CNAME chain revisits a host.
|
||||
var ErrCNAMELoop = errors.New("cname loop detected")
|
||||
|
||||
// Resolver is the subset of DNS operations the enricher needs.
|
||||
type Resolver interface {
|
||||
LookupCNAME(ctx context.Context, host string) (string, error)
|
||||
LookupIP(ctx context.Context, host string) ([]net.IP, error)
|
||||
}
|
||||
|
||||
// Result is a resolved hostname: its CNAME chain (chain[0] is the queried host)
|
||||
// and any A/AAAA addresses.
|
||||
type Result struct {
|
||||
Chain []string
|
||||
IPs []net.IP
|
||||
}
|
||||
|
||||
// GoResolver uses the standard library resolver.
|
||||
//
|
||||
// ponytail: net.Resolver.LookupCNAME returns the final canonical name, so the
|
||||
// chain has at most two entries and intermediate CNAME hops are not observed.
|
||||
// Swap in miekg/dns if the full chain is ever needed.
|
||||
type GoResolver struct {
|
||||
R *net.Resolver
|
||||
}
|
||||
|
||||
func NewGoResolver() *GoResolver {
|
||||
return &GoResolver{R: net.DefaultResolver}
|
||||
}
|
||||
|
||||
func (g *GoResolver) LookupCNAME(ctx context.Context, host string) (string, error) {
|
||||
return g.R.LookupCNAME(ctx, host)
|
||||
}
|
||||
|
||||
func (g *GoResolver) LookupIP(ctx context.Context, host string) ([]net.IP, error) {
|
||||
return g.R.LookupIP(ctx, "ip", host)
|
||||
}
|
||||
|
||||
// Follow walks a CNAME chain up to maxDepth hops, detecting loops.
|
||||
func Follow(ctx context.Context, r Resolver, host string, maxDepth int) ([]string, error) {
|
||||
if maxDepth < 1 {
|
||||
maxDepth = 1
|
||||
}
|
||||
chain := []string{host}
|
||||
cur := host
|
||||
for i := 0; i < maxDepth; i++ {
|
||||
next, err := r.LookupCNAME(ctx, cur)
|
||||
if err != nil {
|
||||
return chain, err
|
||||
}
|
||||
next = strings.TrimSuffix(next, ".")
|
||||
if next == "" || next == cur {
|
||||
break
|
||||
}
|
||||
for _, seen := range chain {
|
||||
if seen == next {
|
||||
return chain, ErrCNAMELoop
|
||||
}
|
||||
}
|
||||
chain = append(chain, next)
|
||||
cur = next
|
||||
}
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
// Enricher resolves hostnames with caching and bounded CNAME depth. It is safe
|
||||
// for concurrent use.
|
||||
type Enricher struct {
|
||||
Resolver Resolver
|
||||
Cache *TTLCache[Result]
|
||||
PositiveTTL time.Duration
|
||||
NegativeTTL time.Duration
|
||||
Timeout time.Duration
|
||||
MaxDepth int
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// Resolve returns the result for host. ok is false when the lookup failed;
|
||||
// failures (including "no such host") are negatively cached for NegativeTTL.
|
||||
func (e *Enricher) Resolve(ctx context.Context, host string) (Result, bool) {
|
||||
if e.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, e.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
if e.Cache != nil {
|
||||
if res, ok := e.Cache.Get(host); ok {
|
||||
if e.Metrics != nil {
|
||||
e.Metrics.DNSCacheHits.Add(1)
|
||||
}
|
||||
return res, len(res.Chain) > 0 || len(res.IPs) > 0
|
||||
}
|
||||
}
|
||||
if e.Metrics != nil {
|
||||
e.Metrics.DNSQueries.Add(1)
|
||||
}
|
||||
|
||||
chain, cnameErr := Follow(ctx, e.Resolver, host, e.MaxDepth)
|
||||
if cnameErr != nil && len(chain) == 1 {
|
||||
// A definitive NXDOMAIN has no A/AAAA to fetch either, so skip the
|
||||
// second lookup. NXDOMAIN is a valid answer, not an infrastructure error.
|
||||
if e.Metrics != nil && !definitive(cnameErr) {
|
||||
e.Metrics.DNSErrors.Add(1)
|
||||
}
|
||||
if e.Cache != nil {
|
||||
e.Cache.Set(host, Result{}, e.NegativeTTL)
|
||||
}
|
||||
return Result{}, false
|
||||
}
|
||||
res := Result{Chain: chain}
|
||||
if ips, err := e.Resolver.LookupIP(ctx, host); err == nil {
|
||||
res.IPs = ips
|
||||
}
|
||||
if e.Cache != nil {
|
||||
e.Cache.Set(host, res, e.PositiveTTL)
|
||||
}
|
||||
return res, true
|
||||
}
|
||||
Reference in new issue
Block a user