add geoip country/asn labels and ipv6

This commit is contained in:
Abel Luck 2026-05-05 13:57:12 +02:00
parent 8318f9fe70
commit 4710df2523
12 changed files with 559 additions and 43 deletions

View file

@ -12,6 +12,8 @@ it passively decodes DNSTT session IDs from DNS query names.
sudo dnstt_exporter \ sudo dnstt_exporter \
-dnstt.domain tunnel.example.com \ -dnstt.domain tunnel.example.com \
-dnstt.port 53 \ -dnstt.port 53 \
-geoip.country-database /path/to/GeoLite2-Country.mmdb \
-geoip.asn-database /path/to/GeoLite2-ASN.mmdb \
-web.listen-address :9713 -web.listen-address :9713
``` ```
@ -20,9 +22,30 @@ or grant the binary `CAP_NET_RAW`.
Metrics are served at `http://127.0.0.1:9713/metrics` by default. Metrics are served at `http://127.0.0.1:9713/metrics` by default.
## How It Works
`dnstt_exporter` opens a Linux `AF_PACKET` raw socket and passively watches UDP
DNS traffic on the configured DNSTT port. It parses IPv4 and IPv6 packets,
matches DNS query names against the configured DNSTT domain, and decodes the
DNSTT session ID from the query-name prefix.
The exporter treats a session as active when it has seen a query for that
session within the last 30 seconds. Peak client counts are the highest active
session counts observed since the exporter started.
GeoIP labels are based on the resolver address seen by the server. For incoming
queries this is the packet source address; for outgoing responses it is the
packet destination address. This may be a recursive resolver such as an ISP DNS
server, Cloudflare, Google, or Quad9, not the original DNSTT client.
The exporter does not run `dnstt-server`, proxy traffic, terminate DNSTT, or
decrypt tunnel payloads.
## Metrics ## Metrics
All DNSTT metrics use a `domain` label: All DNSTT metrics use a `domain` label. If `-geoip.country-database` is set,
metrics also include `country`. If `-geoip.asn-database` is set, metrics also
include `asn`. Unmapped countries use `ZZ`; unmapped ASNs use `0`.
- `dnstt_active_clients` - `dnstt_active_clients`
- `dnstt_peak_clients` - `dnstt_peak_clients`

View file

@ -39,6 +39,8 @@ func main() {
listenAddress = flag.String("web.listen-address", ":9713", "Address on which to expose metrics and web interface.") listenAddress = flag.String("web.listen-address", ":9713", "Address on which to expose metrics and web interface.")
metricsPath = flag.String("web.telemetry-path", "/metrics", "Path under which to expose metrics.") metricsPath = flag.String("web.telemetry-path", "/metrics", "Path under which to expose metrics.")
dnsPort = flag.Int("dnstt.port", 53, "UDP port where DNSTT DNS traffic is observed.") dnsPort = flag.Int("dnstt.port", 53, "UDP port where DNSTT DNS traffic is observed.")
countryDB = flag.String("geoip.country-database", "", "Optional MaxMind GeoIP2/GeoLite2 Country database path.")
asnDB = flag.String("geoip.asn-database", "", "Optional MaxMind GeoIP2/GeoLite2 ASN database path.")
) )
flag.Var(&domains, "dnstt.domain", "DNSTT tunnel domain to observe. Repeat for multiple domains.") flag.Var(&domains, "dnstt.domain", "DNSTT tunnel domain to observe. Repeat for multiple domains.")
flag.Parse() flag.Parse()
@ -50,7 +52,17 @@ func main() {
log.Fatalf("-dnstt.port must be between 1 and 65535, got %d", *dnsPort) log.Fatalf("-dnstt.port must be between 1 and 65535, got %d", *dnsPort)
} }
collector := dnstt.NewCollector(domains) var opts []dnstt.CollectorOption
if *countryDB != "" || *asnDB != "" {
geoResolver, err := dnstt.OpenGeoIPResolver(*countryDB, *asnDB)
if err != nil {
log.Fatalf("failed to open GeoIP database: %v", err)
}
defer geoResolver.Close()
opts = append(opts, dnstt.WithGeoResolver(geoResolver))
}
collector := dnstt.NewCollector(domains, opts...)
fd, err := dnstt.OpenRawSocket() fd, err := dnstt.OpenRawSocket()
if err != nil { if err != nil {
log.Fatalf("failed to open raw socket: %v", err) log.Fatalf("failed to open raw socket: %v", err)

View file

@ -16,7 +16,7 @@
pname = "dnstt_exporter"; pname = "dnstt_exporter";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
vendorHash = "sha256-+olXxVW9VcvapOuJGas70RIfJgMzUv6mndzJi6Apw+s="; vendorHash = "sha256-+msAQqz07XVzGjpwi8PvlA7jP4Y+j/BgSZdnIc0LrpA=";
subPackages = [ "cmd/dnstt_exporter" ]; subPackages = [ "cmd/dnstt_exporter" ];
}; };

8
go.mod
View file

@ -2,7 +2,10 @@ module guardianproject.dev/bypass-censorship/dnstt_exporter
go 1.25 go 1.25
require github.com/prometheus/client_golang v1.23.2 require (
github.com/oschwald/geoip2-golang/v2 v2.1.0
github.com/prometheus/client_golang v1.23.2
)
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
@ -10,10 +13,11 @@ require (
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.38.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect google.golang.org/protobuf v1.36.8 // indirect
) )

8
go.sum
View file

@ -17,6 +17,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oschwald/geoip2-golang/v2 v2.1.0 h1:DjnLhNJu9WHwTrmoiQFvgmyJoczhdnm7LB23UBI2Amo=
github.com/oschwald/geoip2-golang/v2 v2.1.0/go.mod h1:qdVmcPgrTJ4q2eP9tHq/yldMTdp2VMr33uVdFbHBiBc=
github.com/oschwald/maxminddb-golang/v2 v2.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEnqRzLQn9eH5ojc=
github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@ -35,8 +39,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -9,7 +9,7 @@ import (
// OpenRawSocket creates an AF_PACKET raw socket for sniffing IPv4 packets. // OpenRawSocket creates an AF_PACKET raw socket for sniffing IPv4 packets.
func OpenRawSocket() (int, error) { func OpenRawSocket() (int, error) {
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_DGRAM, int(htons(syscall.ETH_P_IP))) fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_DGRAM, int(htons(ethPAll)))
if err != nil { if err != nil {
return -1, fmt.Errorf("open raw socket: %w", err) return -1, fmt.Errorf("open raw socket: %w", err)
} }
@ -36,7 +36,7 @@ func CaptureLoop(fd int, port int, collector *Collector, stop <-chan struct{}) {
} }
return return
} }
ProcessIPv4Packet(buf[:n], port, collector) ProcessPacket(buf[:n], port, collector)
} }
} }
@ -48,3 +48,5 @@ func CloseRawSocket(fd int) error {
func htons(v uint16) uint16 { func htons(v uint16) uint16 {
return (v << 8) | (v >> 8) return (v << 8) | (v >> 8)
} }
const ethPAll = 0x0003

View file

@ -1,6 +1,8 @@
package dnstt package dnstt
import ( import (
"net/netip"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -15,14 +17,19 @@ type Collector struct {
domains []string domains []string
tunnels map[string]*tunnelState tunnels map[string]*tunnelState
now func() time.Time now func() time.Time
geo GeoResolver
} }
type tunnelState struct { type tunnelState struct {
series map[geoKey]*seriesState
clients map[string]*clientState
}
type seriesState struct {
queries uint64 queries uint64
bytesIn uint64 bytesIn uint64
bytesOut uint64 bytesOut uint64
peakClients int peakClients int
clients map[string]*clientState
} }
type clientState struct { type clientState struct {
@ -30,6 +37,13 @@ type clientState struct {
lastSeen time.Time lastSeen time.Time
queries uint64 queries uint64
bytesIn uint64 bytesIn uint64
firstKey geoKey
lastKey geoKey
}
type geoKey struct {
country string
asn string
} }
// Snapshot is a point-in-time copy of observed traffic. // Snapshot is a point-in-time copy of observed traffic.
@ -46,6 +60,20 @@ type TunnelSnapshot struct {
TotalQueries uint64 TotalQueries uint64
BytesIn uint64 BytesIn uint64
BytesOut uint64 BytesOut uint64
Series []SeriesSnapshot
}
// SeriesSnapshot is a per-label metric series for a tunnel.
type SeriesSnapshot struct {
Domain string
Country string
ASN string
ActiveClients int
PeakClients int
TotalSessions int
TotalQueries uint64
BytesIn uint64
BytesOut uint64
} }
// CollectorOption configures a Collector. // CollectorOption configures a Collector.
@ -58,6 +86,13 @@ func WithNow(now func() time.Time) CollectorOption {
} }
} }
// WithGeoResolver enables optional GeoIP labels for resolver IP addresses.
func WithGeoResolver(resolver GeoResolver) CollectorOption {
return func(c *Collector) {
c.geo = resolver
}
}
// NewCollector creates a collector for the provided DNSTT domains. // NewCollector creates a collector for the provided DNSTT domains.
func NewCollector(domains []string, opts ...CollectorOption) *Collector { func NewCollector(domains []string, opts ...CollectorOption) *Collector {
c := &Collector{ c := &Collector{
@ -75,6 +110,7 @@ func NewCollector(domains []string, opts ...CollectorOption) *Collector {
} }
c.domains = append(c.domains, domain) c.domains = append(c.domains, domain)
c.tunnels[domain] = &tunnelState{ c.tunnels[domain] = &tunnelState{
series: make(map[geoKey]*seriesState),
clients: make(map[string]*clientState), clients: make(map[string]*clientState),
} }
} }
@ -86,6 +122,13 @@ func NewCollector(domains []string, opts ...CollectorOption) *Collector {
return c return c
} }
// GeoLabelNames returns the optional GeoIP label names used by this collector.
func (c *Collector) GeoLabelNames() []string {
c.mu.Lock()
defer c.mu.Unlock()
return c.geoLabelNamesLocked()
}
// Domains returns the normalized DNSTT domains this collector recognizes. // Domains returns the normalized DNSTT domains this collector recognizes.
func (c *Collector) Domains() []string { func (c *Collector) Domains() []string {
c.mu.Lock() c.mu.Lock()
@ -98,6 +141,11 @@ func (c *Collector) Domains() []string {
// RecordQuery records an observed DNSTT DNS query. // RecordQuery records an observed DNSTT DNS query.
func (c *Collector) RecordQuery(domain string, clientID string, size int) { func (c *Collector) RecordQuery(domain string, clientID string, size int) {
c.RecordQueryFrom(domain, clientID, netip.Addr{}, size)
}
// RecordQueryFrom records an observed DNSTT DNS query from a resolver address.
func (c *Collector) RecordQueryFrom(domain string, clientID string, resolverIP netip.Addr, size int) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@ -106,9 +154,11 @@ func (c *Collector) RecordQuery(domain string, clientID string, size int) {
return return
} }
tunnel.queries++ key := c.geoKeyLocked(resolverIP)
series := ensureSeries(tunnel, key)
series.queries++
if size > 0 { if size > 0 {
tunnel.bytesIn += uint64(size) series.bytesIn += uint64(size)
} }
if clientID == "" { if clientID == "" {
@ -118,23 +168,26 @@ func (c *Collector) RecordQuery(domain string, clientID string, size int) {
now := c.now() now := c.now()
client, exists := tunnel.clients[clientID] client, exists := tunnel.clients[clientID]
if !exists { if !exists {
client = &clientState{firstSeen: now} client = &clientState{firstSeen: now, firstKey: key}
tunnel.clients[clientID] = client tunnel.clients[clientID] = client
} }
client.lastSeen = now client.lastSeen = now
client.lastKey = key
client.queries++ client.queries++
if size > 0 { if size > 0 {
client.bytesIn += uint64(size) client.bytesIn += uint64(size)
} }
active := activeClients(tunnel, now) updatePeaks(tunnel, now)
if active > tunnel.peakClients {
tunnel.peakClients = active
}
} }
// RecordResponse records an observed DNSTT DNS response. // RecordResponse records an observed DNSTT DNS response.
func (c *Collector) RecordResponse(domain string, size int) { func (c *Collector) RecordResponse(domain string, size int) {
c.RecordResponseFrom(domain, netip.Addr{}, size)
}
// RecordResponseFrom records an observed DNSTT DNS response to a resolver address.
func (c *Collector) RecordResponseFrom(domain string, resolverIP netip.Addr, size int) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@ -142,8 +195,10 @@ func (c *Collector) RecordResponse(domain string, size int) {
if !ok { if !ok {
return return
} }
key := c.geoKeyLocked(resolverIP)
series := ensureSeries(tunnel, key)
if size > 0 { if size > 0 {
tunnel.bytesOut += uint64(size) series.bytesOut += uint64(size)
} }
} }
@ -156,21 +211,22 @@ func (c *Collector) Snapshot() Snapshot {
snapshot := Snapshot{Tunnels: make(map[string]TunnelSnapshot, len(c.tunnels))} snapshot := Snapshot{Tunnels: make(map[string]TunnelSnapshot, len(c.tunnels))}
for _, domain := range c.domains { for _, domain := range c.domains {
tunnel := c.tunnels[domain] tunnel := c.tunnels[domain]
active := activeClients(tunnel, now) updatePeaks(tunnel, now)
if active > tunnel.peakClients { series := c.seriesSnapshotsLocked(domain, tunnel, now)
tunnel.peakClients = active
}
snapshot.Tunnels[domain] = TunnelSnapshot{ tunnelSnapshot := TunnelSnapshot{Domain: domain, Series: series}
Domain: domain, for _, s := range series {
ActiveClients: active, tunnelSnapshot.ActiveClients += s.ActiveClients
PeakClients: tunnel.peakClients, tunnelSnapshot.TotalSessions += s.TotalSessions
TotalSessions: len(tunnel.clients), tunnelSnapshot.TotalQueries += s.TotalQueries
TotalQueries: tunnel.queries, tunnelSnapshot.BytesIn += s.BytesIn
BytesIn: tunnel.bytesIn, tunnelSnapshot.BytesOut += s.BytesOut
BytesOut: tunnel.bytesOut, if s.PeakClients > tunnelSnapshot.PeakClients {
tunnelSnapshot.PeakClients = s.PeakClients
} }
} }
snapshot.Tunnels[domain] = tunnelSnapshot
}
return snapshot return snapshot
} }
@ -188,12 +244,103 @@ func (c *Collector) findTunnelLocked(queryDomain string) (*tunnelState, bool) {
return nil, false return nil, false
} }
func activeClients(tunnel *tunnelState, now time.Time) int { func ensureSeries(tunnel *tunnelState, key geoKey) *seriesState {
active := 0 series, ok := tunnel.series[key]
if !ok {
series = &seriesState{}
tunnel.series[key] = series
}
return series
}
func updatePeaks(tunnel *tunnelState, now time.Time) {
activeByKey := make(map[geoKey]int)
for _, client := range tunnel.clients { for _, client := range tunnel.clients {
if now.Sub(client.lastSeen) < ClientTimeout { if now.Sub(client.lastSeen) < ClientTimeout {
active++ activeByKey[client.lastKey]++
}
}
for key, active := range activeByKey {
series := ensureSeries(tunnel, key)
if active > series.peakClients {
series.peakClients = active
} }
} }
return active }
func (c *Collector) seriesSnapshotsLocked(domain string, tunnel *tunnelState, now time.Time) []SeriesSnapshot {
activeByKey := make(map[geoKey]int)
sessionsByKey := make(map[geoKey]int)
for _, client := range tunnel.clients {
sessionsByKey[client.firstKey]++
if now.Sub(client.lastSeen) < ClientTimeout {
activeByKey[client.lastKey]++
}
}
keys := make([]geoKey, 0, len(tunnel.series))
for key := range tunnel.series {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].country != keys[j].country {
return keys[i].country < keys[j].country
}
return keys[i].asn < keys[j].asn
})
series := make([]SeriesSnapshot, 0, len(keys))
for _, key := range keys {
state := tunnel.series[key]
series = append(series, SeriesSnapshot{
Domain: domain,
Country: key.country,
ASN: key.asn,
ActiveClients: activeByKey[key],
PeakClients: state.peakClients,
TotalSessions: sessionsByKey[key],
TotalQueries: state.queries,
BytesIn: state.bytesIn,
BytesOut: state.bytesOut,
})
}
return series
}
func (c *Collector) geoLabelNamesLocked() []string {
if c.geo == nil {
return nil
}
names := c.geo.LabelNames()
out := make([]string, 0, len(names))
for _, name := range names {
if name == "country" || name == "asn" {
out = append(out, name)
}
}
return out
}
func (c *Collector) geoKeyLocked(addr netip.Addr) geoKey {
var key geoKey
if c.geo == nil {
return key
}
labels := c.geo.Lookup(addr)
for _, name := range c.geoLabelNamesLocked() {
switch name {
case "country":
key.country = labels.Country
if key.country == "" {
key.country = UnknownCountry
}
case "asn":
key.asn = labels.ASN
if key.asn == "" {
key.asn = UnknownASN
}
}
}
return key
} }

View file

@ -18,7 +18,7 @@ type Exporter struct {
// NewExporter creates a Prometheus collector for DNSTT metrics. // NewExporter creates a Prometheus collector for DNSTT metrics.
func NewExporter(collector *Collector) *Exporter { func NewExporter(collector *Collector) *Exporter {
labels := []string{"domain"} labels := append([]string{"domain"}, collector.GeoLabelNames()...)
return &Exporter{ return &Exporter{
collector: collector, collector: collector,
activeClients: prometheus.NewDesc( activeClients: prometheus.NewDesc(
@ -73,11 +73,27 @@ func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {
// Collect sends current metric values to Prometheus. // Collect sends current metric values to Prometheus.
func (e *Exporter) Collect(ch chan<- prometheus.Metric) { func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
for _, tunnel := range e.collector.Snapshot().Tunnels { for _, tunnel := range e.collector.Snapshot().Tunnels {
ch <- prometheus.MustNewConstMetric(e.activeClients, prometheus.GaugeValue, float64(tunnel.ActiveClients), tunnel.Domain) for _, series := range tunnel.Series {
ch <- prometheus.MustNewConstMetric(e.peakClients, prometheus.GaugeValue, float64(tunnel.PeakClients), tunnel.Domain) labels := e.labelValues(series)
ch <- prometheus.MustNewConstMetric(e.queries, prometheus.CounterValue, float64(tunnel.TotalQueries), tunnel.Domain) ch <- prometheus.MustNewConstMetric(e.activeClients, prometheus.GaugeValue, float64(series.ActiveClients), labels...)
ch <- prometheus.MustNewConstMetric(e.bytesIn, prometheus.CounterValue, float64(tunnel.BytesIn), tunnel.Domain) ch <- prometheus.MustNewConstMetric(e.peakClients, prometheus.GaugeValue, float64(series.PeakClients), labels...)
ch <- prometheus.MustNewConstMetric(e.bytesOut, prometheus.CounterValue, float64(tunnel.BytesOut), tunnel.Domain) ch <- prometheus.MustNewConstMetric(e.queries, prometheus.CounterValue, float64(series.TotalQueries), labels...)
ch <- prometheus.MustNewConstMetric(e.sessions, prometheus.CounterValue, float64(tunnel.TotalSessions), tunnel.Domain) ch <- prometheus.MustNewConstMetric(e.bytesIn, prometheus.CounterValue, float64(series.BytesIn), labels...)
ch <- prometheus.MustNewConstMetric(e.bytesOut, prometheus.CounterValue, float64(series.BytesOut), labels...)
ch <- prometheus.MustNewConstMetric(e.sessions, prometheus.CounterValue, float64(series.TotalSessions), labels...)
}
} }
} }
func (e *Exporter) labelValues(series SeriesSnapshot) []string {
values := []string{series.Domain}
for _, label := range e.collector.GeoLabelNames() {
switch label {
case "country":
values = append(values, series.Country)
case "asn":
values = append(values, series.ASN)
}
}
return values
}

View file

@ -1,6 +1,7 @@
package dnstt package dnstt
import ( import (
"net/netip"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -40,3 +41,101 @@ dnstt_sessions_total{domain="tunnel.example.com"} 2
t.Fatal(err) t.Fatal(err)
} }
} }
func TestExporterCollectsGeoIPCountryAndASNLabels(t *testing.T) {
now := time.Unix(1000, 0)
resolverIP := netip.MustParseAddr("2001:db8::53")
c := NewCollector(
[]string{"tunnel.example.com"},
WithNow(func() time.Time { return now }),
WithGeoResolver(fakeGeoResolver{
labelNames: []string{"country", "asn"},
labels: map[netip.Addr]GeoLabels{
resolverIP: {Country: "DE", ASN: "3320"},
},
}),
)
c.RecordQueryFrom("tunnel.example.com", "client-a", resolverIP, 100)
expected := `
# HELP dnstt_active_clients Number of DNSTT client sessions observed within the active timeout window.
# TYPE dnstt_active_clients gauge
dnstt_active_clients{asn="3320",country="DE",domain="tunnel.example.com"} 1
# HELP dnstt_bytes_in_total Total bytes observed in DNSTT DNS queries.
# TYPE dnstt_bytes_in_total counter
dnstt_bytes_in_total{asn="3320",country="DE",domain="tunnel.example.com"} 100
# HELP dnstt_bytes_out_total Total bytes observed in DNSTT DNS responses.
# TYPE dnstt_bytes_out_total counter
dnstt_bytes_out_total{asn="3320",country="DE",domain="tunnel.example.com"} 0
# HELP dnstt_peak_clients Maximum concurrent active DNSTT client sessions observed.
# TYPE dnstt_peak_clients gauge
dnstt_peak_clients{asn="3320",country="DE",domain="tunnel.example.com"} 1
# HELP dnstt_queries_total Total DNSTT DNS queries observed.
# TYPE dnstt_queries_total counter
dnstt_queries_total{asn="3320",country="DE",domain="tunnel.example.com"} 1
# HELP dnstt_sessions_total Total unique DNSTT client sessions observed.
# TYPE dnstt_sessions_total counter
dnstt_sessions_total{asn="3320",country="DE",domain="tunnel.example.com"} 1
`
if err := testutil.CollectAndCompare(NewExporter(c), strings.NewReader(expected)); err != nil {
t.Fatal(err)
}
}
func TestExporterCanCollectASNWithoutCountry(t *testing.T) {
now := time.Unix(1000, 0)
resolverIP := netip.MustParseAddr("192.0.2.53")
c := NewCollector(
[]string{"tunnel.example.com"},
WithNow(func() time.Time { return now }),
WithGeoResolver(fakeGeoResolver{
labelNames: []string{"asn"},
labels: map[netip.Addr]GeoLabels{
resolverIP: {ASN: "15169"},
},
}),
)
c.RecordQueryFrom("tunnel.example.com", "client-a", resolverIP, 100)
expected := `
# HELP dnstt_active_clients Number of DNSTT client sessions observed within the active timeout window.
# TYPE dnstt_active_clients gauge
dnstt_active_clients{asn="15169",domain="tunnel.example.com"} 1
# HELP dnstt_bytes_in_total Total bytes observed in DNSTT DNS queries.
# TYPE dnstt_bytes_in_total counter
dnstt_bytes_in_total{asn="15169",domain="tunnel.example.com"} 100
# HELP dnstt_bytes_out_total Total bytes observed in DNSTT DNS responses.
# TYPE dnstt_bytes_out_total counter
dnstt_bytes_out_total{asn="15169",domain="tunnel.example.com"} 0
# HELP dnstt_peak_clients Maximum concurrent active DNSTT client sessions observed.
# TYPE dnstt_peak_clients gauge
dnstt_peak_clients{asn="15169",domain="tunnel.example.com"} 1
# HELP dnstt_queries_total Total DNSTT DNS queries observed.
# TYPE dnstt_queries_total counter
dnstt_queries_total{asn="15169",domain="tunnel.example.com"} 1
# HELP dnstt_sessions_total Total unique DNSTT client sessions observed.
# TYPE dnstt_sessions_total counter
dnstt_sessions_total{asn="15169",domain="tunnel.example.com"} 1
`
if err := testutil.CollectAndCompare(NewExporter(c), strings.NewReader(expected)); err != nil {
t.Fatal(err)
}
}
type fakeGeoResolver struct {
labelNames []string
labels map[netip.Addr]GeoLabels
}
func (f fakeGeoResolver) Lookup(addr netip.Addr) GeoLabels {
if labels, ok := f.labels[addr]; ok {
return labels
}
return GeoLabels{Country: UnknownCountry, ASN: UnknownASN}
}
func (f fakeGeoResolver) LabelNames() []string {
return f.labelNames
}

108
internal/dnstt/geoip.go Normal file
View file

@ -0,0 +1,108 @@
package dnstt
import (
"fmt"
"net/netip"
"strconv"
geoip2 "github.com/oschwald/geoip2-golang/v2"
)
const (
// UnknownCountry is used when country lookup is enabled but no country is found.
UnknownCountry = "ZZ"
// UnknownASN is used when ASN lookup is enabled but no ASN is found.
UnknownASN = "0"
)
// GeoLabels are optional labels derived from a resolver IP address.
type GeoLabels struct {
Country string
ASN string
}
// GeoResolver resolves optional GeoIP labels for resolver IP addresses.
type GeoResolver interface {
Lookup(netip.Addr) GeoLabels
LabelNames() []string
}
// GeoIPResolver uses optional MaxMind Country and ASN databases.
type GeoIPResolver struct {
countryDB *geoip2.Reader
asnDB *geoip2.Reader
}
// OpenGeoIPResolver opens optional MaxMind Country and ASN databases.
func OpenGeoIPResolver(countryDatabase string, asnDatabase string) (*GeoIPResolver, error) {
resolver := &GeoIPResolver{}
if countryDatabase != "" {
db, err := geoip2.Open(countryDatabase)
if err != nil {
return nil, fmt.Errorf("open country database: %w", err)
}
resolver.countryDB = db
}
if asnDatabase != "" {
db, err := geoip2.Open(asnDatabase)
if err != nil {
resolver.Close()
return nil, fmt.Errorf("open ASN database: %w", err)
}
resolver.asnDB = db
}
return resolver, nil
}
// Close closes any open MaxMind databases.
func (r *GeoIPResolver) Close() {
if r.countryDB != nil {
r.countryDB.Close()
}
if r.asnDB != nil {
r.asnDB.Close()
}
}
// LabelNames returns the optional Prometheus labels enabled by configured databases.
func (r *GeoIPResolver) LabelNames() []string {
var labels []string
if r.countryDB != nil {
labels = append(labels, "country")
}
if r.asnDB != nil {
labels = append(labels, "asn")
}
return labels
}
// Lookup returns optional GeoIP labels for a resolver IP address.
func (r *GeoIPResolver) Lookup(addr netip.Addr) GeoLabels {
labels := GeoLabels{}
if !addr.IsValid() {
if r.countryDB != nil {
labels.Country = UnknownCountry
}
if r.asnDB != nil {
labels.ASN = UnknownASN
}
return labels
}
if r.countryDB != nil {
labels.Country = UnknownCountry
record, err := r.countryDB.Country(addr)
if err == nil && record.HasData() && record.Country.ISOCode != "" {
labels.Country = record.Country.ISOCode
}
}
if r.asnDB != nil {
labels.ASN = UnknownASN
record, err := r.asnDB.ASN(addr)
if err == nil && record.HasData() && record.AutonomousSystemNumber != 0 {
labels.ASN = strconv.FormatUint(uint64(record.AutonomousSystemNumber), 10)
}
}
return labels
}

View file

@ -2,9 +2,23 @@ package dnstt
import ( import (
"encoding/binary" "encoding/binary"
"net/netip"
"strings" "strings"
) )
// ProcessPacket records DNSTT DNS traffic from a raw IP packet.
func ProcessPacket(data []byte, port int, collector *Collector) {
if len(data) == 0 {
return
}
switch data[0] >> 4 {
case 4:
ProcessIPv4Packet(data, port, collector)
case 6:
ProcessIPv6Packet(data, port, collector)
}
}
// ProcessIPv4Packet records DNSTT DNS traffic from a raw IPv4 packet. // ProcessIPv4Packet records DNSTT DNS traffic from a raw IPv4 packet.
func ProcessIPv4Packet(data []byte, port int, collector *Collector) { func ProcessIPv4Packet(data []byte, port int, collector *Collector) {
if len(data) < 20 || data[0]>>4 != 4 { if len(data) < 20 || data[0]>>4 != 4 {
@ -18,8 +32,39 @@ func ProcessIPv4Packet(data []byte, port int, collector *Collector) {
if data[9] != 17 { if data[9] != 17 {
return return
} }
srcIP := netip.AddrFrom4([4]byte{data[12], data[13], data[14], data[15]})
dstIP := netip.AddrFrom4([4]byte{data[16], data[17], data[18], data[19]})
udpData := data[ihl:] udpData := data[ihl:]
processUDPPayload(udpData, srcIP, dstIP, port, collector)
}
// ProcessIPv6Packet records DNSTT DNS traffic from a raw IPv6 packet.
func ProcessIPv6Packet(data []byte, port int, collector *Collector) {
if len(data) < 40 || data[0]>>4 != 6 {
return
}
if data[6] != 17 {
return
}
var srcBytes [16]byte
copy(srcBytes[:], data[8:24])
srcIP := netip.AddrFrom16(srcBytes)
var dstBytes [16]byte
copy(dstBytes[:], data[24:40])
dstIP := netip.AddrFrom16(dstBytes)
payloadLen := int(binary.BigEndian.Uint16(data[4:6]))
if payloadLen <= 0 || 40+payloadLen > len(data) {
return
}
processUDPPayload(data[40:40+payloadLen], srcIP, dstIP, port, collector)
}
func processUDPPayload(udpData []byte, srcIP netip.Addr, dstIP netip.Addr, port int, collector *Collector) {
if len(udpData) < 8 { if len(udpData) < 8 {
return return
} }
@ -35,7 +80,7 @@ func ProcessIPv4Packet(data []byte, port int, collector *Collector) {
if int(dstPort) == port { if int(dstPort) == port {
domain, clientID := ExtractQuery(dnsPayload, collector.Domains()) domain, clientID := ExtractQuery(dnsPayload, collector.Domains())
if domain != "" { if domain != "" {
collector.RecordQuery(domain, clientID, udpLen) collector.RecordQueryFrom(domain, clientID, srcIP, udpLen)
} }
return return
} }
@ -43,7 +88,7 @@ func ProcessIPv4Packet(data []byte, port int, collector *Collector) {
if int(srcPort) == port { if int(srcPort) == port {
domain := extractQueryDomain(dnsPayload) domain := extractQueryDomain(dnsPayload)
if domain != "" { if domain != "" {
collector.RecordResponse(domain, udpLen) collector.RecordResponseFrom(domain, dstIP, udpLen)
} }
} }
} }

View file

@ -0,0 +1,56 @@
package dnstt
import (
"encoding/binary"
"net/netip"
"testing"
"time"
)
func TestProcessIPv6PacketRecordsResolverAddress(t *testing.T) {
now := time.Unix(1000, 0)
resolverIP := netip.MustParseAddr("2001:db8::53")
serverIP := netip.MustParseAddr("2001:db8::1")
c := NewCollector(
[]string{"tunnel.example.com"},
WithNow(func() time.Time { return now }),
WithGeoResolver(fakeGeoResolver{
labelNames: []string{"asn"},
labels: map[netip.Addr]GeoLabels{
resolverIP: {ASN: "13335"},
},
}),
)
ProcessPacket(ipv6UDPPacket(resolverIP, serverIP, 53000, 53, dnsQuery("MFRGGZDFMZTWQ2LK", "tunnel.example.com")), 53, c)
series := c.Snapshot().Tunnels["tunnel.example.com"].Series
if len(series) != 1 {
t.Fatalf("series count = %d, want 1", len(series))
}
if series[0].ASN != "13335" {
t.Fatalf("ASN = %q, want 13335", series[0].ASN)
}
if series[0].TotalQueries != 1 {
t.Fatalf("queries = %d, want 1", series[0].TotalQueries)
}
}
func ipv6UDPPacket(src, dst netip.Addr, srcPort, dstPort uint16, payload []byte) []byte {
packet := make([]byte, 40+8+len(payload))
packet[0] = 0x60
binary.BigEndian.PutUint16(packet[4:6], uint16(8+len(payload)))
packet[6] = 17
packet[7] = 64
srcBytes := src.As16()
dstBytes := dst.As16()
copy(packet[8:24], srcBytes[:])
copy(packet[24:40], dstBytes[:])
udp := packet[40:]
binary.BigEndian.PutUint16(udp[0:2], srcPort)
binary.BigEndian.PutUint16(udp[2:4], dstPort)
binary.BigEndian.PutUint16(udp[4:6], uint16(8+len(payload)))
copy(udp[8:], payload)
return packet
}