From 998c67e67f420bd209bc2f1e2b3d6f41aba3b2d9 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 21 Sep 2026 10:58:19 +0200 Subject: [PATCH 1/6] chore(lint): Clear gosec and gocritic findings in libs and dnscheck Signed-off-by: Maciek --- dnscheck/api/check.go | 5 +--- dnscheck/cache/cache.go | 3 +-- dnscheck/dns/handler.go | 16 +++++++++-- libs/dnsstamps/dnsstamps.go | 53 ++++++++++++++++++++++++------------- libs/store/mongodb.go | 2 +- libs/store/store.go | 3 +-- 6 files changed, 53 insertions(+), 29 deletions(-) diff --git a/dnscheck/api/check.go b/dnscheck/api/check.go index 17ed772c..f2acdbd2 100644 --- a/dnscheck/api/check.go +++ b/dnscheck/api/check.go @@ -49,10 +49,7 @@ func (s *APIServer) DnsCheck() fiber.Handler { return HandleError(c, err, ErrFailedToUnmarshalRecord) } - return c.Status(200).JSON(dns.DNSCheckResponse{ - Status: dnsRecord.Status, - ProfileId: dnsRecord.ProfileId, - }) + return c.Status(200).JSON(dns.DNSCheckResponse(dnsRecord)) } return handler } diff --git a/dnscheck/cache/cache.go b/dnscheck/cache/cache.go index 3e577c1c..6ef03af7 100644 --- a/dnscheck/cache/cache.go +++ b/dnscheck/cache/cache.go @@ -16,8 +16,7 @@ type Cache interface { // New creates a new Cache instance whose entries expire after ttl. func New(cacheType string, ttl time.Duration) (Cache, error) { - switch cacheType { - case CacheTypeBigCache: + if cacheType == CacheTypeBigCache { return NewBigcache(ttl) } return nil, errors.New("unknown cache type") diff --git a/dnscheck/dns/handler.go b/dnscheck/dns/handler.go index e6921fb3..a2d65dda 100644 --- a/dnscheck/dns/handler.go +++ b/dnscheck/dns/handler.go @@ -171,7 +171,9 @@ func (h *Handler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { default: msg.Ns = h.createSOA() } - w.WriteMsg(&msg) + if err := w.WriteMsg(&msg); err != nil { + log.Error().Err(err).Msg("Failed to write DNS response") + } } func (h *Handler) extractConfiguredProfileId(r *dns.Msg) (profileId string) { @@ -203,7 +205,7 @@ func (h *Handler) createSOA() []dns.RR { Ttl: TTL}, Ns: "ns1." + dom, Mbox: "hostmaster." + dom, - Serial: uint32(time.Now().Truncate(time.Hour).Unix()), + Serial: soaSerial(time.Now()), Refresh: 28800, Retry: 7200, Expire: 604800, @@ -212,6 +214,16 @@ func (h *Handler) createSOA() []dns.RR { } } +// soaSerial is the hour-truncated Unix time. RFC 1035 ยง3.3.13 makes SERIAL a +// 32-bit unsigned value, which Unix seconds exceed only in 2106. +func soaSerial(now time.Time) uint32 { + s := now.Truncate(time.Hour).Unix() + if s < 0 || s > 1<<32-1 { + return 0 + } + return uint32(s) +} + // clientIP returns the transport-level source address of the query. It is read // straight from the socket address and never resolved. func clientIP(addr net.Addr) (net.IP, error) { diff --git a/libs/dnsstamps/dnsstamps.go b/libs/dnsstamps/dnsstamps.go index 9ba29ffd..fb7bd24e 100644 --- a/libs/dnsstamps/dnsstamps.go +++ b/libs/dnsstamps/dnsstamps.go @@ -99,15 +99,16 @@ func NewServerStampFromString(stampStr string) (ServerStamp, error) { return ServerStamp{}, errors.New("stamp is too short") } - if bin[0] == uint8(StampProtoTypePlain) { + switch bin[0] { + case uint8(StampProtoTypePlain): return newPlainServerStamp(bin) - } else if bin[0] == uint8(StampProtoTypeDNSCrypt) { + case uint8(StampProtoTypeDNSCrypt): return newDNSCryptServerStamp(bin) - } else if bin[0] == uint8(StampProtoTypeDoH) { + case uint8(StampProtoTypeDoH): return newDoHServerStamp(bin) - } else if bin[0] == uint8(StampProtoTypeTLS) { + case uint8(StampProtoTypeTLS): return newDoTOrDoQServerStamp(bin, StampProtoTypeTLS, defaultDoTPort) - } else if bin[0] == uint8(StampProtoTypeDoQ) { + case uint8(StampProtoTypeDoQ): return newDoTOrDoQServerStamp(bin, StampProtoTypeDoQ, defaultDoQPort) } return ServerStamp{}, errors.New("unsupported stamp version or protocol") @@ -317,6 +318,18 @@ func newPlainServerStamp(bin []byte) (ServerStamp, error) { return stamp, nil } +// lenByte is the one-byte length prefix the stamp format uses for every +// variable field; a value longer than 255 bytes cannot be encoded and is +// truncated to the maximum rather than wrapped. Written as an explicit +// comparison because gosec (G115) only credits a bounds check in that form. +func lenByte[T ~string | ~[]byte](v T) uint8 { + n := len(v) + if n > 255 { + n = 255 + } + return uint8(n) +} + func (stamp *ServerStamp) dnsCryptString() string { bin := make([]uint8, 9) bin[0] = uint8(StampProtoTypeDNSCrypt) @@ -326,13 +339,13 @@ func (stamp *ServerStamp) dnsCryptString() string { if strings.HasSuffix(serverAddrStr, ":"+strconv.Itoa(defaultDNSCryptPort)) { serverAddrStr = serverAddrStr[:len(serverAddrStr)-1-len(strconv.Itoa(defaultDNSCryptPort))] } - bin = append(bin, uint8(len(serverAddrStr))) + bin = append(bin, lenByte(serverAddrStr)) bin = append(bin, []uint8(serverAddrStr)...) - bin = append(bin, uint8(len(stamp.ServerPk))) + bin = append(bin, lenByte(stamp.ServerPk)) bin = append(bin, stamp.ServerPk...) - bin = append(bin, uint8(len(stamp.ProviderName))) + bin = append(bin, lenByte(stamp.ProviderName)) bin = append(bin, []uint8(stamp.ProviderName)...) str := base64.RawURLEncoding.EncodeToString(bin) @@ -349,7 +362,7 @@ func (stamp *ServerStamp) dohString() string { if strings.HasSuffix(serverAddrStr, ":"+strconv.Itoa(defaultDoHPort)) { serverAddrStr = serverAddrStr[:len(serverAddrStr)-1-len(strconv.Itoa(defaultDoHPort))] } - bin = append(bin, uint8(len(serverAddrStr))) + bin = append(bin, lenByte(serverAddrStr)) bin = append(bin, []uint8(serverAddrStr)...) if len(stamp.Hashes) == 0 { @@ -357,19 +370,21 @@ func (stamp *ServerStamp) dohString() string { } else { last := len(stamp.Hashes) - 1 for i, hash := range stamp.Hashes { - vlen := len(hash) + // Low 7 bits carry the length (hashes are 32-byte SHA-256 digests), + // the high bit flags that another hash follows. + vlen := lenByte(hash) & 0x7f if i < last { vlen |= 0x80 } - bin = append(bin, uint8(vlen)) + bin = append(bin, vlen) bin = append(bin, hash...) } } - bin = append(bin, uint8(len(stamp.ProviderName))) + bin = append(bin, lenByte(stamp.ProviderName)) bin = append(bin, []uint8(stamp.ProviderName)...) - bin = append(bin, uint8(len(stamp.Path))) + bin = append(bin, lenByte(stamp.Path)) bin = append(bin, []uint8(stamp.Path)...) str := base64.RawURLEncoding.EncodeToString(bin) @@ -385,7 +400,7 @@ func (stamp *ServerStamp) dotOrDoqString(stampType StampProtoType, defaultPort u if strings.HasSuffix(serverAddrStr, ":"+strconv.Itoa(int(defaultPort))) { serverAddrStr = serverAddrStr[:len(serverAddrStr)-1-len(strconv.Itoa(int(defaultPort)))] } - bin = append(bin, uint8(len(serverAddrStr))) + bin = append(bin, lenByte(serverAddrStr)) bin = append(bin, []uint8(serverAddrStr)...) if len(stamp.Hashes) == 0 { @@ -393,16 +408,18 @@ func (stamp *ServerStamp) dotOrDoqString(stampType StampProtoType, defaultPort u } else { last := len(stamp.Hashes) - 1 for i, hash := range stamp.Hashes { - vlen := len(hash) + // Low 7 bits carry the length (hashes are 32-byte SHA-256 digests), + // the high bit flags that another hash follows. + vlen := lenByte(hash) & 0x7f if i < last { vlen |= 0x80 } - bin = append(bin, uint8(vlen)) + bin = append(bin, vlen) bin = append(bin, hash...) } } - bin = append(bin, uint8(len(stamp.ProviderName))) + bin = append(bin, lenByte(stamp.ProviderName)) bin = append(bin, []uint8(stamp.ProviderName)...) str := base64.RawURLEncoding.EncodeToString(bin) @@ -418,7 +435,7 @@ func (stamp *ServerStamp) plainString() string { if strings.HasSuffix(serverAddrStr, ":"+strconv.Itoa(defaultPlainPort)) { serverAddrStr = serverAddrStr[:len(serverAddrStr)-1-len(strconv.Itoa(defaultPlainPort))] } - bin = append(bin, uint8(len(serverAddrStr))) + bin = append(bin, lenByte(serverAddrStr)) bin = append(bin, []uint8(serverAddrStr)...) str := base64.RawURLEncoding.EncodeToString(bin) diff --git a/libs/store/mongodb.go b/libs/store/mongodb.go index 3a58b8c9..3a285f8c 100644 --- a/libs/store/mongodb.go +++ b/libs/store/mongodb.go @@ -130,7 +130,7 @@ func (db *MongoDB) connect() error { tlsOpts := &tls.Config{ Certificates: []tls.Certificate{cert}, RootCAs: caCertPool, - InsecureSkipVerify: db.Config.TLSInsecureSkipVerify, + InsecureSkipVerify: db.Config.TLSInsecureSkipVerify, //nolint:gosec // operator opt-in for dev/test stacks only; false in production config } clientOpts.SetTLSConfig(tlsOpts) diff --git a/libs/store/store.go b/libs/store/store.go index ad6813b3..87636b06 100644 --- a/libs/store/store.go +++ b/libs/store/store.go @@ -19,8 +19,7 @@ type Store interface { // NewStore creates a new Db instance func New(dbType string, dbConfig *Config) (Store, error) { - switch dbType { - case DbTypeMongoDb: + if dbType == DbTypeMongoDb { return NewMongoDB(dbConfig) } return nil, errors.New("unknown db type") From b8ab470dc2d7e9c279cfba43373fb837f88f1bab Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 21 Sep 2026 12:17:34 +0200 Subject: [PATCH 2/6] chore(dnscheck): Drop the unused GeoLite2-City database from dev and test stacks Signed-off-by: Maciek --- .gitignore | 1 - README.md | 4 ++-- compose.dnscheck.yml | 1 - tests/bootstrap/geolite/GeoLite2-City.mmdb | Bin 833 -> 0 bytes tests/bootstrap/geolite/README.md | 6 +++--- tests/docker-compose.yml | 1 - tests/scripts/generate_stub_mmdb.py | 15 ++++++--------- 7 files changed, 11 insertions(+), 17 deletions(-) delete mode 100644 tests/bootstrap/geolite/GeoLite2-City.mmdb diff --git a/.gitignore b/.gitignore index aadc890f..56ee5625 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,5 @@ tests/docker_logs .nginx-validate.* /docs/ /dev/bootstrap/GeoLite2-ASN/ -/dev/bootstrap/GeoLite2-City/ /dev/bootstrap/monitoring/ /scripts/ diff --git a/README.md b/README.md index 3858dede..5737f158 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,8 @@ cp api/.env.sample api/.env cp proxy/.env.sample proxy/.env cp dnscheck/.env.sample dnscheck/.env -# 2. MaxMind GeoLite2 databases (mounted by the proxy and dnscheck) -# Place them under dev/bootstrap/GeoLite2-ASN/ and dev/bootstrap/GeoLite2-City/ +# 2. MaxMind GeoLite2-ASN database (mounted by the proxy and dnscheck) +# Place GeoLite2-ASN.mmdb under dev/bootstrap/GeoLite2-ASN/ ``` Then: diff --git a/compose.dnscheck.yml b/compose.dnscheck.yml index c1541053..391c3423 100644 --- a/compose.dnscheck.yml +++ b/compose.dnscheck.yml @@ -16,7 +16,6 @@ services: volumes: - ./dnscheck:/app - ./dev/bootstrap/GeoLite2-ASN/:/opt/dnscheck/GeoIP - - ./dev/bootstrap/GeoLite2-City/:/opt/dnscheck/GeoIPCity # - ./dev/certs:/certs env_file: - ./dnscheck/.env diff --git a/tests/bootstrap/geolite/GeoLite2-City.mmdb b/tests/bootstrap/geolite/GeoLite2-City.mmdb deleted file mode 100644 index 5ca3951885eafe30746f964bb2dc4e2e9c6ed1eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 833 zcmZXQNpI6Y7>3`3u^ScPI3Y`nO9B$@5#>)&l1n&>1Prhn93^`fQ#gph||QMlhqMGU|@B_F; zRNEU=znQ1Az+CRPNNxjn^2%MHoO=Z@a-Rpx+^ZyN_`lZ!mPrwdNMxy>_EX6tKhB~y zPX@E)<--4=NV+TzSD6e&T(vyUvdym=v@vZ$n;T3%Uvg?j(`$LvMcwe44c%$Edby=n zY|p3<`)t+c4T>M{l^YG))J@l_xu)5yxz;e1Dt$Vb8F38TyFXvG%T3cY7l+9UmAriW z>AS<8Ibj@3bwu=SAr=AmJ0gqaJFA}Xjz|JO4OjWv=t9`@pYSAAL#~yafJxS7DfeZ+ z$8C!XJCuA{&E?isSlrE6m!~K#wZoVtegC0IA}0NaJr>EYON#%dVf;INQ*USCO5pRJ q=sYSF!w;{<>j6(YN!S~H)0Ya5mkTYKwY5Lr*3?6qQPU((W%?5cjIYQ5 diff --git a/tests/bootstrap/geolite/README.md b/tests/bootstrap/geolite/README.md index 9b93c2f0..1c1f4d14 100644 --- a/tests/bootstrap/geolite/README.md +++ b/tests/bootstrap/geolite/README.md @@ -1,6 +1,6 @@ -These are stub .mmdb files for backend E2E tests, NOT full GeoLite2 databases. +This is a stub GeoLite2-ASN .mmdb for backend E2E tests, NOT the full GeoLite2 database. -They contain only two entries (AS15169 Google, AS13335 Cloudflare). -City lookups return empty records but won't crash. +It contains only the networks the tests query (Google, Cloudflare, Apple, Microsoft ASNs). +The proxy and dnscheck read the ASN edition only; no City database is mounted. Regenerate: `cd tests && python3 scripts/generate_stub_mmdb.py` diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 2b0e5569..28d409d0 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -72,7 +72,6 @@ services: volumes: - ../dnscheck:/app - ./bootstrap/geolite/:/opt/dnscheck/GeoIP - - ./bootstrap/geolite/:/opt/dnscheck/GeoIPCity env_file: - ./config/dnscheck.env diff --git a/tests/scripts/generate_stub_mmdb.py b/tests/scripts/generate_stub_mmdb.py index 58a1bc32..1a71534a 100644 --- a/tests/scripts/generate_stub_mmdb.py +++ b/tests/scripts/generate_stub_mmdb.py @@ -18,12 +18,11 @@ Usage: python scripts/generate_stub_mmdb.py - Writes the backend E2E stubs to bootstrap/geolite/ (both files carry - the ASN payload; the "City" file is a copy so mounts never fail). + Writes the backend E2E ASN stub to bootstrap/geolite/. python scripts/generate_stub_mmdb.py --out-dir ../dnscheck/internal/maxmind/testdata --city-typed - Writes the dnscheck unit-test fixtures. --city-typed makes the City - file a real GeoLite2-City database so a wrong-type file can be tested. + Writes the dnscheck unit-test fixtures. --city-typed additionally + writes a real GeoLite2-City database so a wrong-type file can be tested. """ import argparse @@ -37,7 +36,7 @@ parser.add_argument( "--city-typed", action="store_true", - help="write GeoLite2-City.mmdb with database_type GeoLite2-City instead of copying the ASN stub", + help="also write GeoLite2-City.mmdb with database_type GeoLite2-City (dnscheck wrong-type fixture)", ) args = parser.parse_args() os.makedirs(args.out_dir, exist_ok=True) @@ -75,8 +74,8 @@ writer.to_db_file(out_asn) print(f"Wrote {out_asn}") -out_city = os.path.join(args.out_dir, "GeoLite2-City.mmdb") if args.city_typed: + out_city = os.path.join(args.out_dir, "GeoLite2-City.mmdb") city_writer = MMDBWriter( ip_version=4, database_type="GeoLite2-City", @@ -87,6 +86,4 @@ {"country": {"iso_code": "US", "names": {"en": "United States"}}}, ) city_writer.to_db_file(out_city) -else: - writer.to_db_file(out_city) -print(f"Wrote {out_city}") + print(f"Wrote {out_city}") From 5c7808b2333703b58b2f7e30315064aecb42b76e Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 21 Sep 2026 12:17:50 +0200 Subject: [PATCH 3/6] feat(libs): Add geoipdb, a GeoLite2-ASN reader that reloads a replaced file Signed-off-by: Maciek --- libs/geoipdb/reader.go | 209 ++++++++++++ libs/geoipdb/reader_test.go | 298 ++++++++++++++++++ libs/geoipdb/testdata/GeoLite2-ASN.mmdb | Bin 0 -> 833 bytes libs/geoipdb/testdata/GeoLite2-ASN.newer.mmdb | Bin 0 -> 833 bytes libs/geoipdb/testdata/GeoLite2-City.mmdb | Bin 0 -> 493 bytes libs/go.mod | 2 + libs/go.sum | 4 + 7 files changed, 513 insertions(+) create mode 100644 libs/geoipdb/reader.go create mode 100644 libs/geoipdb/reader_test.go create mode 100644 libs/geoipdb/testdata/GeoLite2-ASN.mmdb create mode 100644 libs/geoipdb/testdata/GeoLite2-ASN.newer.mmdb create mode 100644 libs/geoipdb/testdata/GeoLite2-City.mmdb diff --git a/libs/geoipdb/reader.go b/libs/geoipdb/reader.go new file mode 100644 index 00000000..a062e223 --- /dev/null +++ b/libs/geoipdb/reader.go @@ -0,0 +1,209 @@ +// Package geoipdb serves ASN lookups from a MaxMind database file and reopens +// the file when it is replaced on disk, so a refreshed GeoLite2 build is used +// without restarting the process. +package geoipdb + +import ( + "context" + "errors" + "fmt" + "math" + "net" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/oschwald/geoip2-golang" + "github.com/rs/zerolog/log" +) + +// DefaultReloadInterval is how often Watch checks the file when the caller +// passes a non-positive interval. +const DefaultReloadInterval = 15 * time.Minute + +// Stats describes the database currently being served. +type Stats struct { + BuildTime time.Time // build_epoch recorded in the database metadata + LoadedAt time.Time // when the current file was opened + Reloads uint64 // successful swaps since Open + Failures uint64 // replacements rejected since Open + LastError string // most recent reload error; empty after a success +} + +// fileSig is what Reload compares to decide whether the file was replaced. +// geoipupdate writes a temporary file and renames it over the target, which +// always changes the modification time. +type fileSig struct { + size int64 + modTime time.Time +} + +func (s fileSig) equal(o fileSig) bool { + return s.size == o.size && s.modTime.Equal(o.modTime) +} + +// Reader is safe for concurrent use and lock-free on the lookup path: the +// current database is an atomic pointer, and a swap simply publishes a new +// one. The previous database is never closed explicitly; lookups still +// holding it finish and the garbage collector reclaims it. +// +// The file is read into memory rather than mmapped. An mmap follows whatever +// a writer later does to the same inode, so an in-place overwrite would +// corrupt the database mid-lookup; a private copy is immune to that, and +// GeoLite2-ASN is small enough (~11 MB) for the copy to be free. It also +// means dropping a database releases nothing but heap. +type Reader struct { + path string + cur atomic.Pointer[geoip2.Reader] + + mu sync.Mutex // guards sig and stats; serialises Reload callers + sig fileSig + stats Stats +} + +// Open opens an ASN-capable database. A missing, unreadable, corrupt or +// wrong-edition file is an error. +func Open(path string) (*Reader, error) { + if path == "" { + return nil, errors.New("geoip database path is required") + } + db, sig, err := openFile(path) + if err != nil { + return nil, err + } + r := &Reader{ + path: path, + sig: sig, + stats: Stats{BuildTime: buildTime(db), LoadedAt: time.Now()}, + } + r.cur.Store(db) + return r, nil +} + +func openFile(path string) (*geoip2.Reader, fileSig, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fileSig{}, err + } + if !info.Mode().IsRegular() { + return nil, fileSig{}, fmt.Errorf("%s is not a regular file", path) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fileSig{}, err + } + db, err := geoip2.FromBytes(data) + if err != nil { + return nil, fileSig{}, fmt.Errorf("open %s: %w", path, err) + } + // geoip2 reports an edition/method mismatch only at lookup time, so probe + // once here rather than on the first real query. + if _, err := db.ASN(net.IPv4(192, 0, 2, 1)); err != nil { + return nil, fileSig{}, fmt.Errorf("%s does not support ASN lookups: %w", path, err) + } + return db, fileSig{size: info.Size(), modTime: info.ModTime()}, nil +} + +func buildTime(db *geoip2.Reader) time.Time { + epoch := db.Metadata().BuildEpoch + if epoch > math.MaxInt64 { + epoch = math.MaxInt64 + } + return time.Unix(int64(epoch), 0).UTC() +} + +// Path returns the file the reader watches. +func (r *Reader) Path() string { + return r.path +} + +// ASN looks up ip in the database currently loaded. An address outside the +// database yields an empty record and no error. +func (r *Reader) ASN(ip net.IP) (*geoip2.ASN, error) { + db := r.cur.Load() + if db == nil { + return nil, errors.New("geoip database is closed") + } + return db.ASN(ip) +} + +// Stats returns a snapshot of the reader state. +func (r *Reader) Stats() Stats { + r.mu.Lock() + defer r.mu.Unlock() + return r.stats +} + +// Reload reopens the file if its size or modification time changed since it +// was last loaded. It reports whether a new database is now being served. A +// replacement that cannot be opened or is not an ASN database is rejected and +// the previous database keeps serving. +func (r *Reader) Reload() (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.cur.Load() == nil { + return false, errors.New("geoip database is closed") + } + + info, err := os.Stat(r.path) + if err != nil { + return false, r.failLocked(err) + } + if r.sig.equal(fileSig{size: info.Size(), modTime: info.ModTime()}) { + return false, nil + } + + db, sig, err := openFile(r.path) + if err != nil { + return false, r.failLocked(err) + } + + r.cur.Store(db) + r.sig = sig + r.stats.BuildTime = buildTime(db) + r.stats.LoadedAt = time.Now() + r.stats.Reloads++ + r.stats.LastError = "" + + log.Info().Str("path", r.path).Time("build_time", r.stats.BuildTime).Msg("GeoIP database reloaded") + return true, nil +} + +// failLocked records a rejected replacement; r.mu must be held. +func (r *Reader) failLocked(err error) error { + r.stats.Failures++ + r.stats.LastError = err.Error() + log.Error().Err(err).Str("path", r.path).Msg("GeoIP database reload rejected; previous database kept") + return err +} + +// Watch calls Reload every interval until ctx is cancelled. +func (r *Reader) Watch(ctx context.Context, every time.Duration) { + if r == nil { + return + } + if every <= 0 { + every = DefaultReloadInterval + } + ticker := time.NewTicker(every) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _, _ = r.Reload() + } + } +} + +// Close stops serving lookups; later calls to ASN return an error. The +// in-memory database holds no file mapping, so there is nothing else to +// release, and it is not closed explicitly because a lookup may still be +// using it. +func (r *Reader) Close() error { + r.cur.Store(nil) + return nil +} diff --git a/libs/geoipdb/reader_test.go b/libs/geoipdb/reader_test.go new file mode 100644 index 00000000..9ba9d2fe --- /dev/null +++ b/libs/geoipdb/reader_test.go @@ -0,0 +1,298 @@ +package geoipdb + +import ( + "context" + "net" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/oschwald/geoip2-golang" +) + +const ( + asnFixture = "testdata/GeoLite2-ASN.mmdb" // build 2026-09-04 + newerFixture = "testdata/GeoLite2-ASN.newer.mmdb" // same networks, build 2026-09-18 + cityFixture = "testdata/GeoLite2-City.mmdb" +) + +// installFixture copies src to dst with a stable mtime so a later replacement +// always changes the on-disk signature the reloader compares. +func installFixture(t *testing.T, src, dst string, mtime time.Time) { + t.Helper() + data, err := os.ReadFile(src) + if err != nil { + t.Fatalf("read %s: %v", src, err) + } + // Same write-then-rename sequence geoipupdate uses. + tmp := dst + ".temporary" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(tmp, mtime, mtime); err != nil { + t.Fatal(err) + } + if err := os.Rename(tmp, dst); err != nil { + t.Fatal(err) + } +} + +func openTemp(t *testing.T) (*Reader, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "GeoLite2-ASN.mmdb") + installFixture(t, asnFixture, path, time.Now().Add(-time.Hour)) + r, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = r.Close() }) + return r, path +} + +func mustASN(t *testing.T, r *Reader, ip string) *geoip2.ASN { + t.Helper() + rec, err := r.ASN(net.ParseIP(ip)) + if err != nil { + t.Fatalf("lookup %s: %v", ip, err) + } + return rec +} + +func TestOpenRejectsMissingFile(t *testing.T) { + if _, err := Open(filepath.Join(t.TempDir(), "missing.mmdb")); err == nil { + t.Fatal("expected an error for a missing file") + } +} + +func TestOpenRejectsEmptyPath(t *testing.T) { + if _, err := Open(""); err == nil { + t.Fatal("expected an error for an empty path") + } +} + +func TestOpenRejectsNonASNDatabase(t *testing.T) { + if _, err := Open(cityFixture); err == nil { + t.Fatal("expected an error when the path points at a City database") + } +} + +func TestASNReturnsRecordAndBuildTime(t *testing.T) { + r, _ := openTemp(t) + + rec := mustASN(t, r, "8.8.8.8") + if rec.AutonomousSystemNumber != 15169 { + t.Errorf("got ASN %d, want 15169", rec.AutonomousSystemNumber) + } + if got := r.Stats().BuildTime.UTC().Format("2006-01-02"); got != "2026-09-04" { + t.Errorf("got build date %s, want 2026-09-04", got) + } + if rec := mustASN(t, r, "203.0.113.5"); rec.AutonomousSystemNumber != 0 { + t.Errorf("unknown IP yielded ASN %d, want 0", rec.AutonomousSystemNumber) + } +} + +func TestReloadWithoutChangeIsNoop(t *testing.T) { + r, _ := openTemp(t) + + changed, err := r.Reload() + if err != nil { + t.Fatalf("reload: %v", err) + } + if changed { + t.Fatal("reload reported a change for an untouched file") + } + if s := r.Stats(); s.Reloads != 0 || s.Failures != 0 { + t.Errorf("stats after noop: %+v", s) + } +} + +func TestReloadPicksUpReplacedFile(t *testing.T) { + r, path := openTemp(t) + installFixture(t, newerFixture, path, time.Now()) + + changed, err := r.Reload() + if err != nil { + t.Fatalf("reload: %v", err) + } + if !changed { + t.Fatal("reload did not notice the replaced file") + } + s := r.Stats() + if got := s.BuildTime.UTC().Format("2006-01-02"); got != "2026-09-18" { + t.Errorf("got build date %s after reload, want 2026-09-18", got) + } + if s.Reloads != 1 || s.Failures != 0 || s.LastError != "" { + t.Errorf("stats after reload: %+v", s) + } + if rec := mustASN(t, r, "8.8.8.8"); rec.AutonomousSystemNumber != 15169 { + t.Errorf("lookup after reload: got ASN %d, want 15169", rec.AutonomousSystemNumber) + } +} + +// The overwrite here is deliberately in place (no rename): the loaded copy +// must not depend on the file's inode staying intact. +func TestReloadKeepsServingWhenReplacementIsCorrupt(t *testing.T) { + r, path := openTemp(t) + if err := os.WriteFile(path, []byte("not an mmdb"), 0o644); err != nil { + t.Fatal(err) + } + + changed, err := r.Reload() + if err == nil { + t.Fatal("expected an error for a corrupt replacement") + } + if changed { + t.Fatal("a failed reload must not report a change") + } + s := r.Stats() + if got := s.BuildTime.UTC().Format("2006-01-02"); got != "2026-09-04" { + t.Errorf("build date changed to %s after a failed reload", got) + } + if s.Failures != 1 || s.LastError == "" { + t.Errorf("stats after failed reload: %+v", s) + } + if rec := mustASN(t, r, "8.8.8.8"); rec.AutonomousSystemNumber != 15169 { + t.Errorf("old database not served after failed reload: ASN %d", rec.AutonomousSystemNumber) + } +} + +func TestReloadKeepsServingWhenReplacementIsWrongType(t *testing.T) { + r, path := openTemp(t) + installFixture(t, cityFixture, path, time.Now()) + + if _, err := r.Reload(); err == nil { + t.Fatal("expected an error for a City database on the ASN path") + } + if rec := mustASN(t, r, "8.8.8.8"); rec.AutonomousSystemNumber != 15169 { + t.Errorf("old database not served after wrong-type reload: ASN %d", rec.AutonomousSystemNumber) + } +} + +func TestReloadKeepsServingWhenFileVanishes(t *testing.T) { + r, path := openTemp(t) + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + + if _, err := r.Reload(); err == nil { + t.Fatal("expected an error when the file is gone") + } + if rec := mustASN(t, r, "8.8.8.8"); rec.AutonomousSystemNumber != 15169 { + t.Errorf("old database not served after the file vanished: ASN %d", rec.AutonomousSystemNumber) + } +} + +// Lookups in flight while the file is swapped must never touch an unmapped +// reader. Run with -race. +func TestReloadIsSafeUnderConcurrentLookups(t *testing.T) { + r, path := openTemp(t) + + stop := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + if rec, err := r.ASN(net.ParseIP("8.8.8.8")); err != nil || rec.AutonomousSystemNumber != 15169 { + t.Errorf("lookup during reload: rec=%+v err=%v", rec, err) + return + } + } + } + }() + } + for i := 0; i < 20; i++ { + src := asnFixture + if i%2 == 0 { + src = newerFixture + } + installFixture(t, src, path, time.Now().Add(time.Duration(i)*time.Second)) + if _, err := r.Reload(); err != nil { + t.Fatalf("reload %d: %v", i, err) + } + } + close(stop) + wg.Wait() +} + +func TestWatchReloadsOnInterval(t *testing.T) { + r, path := openTemp(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { + r.Watch(ctx, 10*time.Millisecond) + close(done) + }() + + installFixture(t, newerFixture, path, time.Now()) + deadline := time.Now().Add(5 * time.Second) + for r.Stats().Reloads == 0 { + if time.Now().After(deadline) { + t.Fatal("watcher did not reload the replaced file") + } + time.Sleep(5 * time.Millisecond) + } + if got := r.Stats().BuildTime.UTC().Format("2006-01-02"); got != "2026-09-18" { + t.Errorf("got build date %s after watched reload, want 2026-09-18", got) + } + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("watcher did not stop on context cancel") + } +} + +func TestCloseThenASNReturnsError(t *testing.T) { + r, _ := openTemp(t) + if err := r.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if _, err := r.ASN(net.ParseIP("8.8.8.8")); err == nil { + t.Fatal("expected an error from a closed reader") + } +} + +func BenchmarkASN(b *testing.B) { + r, err := Open(asnFixture) + if err != nil { + b.Fatal(err) + } + defer r.Close() + ip := net.ParseIP("8.8.8.8") + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if _, err := r.ASN(ip); err != nil { + b.Fatal(err) + } + } + }) +} + +// Baseline: the bare geoip2 reader without the reload guard. +func BenchmarkASNRawReader(b *testing.B) { + db, err := geoip2.Open(asnFixture) + if err != nil { + b.Fatal(err) + } + defer db.Close() + ip := net.ParseIP("8.8.8.8") + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if _, err := db.ASN(ip); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/libs/geoipdb/testdata/GeoLite2-ASN.mmdb b/libs/geoipdb/testdata/GeoLite2-ASN.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..9e2b2d5f87ac8debfbc7363f6d2798f69c928f8c GIT binary patch literal 833 zcmZXQNpI6Y7>3`3uT{fN8C?5Ks-o1L{$BUfg|KY#9`tH@hI^aag?a~j{_&j$B1jh zlX-dyIGy_$lC!|MymB7+NPdxciFi3rKa=RdcwV^*TqD0uoFLvHP7-et)l|2DAHZ#* z+TNx5y*!-)rgOhf@&K60D-VHk?iIkueHJituacXx%48_ws^xi>ZGP3DjcB9VbbsvCf>Se^UdyY_>xS2C=uXSk%PqZP zdq#cGXRAK1U;Ol_+-TUQZn{>@HO*$twFaqF>5Km4kYm{13`3uO_CwFsy&fC2GYO44T%dU znk(YMKY$|={{gsh;?9Z5Cz*8)kDmG7Z)QFXPylWKMc@Lk4zPg|aE4?(umRA3jkMiF z+)Ug;+)CU=R0p>MJIHqucM*3J_Yn6I_Yqb9e&7K42yv7+Mm$J7L>woo{=>i#@(JQA z;?X=k1{}}*1j$L@R9-m^d>}tdJV!jAr=Ll5U^1^<1TK+ZCQcEr5T}V(iE65Azz^U$ zQEhKh{Z^jN0JFK@CbN);b=_@xy3y3j zj&Ih6eU9q$2gQ%~je6a&bj!1=o@F(vo;^&ZN}mpBTfY=tpP`oSZSL`((|TNcT$ON#%dVf;INU2A3GN+;kw q(SBSiF26P2)jB+FCt+{+O<&49UMe(Y*3$lbTT>5dW>u3smFZ6p;IHuj literal 0 HcmV?d00001 diff --git a/libs/geoipdb/testdata/GeoLite2-City.mmdb b/libs/geoipdb/testdata/GeoLite2-City.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..67c4e79309f8abfbea1f83101a48b785caa41aba GIT binary patch literal 493 zcmZ9{%}xR_5C`xPSH*8pR7BCa8;$W1Fa`zpL^v7KbayM3u`Bf(g%7|( z;LVe-U^52|_VCN3o&2W*Fav-9C32PwNPjs8Fi-UYStb|BC32ZuA$|WUz#7%-cM87o-D;|T(uy^p^`{uD};oW?r+YAYY$Q+(nFc8rL%Ntl(- zVpMC(j6DitrBhJZf6XkN4Fp=r_At()AU&ft8*x1O-`7ccTa(6-3 Date: Mon, 21 Sep 2026 12:18:18 +0200 Subject: [PATCH 4/6] feat(proxy): Reload the ASN database in place and add GEOIP_DB_RELOAD Signed-off-by: Maciek --- proxy/config/config.go | 18 ++- proxy/go.mod | 2 +- proxy/internal/asnlookup/lookup.go | 37 +++++- proxy/internal/asnlookup/lookup_test.go | 116 ++++++++++++++++++ .../asnlookup/testdata/GeoLite2-ASN.mmdb | Bin 0 -> 833 bytes .../testdata/GeoLite2-ASN.newer.mmdb | Bin 0 -> 833 bytes proxy/server/server.go | 9 +- 7 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 proxy/internal/asnlookup/lookup_test.go create mode 100644 proxy/internal/asnlookup/testdata/GeoLite2-ASN.mmdb create mode 100644 proxy/internal/asnlookup/testdata/GeoLite2-ASN.newer.mmdb diff --git a/proxy/config/config.go b/proxy/config/config.go index 495aa507..1e27a334 100644 --- a/proxy/config/config.go +++ b/proxy/config/config.go @@ -126,6 +126,9 @@ type ServicesConfig struct { CatalogPath string CatalogReloadEvery time.Duration GeoIPASNDBPath string + // GEOIP_DB_RELOAD: how often the ASN database file is checked for a + // refreshed build and reopened in place. + GeoIPASNDBReloadEvery time.Duration } // FilteringConfig holds global filter master switches. These are operator-level @@ -397,6 +400,14 @@ func New() (*Config, error) { } geoIPASNDBPath := strings.TrimSpace(os.Getenv("GEOIP_DB_ASN_FILE")) + geoIPASNDBReloadEveryStr := strings.TrimSpace(os.Getenv("GEOIP_DB_RELOAD")) + if geoIPASNDBReloadEveryStr == "" { + geoIPASNDBReloadEveryStr = "15m" + } + geoIPASNDBReloadEvery, err := time.ParseDuration(geoIPASNDBReloadEveryStr) + if err != nil || geoIPASNDBReloadEvery <= 0 { + return nil, fmt.Errorf("GEOIP_DB_RELOAD must be a positive duration, got %q", geoIPASNDBReloadEveryStr) + } cacheAddrs := strings.Split(os.Getenv("CACHE_ADDRESSES"), ",") @@ -412,9 +423,10 @@ func New() (*Config, error) { MaxGoroutines: loadMaxGoroutines(), }, Services: &ServicesConfig{ - CatalogPath: servicesCatalogPath, - CatalogReloadEvery: servicesCatalogReloadEvery, - GeoIPASNDBPath: geoIPASNDBPath, + CatalogPath: servicesCatalogPath, + CatalogReloadEvery: servicesCatalogReloadEvery, + GeoIPASNDBPath: geoIPASNDBPath, + GeoIPASNDBReloadEvery: geoIPASNDBReloadEvery, }, Filtering: &FilteringConfig{ CNAMEUncloakingEnabled: getEnvBoolDefault("CNAME_UNCLOAKING_ENABLED", true), diff --git a/proxy/go.mod b/proxy/go.mod index aee5bf9c..9543b0ee 100644 --- a/proxy/go.mod +++ b/proxy/go.mod @@ -10,7 +10,6 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/ivpn/dns/libs v0.0.0 github.com/miekg/dns v1.1.72 - github.com/oschwald/geoip2-golang v1.13.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/quic-go/quic-go v0.60.0 @@ -70,6 +69,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/oschwald/geoip2-golang v1.13.0 // indirect github.com/oschwald/maxminddb-golang v1.13.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect diff --git a/proxy/internal/asnlookup/lookup.go b/proxy/internal/asnlookup/lookup.go index de8bf056..4e0e25fb 100644 --- a/proxy/internal/asnlookup/lookup.go +++ b/proxy/internal/asnlookup/lookup.go @@ -1,22 +1,26 @@ package asnlookup import ( + "context" "errors" "fmt" "net" + "time" - "github.com/oschwald/geoip2-golang" + "github.com/ivpn/dns/libs/geoipdb" ) +// Lookup answers ASN queries from a GeoLite2-ASN file and follows the file +// when it is refreshed on disk. type Lookup struct { - db *geoip2.Reader + db *geoipdb.Reader } func New(mmdbPath string) (*Lookup, error) { if mmdbPath == "" { return nil, errors.New("ASN MMDB path is required") } - db, err := geoip2.Open(mmdbPath) + db, err := geoipdb.Open(mmdbPath) if err != nil { return nil, err } @@ -40,6 +44,33 @@ func (l *Lookup) ASN(ip net.IP) (uint, error) { return rec.AutonomousSystemNumber, nil } +// Reload checks the file now and reopens it if it changed; see +// geoipdb.Reader.Reload. Production relies on Watch, which runs the same check +// on a timer; Reload is the synchronous entry point for tests and for a manual +// "reload now" trigger. +func (l *Lookup) Reload() (bool, error) { + if l == nil || l.db == nil { + return false, nil + } + return l.db.Reload() +} + +// Watch reloads the database on a timer until ctx is cancelled. +func (l *Lookup) Watch(ctx context.Context, every time.Duration) { + if l == nil || l.db == nil { + return + } + l.db.Watch(ctx, every) +} + +// Stats reports the build time and reload counters of the loaded database. +func (l *Lookup) Stats() geoipdb.Stats { + if l == nil || l.db == nil { + return geoipdb.Stats{} + } + return l.db.Stats() +} + func (l *Lookup) Close() error { if l == nil || l.db == nil { return nil diff --git a/proxy/internal/asnlookup/lookup_test.go b/proxy/internal/asnlookup/lookup_test.go new file mode 100644 index 00000000..a5579bc4 --- /dev/null +++ b/proxy/internal/asnlookup/lookup_test.go @@ -0,0 +1,116 @@ +package asnlookup + +import ( + "net" + "os" + "path/filepath" + "testing" + "time" +) + +const ( + asnFixture = "testdata/GeoLite2-ASN.mmdb" // build 2026-09-04 + newerFixture = "testdata/GeoLite2-ASN.newer.mmdb" // same networks, build 2026-09-18 +) + +func install(t *testing.T, src, dst string, mtime time.Time) { + t.Helper() + data, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + tmp := dst + ".temporary" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(tmp, mtime, mtime); err != nil { + t.Fatal(err) + } + if err := os.Rename(tmp, dst); err != nil { + t.Fatal(err) + } +} + +// specRef: proxy-filtering-behaviour.md #GEO1 +func TestNewRequiresPath(t *testing.T) { + if _, err := New(""); err == nil { + t.Fatal("expected an error for an empty path") + } +} + +func TestASNReturnsNumberAndZeroForUnknownOrNil(t *testing.T) { + l, err := New(asnFixture) + if err != nil { + t.Fatalf("open: %v", err) + } + defer l.Close() + + if asn, err := l.ASN(net.ParseIP("8.8.8.8")); err != nil || asn != 15169 { + t.Errorf("got asn=%d err=%v, want 15169", asn, err) + } + if asn, err := l.ASN(net.ParseIP("203.0.113.5")); err != nil || asn != 0 { + t.Errorf("unknown IP: got asn=%d err=%v, want 0", asn, err) + } + if asn, err := l.ASN(nil); err != nil || asn != 0 { + t.Errorf("nil IP: got asn=%d err=%v, want 0", asn, err) + } + var none *Lookup + if asn, err := none.ASN(net.ParseIP("8.8.8.8")); err != nil || asn != 0 { + t.Errorf("nil lookup: got asn=%d err=%v, want 0", asn, err) + } +} + +// A database refreshed on disk (write + rename, as geoipupdate does) is served +// after the next reload without reopening the Lookup. +// +// specRef: proxy-filtering-behaviour.md #GEO2 +func TestReloadServesReplacedDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "GeoLite2-ASN.mmdb") + install(t, asnFixture, path, time.Now().Add(-time.Hour)) + l, err := New(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer l.Close() + if got := l.Stats().BuildTime.UTC().Format("2006-01-02"); got != "2026-09-04" { + t.Fatalf("initial build date %s, want 2026-09-04", got) + } + + install(t, newerFixture, path, time.Now()) + changed, err := l.Reload() + if err != nil || !changed { + t.Fatalf("reload: changed=%v err=%v", changed, err) + } + if got := l.Stats().BuildTime.UTC().Format("2006-01-02"); got != "2026-09-18" { + t.Errorf("build date after reload %s, want 2026-09-18", got) + } + if asn, err := l.ASN(net.ParseIP("8.8.8.8")); err != nil || asn != 15169 { + t.Errorf("lookup after reload: asn=%d err=%v", asn, err) + } +} + +// A corrupt replacement is rejected and the loaded database keeps serving. +// +// specRef: proxy-filtering-behaviour.md #GEO3 +func TestReloadKeepsOldDatabaseWhenReplacementIsCorrupt(t *testing.T) { + path := filepath.Join(t.TempDir(), "GeoLite2-ASN.mmdb") + install(t, asnFixture, path, time.Now().Add(-time.Hour)) + l, err := New(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer l.Close() + + if err := os.WriteFile(path, []byte("not an mmdb"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := l.Reload(); err == nil { + t.Fatal("expected an error for a corrupt replacement") + } + if s := l.Stats(); s.Failures != 1 || s.LastError == "" { + t.Errorf("stats after failed reload: %+v", s) + } + if asn, err := l.ASN(net.ParseIP("8.8.8.8")); err != nil || asn != 15169 { + t.Errorf("old database not served after failed reload: asn=%d err=%v", asn, err) + } +} diff --git a/proxy/internal/asnlookup/testdata/GeoLite2-ASN.mmdb b/proxy/internal/asnlookup/testdata/GeoLite2-ASN.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..9e2b2d5f87ac8debfbc7363f6d2798f69c928f8c GIT binary patch literal 833 zcmZXQNpI6Y7>3`3uT{fN8C?5Ks-o1L{$BUfg|KY#9`tH@hI^aag?a~j{_&j$B1jh zlX-dyIGy_$lC!|MymB7+NPdxciFi3rKa=RdcwV^*TqD0uoFLvHP7-et)l|2DAHZ#* z+TNx5y*!-)rgOhf@&K60D-VHk?iIkueHJituacXx%48_ws^xi>ZGP3DjcB9VbbsvCf>Se^UdyY_>xS2C=uXSk%PqZP zdq#cGXRAK1U;Ol_+-TUQZn{>@HO*$twFaqF>5Km4kYm{13`3uO_CwFsy&fC2GYO44T%dU znk(YMKY$|={{gsh;?9Z5Cz*8)kDmG7Z)QFXPylWKMc@Lk4zPg|aE4?(umRA3jkMiF z+)Ug;+)CU=R0p>MJIHqucM*3J_Yn6I_Yqb9e&7K42yv7+Mm$J7L>woo{=>i#@(JQA z;?X=k1{}}*1j$L@R9-m^d>}tdJV!jAr=Ll5U^1^<1TK+ZCQcEr5T}V(iE65Azz^U$ zQEhKh{Z^jN0JFK@CbN);b=_@xy3y3j zj&Ih6eU9q$2gQ%~je6a&bj!1=o@F(vo;^&ZN}mpBTfY=tpP`oSZSL`((|TNcT$ON#%dVf;INU2A3GN+;kw q(SBSiF26P2)jB+FCt+{+O<&49UMe(Y*3$lbTT>5dW>u3smFZ6p;IHuj literal 0 HcmV?d00001 diff --git a/proxy/server/server.go b/proxy/server/server.go index 0a643039..43c7439a 100644 --- a/proxy/server/server.go +++ b/proxy/server/server.go @@ -123,7 +123,14 @@ func NewServer(serverConfig *config.Config, collectorChannels map[string]channel log.Error().Err(err).Str("path", serverConfig.Services.GeoIPASNDBPath).Msg("Failed to open ASN MMDB") return nil, fmt.Errorf("ASN lookup: %w", err) } - log.Info().Str("catalog", serverConfig.Services.CatalogPath).Str("geodb", serverConfig.Services.GeoIPASNDBPath).Msg("Services blocking enabled") + // The file is refreshed on disk by geoipupdate; follow it without a restart. + go lookup.Watch(context.Background(), serverConfig.Services.GeoIPASNDBReloadEvery) + log.Info(). + Str("catalog", serverConfig.Services.CatalogPath). + Str("geodb", serverConfig.Services.GeoIPASNDBPath). + Time("geodb_build", lookup.Stats().BuildTime). + Dur("geodb_reload", serverConfig.Services.GeoIPASNDBReloadEvery). + Msg("Services blocking enabled") domainFilter := filter.NewDomainFilter(dnsProxy, cache, servicesCatalog) domainFilter.Metrics = server.Metrics From 996f0c6d9a10f0b82736e3932663e1cf58ca3aff Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 21 Sep 2026 12:18:39 +0200 Subject: [PATCH 5/6] feat(dnscheck): Reload the ASN database in place and add GEOIP_DB_RELOAD Signed-off-by: Maciek --- dnscheck/config/config.go | 19 +++- dnscheck/config/config_test.go | 32 +++++++ dnscheck/dns/server.go | 9 ++ dnscheck/go.mod | 2 +- dnscheck/go.sum | 4 +- dnscheck/internal/maxmind/maxmind.go | 38 +++++--- dnscheck/internal/maxmind/maxmind_test.go | 89 +++++++++++++++++- .../maxmind/testdata/GeoLite2-ASN.newer.mmdb | Bin 0 -> 833 bytes 8 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 dnscheck/internal/maxmind/testdata/GeoLite2-ASN.newer.mmdb diff --git a/dnscheck/config/config.go b/dnscheck/config/config.go index 04f7cb85..82490f06 100644 --- a/dnscheck/config/config.go +++ b/dnscheck/config/config.go @@ -83,9 +83,14 @@ type CacheConfig struct { HMACKey string } +// DefaultGeoIPDBReload is how often the ASN database file is checked for a +// refreshed build when GEOIP_DB_RELOAD is unset. +const DefaultGeoIPDBReload = 15 * time.Minute + // GeoLookupConfig represents access to the MaxMind GeoIP ASN database type GeoLookupConfig struct { - DBASNFile string + DBASNFile string + ReloadEvery time.Duration } // IsValid check whether config section is valid @@ -123,8 +128,18 @@ func New() (*Config, error) { return nil, errors.New("CACHE_HMAC_KEY environment variable is required") } + geoReload := DefaultGeoIPDBReload + if raw := os.Getenv("GEOIP_DB_RELOAD"); raw != "" { + parsed, err := time.ParseDuration(raw) + if err != nil || parsed <= 0 { + return nil, fmt.Errorf("GEOIP_DB_RELOAD must be a positive duration, got %q", raw) + } + geoReload = parsed + } + geoLookup := &GeoLookupConfig{ - DBASNFile: os.Getenv("GEOIP_DB_ASN_FILE"), + DBASNFile: os.Getenv("GEOIP_DB_ASN_FILE"), + ReloadEvery: geoReload, } if err := geoLookup.IsValid(); err != nil { return nil, err diff --git a/dnscheck/config/config_test.go b/dnscheck/config/config_test.go index 57497b48..8f100d53 100644 --- a/dnscheck/config/config_test.go +++ b/dnscheck/config/config_test.go @@ -129,3 +129,35 @@ func TestNewCacheTTLDefaultsAndValidates(t *testing.T) { } } } + +// specRef: dnscheck-behaviour.md #S8 +func TestNewGeoIPReloadDefaultsAndValidates(t *testing.T) { + t.Setenv("CACHE_HMAC_KEY", "test-key") + t.Setenv("GEOIP_DB_ASN_FILE", "/opt/dnscheck/GeoLite2-ASN.mmdb") + t.Setenv("DNS_AUTH_SERVER_IP_RANGE", "10.5.0.0/16") + + t.Setenv("GEOIP_DB_RELOAD", "") + cfg, err := New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.GeoLookupConfig.ReloadEvery != DefaultGeoIPDBReload { + t.Errorf("ReloadEvery = %v, want default %v", cfg.GeoLookupConfig.ReloadEvery, DefaultGeoIPDBReload) + } + + t.Setenv("GEOIP_DB_RELOAD", "1h") + cfg, err = New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.GeoLookupConfig.ReloadEvery != time.Hour { + t.Errorf("ReloadEvery = %v, want 1h", cfg.GeoLookupConfig.ReloadEvery) + } + + for _, bad := range []string{"daily", "-15m", "0"} { + t.Setenv("GEOIP_DB_RELOAD", bad) + if _, err := New(); err == nil { + t.Errorf("expected an error for GEOIP_DB_RELOAD=%q", bad) + } + } +} diff --git a/dnscheck/dns/server.go b/dnscheck/dns/server.go index f361c1b8..62fbe22a 100644 --- a/dnscheck/dns/server.go +++ b/dnscheck/dns/server.go @@ -1,12 +1,14 @@ package dns import ( + "context" "fmt" "github.com/dnscheck/cache" "github.com/dnscheck/config" "github.com/dnscheck/internal/maxmind" "github.com/miekg/dns" + "github.com/rs/zerolog/log" ) // GeoLookuper resolves a client IP to its ASN record. @@ -37,6 +39,13 @@ func New(config *config.Config, cache cache.Cache) (*DNSServer, error) { return nil, fmt.Errorf("geoip: %w", err) } srv.GeoLookup = geoLookup + // The file is refreshed on disk by geoipupdate; follow it without a restart. + go geoLookup.Watch(context.Background(), config.GeoLookupConfig.ReloadEvery) + log.Info(). + Str("path", config.GeoLookupConfig.DBASNFile). + Time("build_time", geoLookup.Stats().BuildTime). + Dur("reload_every", config.GeoLookupConfig.ReloadEvery). + Msg("GeoIP ASN database loaded") // DNS srv.DNSTCP = &dns.Server{Addr: ":53", Net: "tcp"} diff --git a/dnscheck/go.mod b/dnscheck/go.mod index b4dceed5..038e8d87 100644 --- a/dnscheck/go.mod +++ b/dnscheck/go.mod @@ -6,7 +6,7 @@ require ( github.com/go-playground/validator/v10 v10.25.0 github.com/gofiber/fiber/v2 v2.52.12 github.com/miekg/dns v1.1.62 - github.com/oschwald/geoip2-golang v1.11.0 + github.com/oschwald/geoip2-golang v1.13.0 github.com/rs/zerolog v1.34.0 ) diff --git a/dnscheck/go.sum b/dnscheck/go.sum index c4f8871b..db6d7c5c 100644 --- a/dnscheck/go.sum +++ b/dnscheck/go.sum @@ -37,8 +37,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= -github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w= -github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI= +github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= diff --git a/dnscheck/internal/maxmind/maxmind.go b/dnscheck/internal/maxmind/maxmind.go index ad2b0fb1..aee78fc0 100644 --- a/dnscheck/internal/maxmind/maxmind.go +++ b/dnscheck/internal/maxmind/maxmind.go @@ -1,39 +1,53 @@ package maxmind import ( + "context" "fmt" "net" + "time" + "github.com/ivpn/dns/libs/geoipdb" "github.com/oschwald/geoip2-golang" ) // GeoLookupManager answers ASN lookups from a MaxMind database that is opened -// once and shared by every request; geoip2.Reader is safe for concurrent use. +// once, shared by every request, and reopened in place when the file on disk +// is refreshed. type GeoLookupManager struct { - asnDB *geoip2.Reader + db *geoipdb.Reader } // NewGeoLookupManager opens the ASN database and fails if the file is missing, // unreadable or not an ASN-capable database type. func NewGeoLookupManager(dbASNFile string) (*GeoLookupManager, error) { - asnDB, err := geoip2.Open(dbASNFile) + db, err := geoipdb.Open(dbASNFile) if err != nil { return nil, fmt.Errorf("cannot open geoip ASN database %q: %w", dbASNFile, err) } + return &GeoLookupManager{db: db}, nil +} - // geoip2 only reports a database/method mismatch at lookup time, so probe - // once here rather than on every request. - if _, err := asnDB.ASN(net.IPv4(192, 0, 2, 1)); err != nil { - asnDB.Close() - return nil, fmt.Errorf("geoip database %q does not support ASN lookups: %w", dbASNFile, err) - } +// Reload checks the file now and reopens it if it changed; a broken replacement +// is rejected and the loaded database keeps serving. Production relies on +// Watch, which runs the same check on a timer; Reload is the synchronous +// entry point for tests and for a manual "reload now" trigger. +func (g *GeoLookupManager) Reload() (bool, error) { + return g.db.Reload() +} + +// Watch reloads the database on a timer until ctx is cancelled. +func (g *GeoLookupManager) Watch(ctx context.Context, every time.Duration) { + g.db.Watch(ctx, every) +} - return &GeoLookupManager{asnDB: asnDB}, nil +// Stats reports the build time and reload counters of the loaded database. +func (g *GeoLookupManager) Stats() geoipdb.Stats { + return g.db.Stats() } // Close releases the underlying database. func (g *GeoLookupManager) Close() error { - return g.asnDB.Close() + return g.db.Close() } // GetGeoLookup returns the ASN record for ip. An address that is not in the @@ -44,7 +58,7 @@ func (g *GeoLookupManager) GetGeoLookup(ip string) (*GeoLookup, error) { return nil, fmt.Errorf("invalid IP address %q", ip) } - asn, err := g.asnDB.ASN(ipnet) + asn, err := g.db.ASN(ipnet) if err != nil { return nil, fmt.Errorf("cannot get ASN: %w", err) } diff --git a/dnscheck/internal/maxmind/maxmind_test.go b/dnscheck/internal/maxmind/maxmind_test.go index ac53c442..42a66f75 100644 --- a/dnscheck/internal/maxmind/maxmind_test.go +++ b/dnscheck/internal/maxmind/maxmind_test.go @@ -4,13 +4,98 @@ import ( "os" "path/filepath" "testing" + "time" ) const ( - asnFixture = "testdata/GeoLite2-ASN.mmdb" - cityFixture = "testdata/GeoLite2-City.mmdb" + asnFixture = "testdata/GeoLite2-ASN.mmdb" // build 2026-09-04 + newerFixture = "testdata/GeoLite2-ASN.newer.mmdb" // same networks, build 2026-09-18 + cityFixture = "testdata/GeoLite2-City.mmdb" ) +// installFixture mirrors geoipupdate: write a temporary file, then rename it +// over the target. +func installFixture(t *testing.T, src, dst string, mtime time.Time) { + t.Helper() + data, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + tmp := dst + ".temporary" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(tmp, mtime, mtime); err != nil { + t.Fatal(err) + } + if err := os.Rename(tmp, dst); err != nil { + t.Fatal(err) + } +} + +// A database refreshed on disk is served after the next reload; the process +// never has to restart. +// +// specRef: dnscheck-behaviour.md #S6 +func TestGeoLookupManagerReloadsReplacedDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "GeoLite2-ASN.mmdb") + installFixture(t, asnFixture, path, time.Now().Add(-time.Hour)) + g, err := NewGeoLookupManager(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer g.Close() + if got := g.Stats().BuildTime.UTC().Format("2006-01-02"); got != "2026-09-04" { + t.Fatalf("initial build date %s, want 2026-09-04", got) + } + + installFixture(t, newerFixture, path, time.Now()) + changed, err := g.Reload() + if err != nil || !changed { + t.Fatalf("reload: changed=%v err=%v", changed, err) + } + if got := g.Stats().BuildTime.UTC().Format("2006-01-02"); got != "2026-09-18" { + t.Errorf("build date after reload %s, want 2026-09-18", got) + } + got, err := g.GetGeoLookup("8.8.8.8") + if err != nil || got.ASN != 15169 { + t.Errorf("lookup after reload: %+v err=%v", got, err) + } +} + +// A broken replacement (corrupt file or City edition on the ASN path) is +// rejected and the previously loaded database keeps answering. +// +// specRef: dnscheck-behaviour.md #S7 +func TestGeoLookupManagerKeepsOldDatabaseWhenReplacementIsBad(t *testing.T) { + path := filepath.Join(t.TempDir(), "GeoLite2-ASN.mmdb") + installFixture(t, asnFixture, path, time.Now().Add(-time.Hour)) + g, err := NewGeoLookupManager(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer g.Close() + + installFixture(t, cityFixture, path, time.Now()) + if _, err := g.Reload(); err == nil { + t.Fatal("expected an error for a City database on the ASN path") + } + if err := os.WriteFile(path, []byte("not an mmdb"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := g.Reload(); err == nil { + t.Fatal("expected an error for a corrupt replacement") + } + + if s := g.Stats(); s.Failures != 2 || s.BuildTime.UTC().Format("2006-01-02") != "2026-09-04" { + t.Errorf("stats after failed reloads: %+v", s) + } + got, err := g.GetGeoLookup("8.8.8.8") + if err != nil || got.ASN != 15169 { + t.Errorf("old database not served after failed reloads: %+v err=%v", got, err) + } +} + // specRef: dnscheck-behaviour.md #S2 func TestNewGeoLookupManagerRejectsMissingFile(t *testing.T) { _, err := NewGeoLookupManager(filepath.Join(t.TempDir(), "missing.mmdb")) diff --git a/dnscheck/internal/maxmind/testdata/GeoLite2-ASN.newer.mmdb b/dnscheck/internal/maxmind/testdata/GeoLite2-ASN.newer.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..06f44556daad90b2b60ae2e9b91aa6ce0001647b GIT binary patch literal 833 zcmZXQNpI6Y7>3`3uO_CwFsy&fC2GYO44T%dU znk(YMKY$|={{gsh;?9Z5Cz*8)kDmG7Z)QFXPylWKMc@Lk4zPg|aE4?(umRA3jkMiF z+)Ug;+)CU=R0p>MJIHqucM*3J_Yn6I_Yqb9e&7K42yv7+Mm$J7L>woo{=>i#@(JQA z;?X=k1{}}*1j$L@R9-m^d>}tdJV!jAr=Ll5U^1^<1TK+ZCQcEr5T}V(iE65Azz^U$ zQEhKh{Z^jN0JFK@CbN);b=_@xy3y3j zj&Ih6eU9q$2gQ%~je6a&bj!1=o@F(vo;^&ZN}mpBTfY=tpP`oSZSL`((|TNcT$ON#%dVf;INU2A3GN+;kw q(SBSiF26P2)jB+FCt+{+O<&49UMe(Y*3$lbTT>5dW>u3smFZ6p;IHuj literal 0 HcmV?d00001 From 50c1580680cce33af487764018197c8fcbbc649d Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 21 Sep 2026 12:19:06 +0200 Subject: [PATCH 6/6] feat(proxy): Export GeoIP database build time and reload counters Signed-off-by: Maciek --- proxy/internal/metrics/geoipdb.go | 44 ++++++++++++++++++ proxy/internal/metrics/geoipdb_test.go | 63 ++++++++++++++++++++++++++ proxy/server/server.go | 1 + 3 files changed, 108 insertions(+) create mode 100644 proxy/internal/metrics/geoipdb.go create mode 100644 proxy/internal/metrics/geoipdb_test.go diff --git a/proxy/internal/metrics/geoipdb.go b/proxy/internal/metrics/geoipdb.go new file mode 100644 index 00000000..abc88f35 --- /dev/null +++ b/proxy/internal/metrics/geoipdb.go @@ -0,0 +1,44 @@ +package metrics + +import ( + "github.com/ivpn/dns/libs/geoipdb" + "github.com/prometheus/client_golang/prometheus" +) + +// GeoIPDBSource is what the GeoIP gauges read; *asnlookup.Lookup satisfies it. +type GeoIPDBSource interface { + Stats() geoipdb.Stats +} + +// ObserveGeoIPDB registers gauges describing the GeoLite2-ASN database in use. +// The build timestamp is the end-to-end freshness signal: it only moves when +// the on-disk file was refreshed and the process picked it up. +func ObserveGeoIPDB(reg prometheus.Registerer, src GeoIPDBSource) { + reg.MustRegister( + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "proxy_dns_geoip_db_build_timestamp_seconds", + Help: "Build time (Unix seconds) of the GeoLite2-ASN database currently serving lookups.", + }, func() float64 { return float64(src.Stats().BuildTime.Unix()) }), + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "proxy_dns_geoip_db_loaded_timestamp_seconds", + Help: "Time (Unix seconds) the current GeoLite2-ASN file was opened by this process.", + }, func() float64 { return float64(src.Stats().LoadedAt.Unix()) }), + prometheus.NewCounterFunc(prometheus.CounterOpts{ + Name: "proxy_dns_geoip_db_reloads_total", + Help: "Successful in-process reloads of the GeoLite2-ASN database.", + }, func() float64 { return float64(src.Stats().Reloads) }), + prometheus.NewCounterFunc(prometheus.CounterOpts{ + Name: "proxy_dns_geoip_db_reload_failures_total", + Help: "Replacement GeoLite2-ASN files rejected (unreadable, corrupt or wrong edition); the previous database kept serving.", + }, func() float64 { return float64(src.Stats().Failures) }), + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "proxy_dns_geoip_db_reload_error", + Help: "1 while the most recent reload attempt failed, 0 after a successful load.", + }, func() float64 { + if src.Stats().LastError != "" { + return 1 + } + return 0 + }), + ) +} diff --git a/proxy/internal/metrics/geoipdb_test.go b/proxy/internal/metrics/geoipdb_test.go new file mode 100644 index 00000000..32114531 --- /dev/null +++ b/proxy/internal/metrics/geoipdb_test.go @@ -0,0 +1,63 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/ivpn/dns/libs/geoipdb" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" +) + +type fakeGeoIPDB struct{ stats geoipdb.Stats } + +func (f fakeGeoIPDB) Stats() geoipdb.Stats { return f.stats } + +func gaugeValue(t *testing.T, reg *prometheus.Registry, name string) float64 { + t.Helper() + families, err := reg.Gather() + if err != nil { + t.Fatal(err) + } + for _, mf := range families { + if mf.GetName() != name { + continue + } + m := mf.GetMetric()[0] + if mf.GetType() == dto.MetricType_COUNTER { + return m.GetCounter().GetValue() + } + return m.GetGauge().GetValue() + } + t.Fatalf("metric %s not registered", name) + return 0 +} + +func TestObserveGeoIPDBExposesBuildAndReloadState(t *testing.T) { + build := time.Date(2026, 9, 18, 13, 50, 42, 0, time.UTC) + loaded := build.Add(2 * time.Hour) + reg := prometheus.NewRegistry() + ObserveGeoIPDB(reg, fakeGeoIPDB{stats: geoipdb.Stats{ + BuildTime: build, + LoadedAt: loaded, + Reloads: 3, + Failures: 1, + LastError: "boom", + }}) + + if got := gaugeValue(t, reg, "proxy_dns_geoip_db_build_timestamp_seconds"); got != float64(build.Unix()) { + t.Errorf("build timestamp gauge = %v, want %v", got, build.Unix()) + } + if got := gaugeValue(t, reg, "proxy_dns_geoip_db_loaded_timestamp_seconds"); got != float64(loaded.Unix()) { + t.Errorf("loaded timestamp gauge = %v, want %v", got, loaded.Unix()) + } + if got := gaugeValue(t, reg, "proxy_dns_geoip_db_reloads_total"); got != 3 { + t.Errorf("reloads counter = %v, want 3", got) + } + if got := gaugeValue(t, reg, "proxy_dns_geoip_db_reload_failures_total"); got != 1 { + t.Errorf("failures counter = %v, want 1", got) + } + if got := gaugeValue(t, reg, "proxy_dns_geoip_db_reload_error"); got != 1 { + t.Errorf("reload error gauge = %v, want 1", got) + } +} diff --git a/proxy/server/server.go b/proxy/server/server.go index 43c7439a..6ff39c9e 100644 --- a/proxy/server/server.go +++ b/proxy/server/server.go @@ -125,6 +125,7 @@ func NewServer(serverConfig *config.Config, collectorChannels map[string]channel } // The file is refreshed on disk by geoipupdate; follow it without a restart. go lookup.Watch(context.Background(), serverConfig.Services.GeoIPASNDBReloadEvery) + metrics.ObserveGeoIPDB(prometheus.DefaultRegisterer, lookup) log.Info(). Str("catalog", serverConfig.Services.CatalogPath). Str("geodb", serverConfig.Services.GeoIPASNDBPath).