Files
ExploreDNS/internal/fingerprint/fingerprint.go
T
c96d18859f fix: address code review issues on PR #10 (HAN-384)
- runner.go: store fingerprints in local var, call formatter.SetFingerprints()
  instead of mutating caller's *Config; removes Fingerprints field from Config
- fingerprint.go: use singleflight.Group in Query to coalesce concurrent
  probes for the same IP, eliminating the double-probe race
- output layer: add SetFingerprints(map[string]string) to Formatter interface;
  textFormatter and jsonFormatter each hold their own fingerprints field
- formatter_test.go: add TestTextFormatterWriteSummaryShowsVersions and
  TestJSONFormatterWriteSummaryShowsVersions verifying version output

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 02:59:38 +10:00

145 lines
3.4 KiB
Go

package fingerprint
import (
"context"
"net"
"sync"
"time"
miekgdns "github.com/miekg/dns"
"golang.org/x/sync/singleflight"
)
const defaultTimeout = 2 * time.Second
// Fingerprinter queries DNS servers for their software version via the
// version.bind CHAOS TXT query. Results are cached per server IP.
type Fingerprinter struct {
mu sync.Mutex
cache map[string]string
timeout time.Duration
group singleflight.Group
exchange func(ctx context.Context, addr string, m *miekgdns.Msg) (*miekgdns.Msg, error)
}
// New returns a Fingerprinter with a 2-second per-query timeout.
func New() *Fingerprinter {
return NewWithTimeout(defaultTimeout)
}
// NewWithTimeout returns a Fingerprinter using the given per-query timeout.
func NewWithTimeout(timeout time.Duration) *Fingerprinter {
return &Fingerprinter{
cache: make(map[string]string),
timeout: timeout,
}
}
// Query returns the version string for ip, or "" if the server doesn't
// respond or doesn't support the version.bind CHAOS query.
// Results are cached: subsequent calls for the same IP return immediately.
// Concurrent calls for the same IP are coalesced via singleflight so only
// one network probe is issued per IP at a time.
func (f *Fingerprinter) Query(ctx context.Context, ip net.IP) string {
key := ip.String()
f.mu.Lock()
if v, ok := f.cache[key]; ok {
f.mu.Unlock()
return v
}
f.mu.Unlock()
v, _, _ := f.group.Do(key, func() (interface{}, error) {
version := f.probe(ctx, ip)
f.mu.Lock()
f.cache[key] = version
f.mu.Unlock()
return version, nil
})
return v.(string)
}
// FingerprintAll queries all ips concurrently and returns a map of
// IP string → version string. IPs that don't respond map to "".
// Already-cached IPs are returned from cache without a network round-trip.
func (f *Fingerprinter) FingerprintAll(ctx context.Context, ips []net.IP) map[string]string {
results := make(map[string]string, len(ips))
var (
wg sync.WaitGroup
mu sync.Mutex
toQuery []net.IP
)
f.mu.Lock()
for _, ip := range ips {
key := ip.String()
if v, ok := f.cache[key]; ok {
results[key] = v
} else {
toQuery = append(toQuery, ip)
}
}
f.mu.Unlock()
for _, ip := range toQuery {
wg.Add(1)
go func(ip net.IP) {
defer wg.Done()
version := f.probe(ctx, ip)
key := ip.String()
f.mu.Lock()
f.cache[key] = version
f.mu.Unlock()
mu.Lock()
results[key] = version
mu.Unlock()
}(ip)
}
wg.Wait()
return results
}
// probe sends a version.bind CHAOS TXT query and returns the version string,
// or "" on any error or non-success response.
func (f *Fingerprinter) probe(ctx context.Context, ip net.IP) string {
m := new(miekgdns.Msg)
m.SetQuestion("version.bind.", miekgdns.TypeTXT)
m.Question[0].Qclass = miekgdns.ClassCHAOS
m.RecursionDesired = false
target := net.JoinHostPort(ip.String(), "53")
queryCtx, cancel := context.WithTimeout(ctx, f.timeout)
defer cancel()
var resp *miekgdns.Msg
var err error
if f.exchange != nil {
resp, err = f.exchange(queryCtx, target, m)
} else {
client := &miekgdns.Client{
Net: "udp",
Timeout: f.timeout,
}
resp, _, err = client.ExchangeContext(queryCtx, m, target)
}
if err != nil || resp == nil || resp.Rcode != miekgdns.RcodeSuccess {
return ""
}
for _, rr := range resp.Answer {
if txt, ok := rr.(*miekgdns.TXT); ok && len(txt.Txt) > 0 {
return txt.Txt[0]
}
}
return ""
}