diff --git a/plugin/geoip/README.md b/plugin/geoip/README.md index febad8ad7..0e2f388a9 100644 --- a/plugin/geoip/README.md +++ b/plugin/geoip/README.md @@ -2,13 +2,15 @@ ## Name -*geoip* - Lookup maxmind geoip2 databases using the client IP, then add associated geoip data to the context request. +*geoip* - Lookup `.mmdb` ([MaxMind db file format](https://maxmind.github.io/MaxMind-DB/)) databases using the client IP, then add associated geoip data to the context request. ## Description -The *geoip* plugin add geo location data associated with the client IP, it allows you to configure a [geoIP2 maxmind database](https://dev.maxmind.com/geoip/docs/databases) to add the geo location data associated with the IP address. +The *geoip* plugin allows you to enrich the data associated with Client IP addresses, e.g. geoip information like City, Country, and Network ASN. GeoIP data is commonly available in the `.mmdb` format, a database format that maps IPv4 and IPv6 addresses to data records using a binary search tree. -The data is added leveraging the *metadata* plugin, values can then be retrieved using it as well, for example: +The data is added leveraging the *metadata* plugin, values can then be retrieved using it as well. + +**Longitude example:** ```go import ( @@ -26,11 +28,47 @@ if getLongitude := metadata.ValueFunc(ctx, "geoip/longitude"); getLongitude != n // ... ``` +**City example:** + +```go +import ( + "github.com/coredns/coredns/plugin/metadata" +) +// ... +if getCity := metadata.ValueFunc(ctx, "geoip/city/name"); getCity != nil { + city := getCity() + // Do something useful with city. +} else { + // The metadata label geoip/city/name for some reason, was not set. +} +// ... +``` + +**ASN example:** + +```go +import ( + "strconv" + "github.com/coredns/coredns/plugin/metadata" +) +// ... +if getASN := metadata.ValueFunc(ctx, "geoip/asn/number"); getASN != nil { + if asn, err := strconv.ParseUint(getASN(), 10, 32); err == nil { + // Do something useful with asn. + } +} +if getASNOrg := metadata.ValueFunc(ctx, "geoip/asn/org"); getASNOrg != nil { + asnOrg := getASNOrg() + // Do something useful with asnOrg. +} +// ... +``` + ## Databases -The supported databases use city schema such as `City` and `Enterprise`. Other databases types with different schemas are not supported yet. +The supported databases use city schema such as `ASN`, `City`, and `Enterprise`. `.mmdb` files are generally supported, as long as their field names correctly map to the Metadata Labels below. Other database types with different schemas are not supported yet. -You can download a [free and public City database](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data). +Free and commercial GeoIP `.mmdb` files are commonly available from vendors like [MaxMind](https://dev.maxmind.com/geoip/docs/databases), [IPinfo](https://ipinfo.io/developers/database-download), and [IPtoASN](https://iptoasn.com/) which is [Public Domain-licensed](https://opendatacommons.org/licenses/pddl/1-0/). ## Syntax @@ -46,7 +84,7 @@ geoip [DBFILE] { } ``` -* **DBFILE** the mmdb database file path. We recommend updating your mmdb database periodically for more accurate results. +* **DBFILE** the `mmdb` database file path. We recommend updating your `mmdb` database periodically for more accurate results. * `edns-subnet`: Optional. Use [EDNS0 subnet](https://en.wikipedia.org/wiki/EDNS_Client_Subnet) (if present) for Geo IP instead of the source IP of the DNS request. This helps identifying the closest source IP address through intermediary DNS resolvers, and it also makes GeoIP testing easy: `dig +subnet=1.2.3.4 @dns-server.example.com www.geo-aware.com`. **NOTE:** due to security reasons, recursive DNS resolvers may mask a few bits off of the clients' IP address, which can cause inaccuracies in GeoIP resolution. @@ -103,6 +141,8 @@ A limited set of fields will be exported as labels, all values are stored using | `geoip/longitude` | `float64` | `0.1315` | Base 10, max available precision. | `geoip/timezone` | `string` | `Europe/London` | The timezone. | `geoip/postalcode` | `string` | `CB4` | The postal code. +| `geoip/asn/number` | `uint` | `396982` | The autonomous system number. +| `geoip/asn/org` | `string` | `GOOGLE-CLOUD-PLATFORM` | The autonomous system organization. ## Continent Codes diff --git a/plugin/geoip/asn.go b/plugin/geoip/asn.go new file mode 100644 index 000000000..9f1830a5d --- /dev/null +++ b/plugin/geoip/asn.go @@ -0,0 +1,21 @@ +package geoip + +import ( + "context" + "strconv" + + "github.com/coredns/coredns/plugin/metadata" + + "github.com/oschwald/geoip2-golang" +) + +func (g GeoIP) setASNMetadata(ctx context.Context, data *geoip2.ASN) { + asnNumber := strconv.FormatUint(uint64(data.AutonomousSystemNumber), 10) + metadata.SetValueFunc(ctx, pluginName+"/asn/number", func() string { + return asnNumber + }) + asnOrg := data.AutonomousSystemOrganization + metadata.SetValueFunc(ctx, pluginName+"/asn/org", func() string { + return asnOrg + }) +} diff --git a/plugin/geoip/geoip.go b/plugin/geoip/geoip.go index 765ac05c0..b817c3522 100644 --- a/plugin/geoip/geoip.go +++ b/plugin/geoip/geoip.go @@ -1,4 +1,4 @@ -// Package geoip implements a max mind database plugin. +// Package geoip implements an MMDB database plugin for geo/network IP lookups. package geoip import ( @@ -17,8 +17,8 @@ import ( var log = clog.NewWithPlugin(pluginName) -// GeoIP is a plugin that add geo location data to the request context by looking up a maxmind -// geoIP2 database, and which data can be later consumed by other middlewares. +// GeoIP is a plugin that adds geo location and network data to the request context by looking up +// an MMDB format database, and which data can be later consumed by other middlewares. type GeoIP struct { Next plugin.Handler db db @@ -34,6 +34,7 @@ type db struct { const ( city = 1 << iota + asn ) var probingIP = net.ParseIP("127.0.0.1") @@ -50,6 +51,7 @@ func newGeoIP(dbPath string, edns0 bool) (*GeoIP, error) { validate func() error }{ {name: "city", provides: city, validate: func() error { _, err := reader.City(probingIP); return err }}, + {name: "asn", provides: asn, validate: func() error { _, err := reader.ASN(probingIP); return err }}, } // Query the database to figure out the database type. for _, schema := range schemas { @@ -63,8 +65,8 @@ func newGeoIP(dbPath string, edns0 bool) (*GeoIP, error) { } } - if db.provides&city == 0 { - return nil, fmt.Errorf("database does not provide city schema") + if db.provides == 0 { + return nil, fmt.Errorf("database does not provide any supported schema (city, asn)") } return &GeoIP{db: db, edns0: edns0}, nil @@ -91,14 +93,21 @@ func (g GeoIP) Metadata(ctx context.Context, state request.Request) context.Cont } } - switch g.db.provides & city { - case city: + if g.db.provides&city != 0 { data, err := g.db.City(srcIP) if err != nil { - log.Debugf("Setting up metadata failed due to database lookup error: %v", err) - return ctx + log.Debugf("Setting up city metadata failed due to database lookup error: %v", err) + } else { + g.setCityMetadata(ctx, data) + } + } + if g.db.provides&asn != 0 { + data, err := g.db.ASN(srcIP) + if err != nil { + log.Debugf("Setting up asn metadata failed due to database lookup error: %v", err) + } else { + g.setASNMetadata(ctx, data) } - g.setCityMetadata(ctx, data) } return ctx } diff --git a/plugin/geoip/geoip_test.go b/plugin/geoip/geoip_test.go index 7f12be9d1..7416f4c56 100644 --- a/plugin/geoip/geoip_test.go +++ b/plugin/geoip/geoip_test.go @@ -17,39 +17,47 @@ func TestMetadata(t *testing.T) { tests := []struct { label string expectedValue string + dbPath string + remoteIP string }{ - {"geoip/city/name", "Cambridge"}, - - {"geoip/country/code", "GB"}, - {"geoip/country/name", "United Kingdom"}, + // City database tests + {"geoip/city/name", "Cambridge", cityDBPath, "81.2.69.142"}, + {"geoip/country/code", "GB", cityDBPath, "81.2.69.142"}, + {"geoip/country/name", "United Kingdom", cityDBPath, "81.2.69.142"}, // is_in_european_union is set to true only to work around bool zero value, and test is really being set. - {"geoip/country/is_in_european_union", "true"}, + {"geoip/country/is_in_european_union", "true", cityDBPath, "81.2.69.142"}, + {"geoip/continent/code", "EU", cityDBPath, "81.2.69.142"}, + {"geoip/continent/name", "Europe", cityDBPath, "81.2.69.142"}, + {"geoip/latitude", "52.2242", cityDBPath, "81.2.69.142"}, + {"geoip/longitude", "0.1315", cityDBPath, "81.2.69.142"}, + {"geoip/timezone", "Europe/London", cityDBPath, "81.2.69.142"}, + {"geoip/postalcode", "CB4", cityDBPath, "81.2.69.142"}, - {"geoip/continent/code", "EU"}, - {"geoip/continent/name", "Europe"}, + // ASN database tests + {"geoip/asn/number", "12345", asnDBPath, "81.2.69.142"}, + {"geoip/asn/org", "Test ASN Organization", asnDBPath, "81.2.69.142"}, - {"geoip/latitude", "52.2242"}, - {"geoip/longitude", "0.1315"}, - {"geoip/timezone", "Europe/London"}, - {"geoip/postalcode", "CB4"}, + // ASN "Not routed" edge case tests (ASN=0) + // Test data from iptoasn.com where some IP ranges have no assigned ASN. + {"geoip/asn/number", "0", asnDBPath, "10.0.0.1"}, + {"geoip/asn/org", "Not routed", asnDBPath, "10.0.0.1"}, } - knownIPAddr := "81.2.69.142" // This IP should be part of the CDIR address range used to create the database fixtures. for _, tc := range tests { t.Run(fmt.Sprintf("%s/%s", tc.label, "direct"), func(t *testing.T) { - geoIP, err := newGeoIP(cityDBPath, false) + geoIP, err := newGeoIP(tc.dbPath, false) if err != nil { t.Fatalf("unable to create geoIP plugin: %v", err) } state := request.Request{ Req: new(dns.Msg), - W: &test.ResponseWriter{RemoteIP: knownIPAddr}, + W: &test.ResponseWriter{RemoteIP: tc.remoteIP}, } testMetadata(t, state, geoIP, tc.label, tc.expectedValue) }) t.Run(fmt.Sprintf("%s/%s", tc.label, "subnet"), func(t *testing.T) { - geoIP, err := newGeoIP(cityDBPath, true) + geoIP, err := newGeoIP(tc.dbPath, true) if err != nil { t.Fatalf("unable to create geoIP plugin: %v", err) } @@ -59,7 +67,7 @@ func TestMetadata(t *testing.T) { } state.Req.SetEdns0(4096, false) if o := state.Req.IsEdns0(); o != nil { - addr := net.ParseIP(knownIPAddr) + addr := net.ParseIP(tc.remoteIP) o.Option = append(o.Option, (&dns.EDNS0_SUBNET{ SourceNetmask: 32, Address: addr, @@ -70,6 +78,47 @@ func TestMetadata(t *testing.T) { } } +func TestMetadataUnknownIP(t *testing.T) { + // Test that looking up an IP not explicitly in the database doesn't crash. + // With IncludeReservedNetworks enabled in the test fixture, the geoip2 library + // returns zero-initialized data rather than an error, so metadata is set with + // zero values (ASN="0", org=""). + unknownIPAddr := "203.0.113.1" // TEST-NET-3, not explicitly in our fixture. + + geoIP, err := newGeoIP(asnDBPath, false) + if err != nil { + t.Fatalf("unable to create geoIP plugin: %v", err) + } + + state := request.Request{ + Req: new(dns.Msg), + W: &test.ResponseWriter{RemoteIP: unknownIPAddr}, + } + + ctx := metadata.ContextWithMetadata(context.Background()) + geoIP.Metadata(ctx, state) + + // For IPs not in the database, geoip2 returns zero values rather than errors. + // Metadata is set with these zero values. + fn := metadata.ValueFunc(ctx, "geoip/asn/number") + if fn == nil { + t.Errorf("expected metadata to be set for unknown IP") + return + } + if fn() != "0" { + t.Errorf("expected geoip/asn/number to be \"0\" for unknown IP, got %q", fn()) + } + + fn = metadata.ValueFunc(ctx, "geoip/asn/org") + if fn == nil { + t.Errorf("expected metadata to be set for unknown IP") + return + } + if fn() != "" { + t.Errorf("expected geoip/asn/org to be empty for unknown IP, got %q", fn()) + } +} + func testMetadata(t *testing.T, state request.Request, geoIP *GeoIP, label, expectedValue string) { t.Helper() ctx := metadata.ContextWithMetadata(context.Background()) diff --git a/plugin/geoip/setup_test.go b/plugin/geoip/setup_test.go index b9b0030ee..ba4848620 100644 --- a/plugin/geoip/setup_test.go +++ b/plugin/geoip/setup_test.go @@ -14,6 +14,7 @@ import ( var ( fixturesDir = "./testdata" cityDBPath = filepath.Join(fixturesDir, "GeoLite2-City.mmdb") + asnDBPath = filepath.Join(fixturesDir, "GeoLite2-ASN.mmdb") unknownDBPath = filepath.Join(fixturesDir, "GeoLite2-UnknownDbType.mmdb") ) @@ -50,9 +51,12 @@ func TestGeoIPParse(t *testing.T) { expectedErr string expectedDBType int }{ - // Valid + // Valid - City database {false, fmt.Sprintf("%s %s\n", pluginName, cityDBPath), "", city}, {false, fmt.Sprintf("%s %s { edns-subnet }", pluginName, cityDBPath), "", city}, + // Valid - ASN database + {false, fmt.Sprintf("%s %s\n", pluginName, asnDBPath), "", asn}, + {false, fmt.Sprintf("%s %s { edns-subnet }", pluginName, asnDBPath), "", asn}, // Invalid {true, pluginName, "Wrong argument count", 0}, diff --git a/plugin/geoip/testdata/GeoLite2-ASN.mmdb b/plugin/geoip/testdata/GeoLite2-ASN.mmdb new file mode 100644 index 000000000..d99983e43 Binary files /dev/null and b/plugin/geoip/testdata/GeoLite2-ASN.mmdb differ diff --git a/plugin/geoip/testdata/README.md b/plugin/geoip/testdata/README.md index 2f6f884c9..5c539c396 100644 --- a/plugin/geoip/testdata/README.md +++ b/plugin/geoip/testdata/README.md @@ -4,7 +4,7 @@ This directory contains mmdb database files used during the testing of this plug # Create mmdb database files If you need to change them to add a new value, or field the best is to recreate them, the code snipped used to create them initially is provided next. -```golang +```go package main import ( @@ -17,13 +17,15 @@ import ( "github.com/maxmind/mmdbwriter/mmdbtype" ) -const cdir = "81.2.69.142/32" +const cidr = "81.2.69.142/32" // Create new mmdb database fixtures in this directory. func main() { createCityDB("GeoLite2-City.mmdb", "DBIP-City-Lite") - // Create unkwnon database type. + // Create unknown database type. createCityDB("GeoLite2-UnknownDbType.mmdb", "UnknownDbType") + // Create ASN database. + createASNDB("GeoLite2-ASN.mmdb", "GeoLite2-ASN") } func createCityDB(dbName, dbType string) { @@ -34,7 +36,7 @@ func createCityDB(dbName, dbType string) { } // Define and insert the new data. - _, ip, err := net.ParseCIDR(cdir) + _, ip, err := net.ParseCIDR(cidr) if err != nil { log.Fatal(err) } @@ -109,4 +111,55 @@ func createCityDB(dbName, dbType string) { log.Fatal(err) } } + +func createASNDB(dbName, dbType string) { + // Load a database writer. + // IncludeReservedNetworks allows inserting private IP ranges like 10.0.0.0/8. + writer, err := mmdbwriter.New(mmdbwriter.Options{ + DatabaseType: dbType, + IncludeReservedNetworks: true, + }) + if err != nil { + log.Fatal(err) + } + + // Define and insert the new data. + _, ip, err := net.ParseCIDR(cidr) + if err != nil { + log.Fatal(err) + } + + record := mmdbtype.Map{ + "autonomous_system_number": mmdbtype.Uint64(12345), + "autonomous_system_organization": mmdbtype.String("Test ASN Organization"), + } + + if err := writer.InsertFunc(ip, inserter.TopLevelMergeWith(record)); err != nil { + log.Fatal(err) + } + + // Add "Not routed" entry for private IP range (ASN=0). + // This tests edge cases from iptoasn.com data where some ranges have no ASN. + _, notRoutedIP, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + log.Fatal(err) + } + notRoutedRecord := mmdbtype.Map{ + "autonomous_system_number": mmdbtype.Uint64(0), + "autonomous_system_organization": mmdbtype.String("Not routed"), + } + if err := writer.InsertFunc(notRoutedIP, inserter.TopLevelMergeWith(notRoutedRecord)); err != nil { + log.Fatal(err) + } + + // Write the DB to the filesystem. + fh, err := os.Create(dbName) + if err != nil { + log.Fatal(err) + } + _, err = writer.WriteTo(fh) + if err != nil { + log.Fatal(err) + } +} ```