Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Configurable geoip data file and data field path #56

Merged
merged 2 commits into from
Dec 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 41 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@ A CoreDNS plugin that is very similar to [k8s_external](https://coredns.io/plugi

This plugin relies on it's own connection to the k8s API server and doesn't share any code with the existing [kubernetes](https://coredns.io/plugins/kubernetes/) plugin. The assumption is that this plugin can now be deployed as a separate instance (alongside the internal kube-dns) and act as a single external DNS interface into your Kubernetes cluster(s).


## Description

`k8s_crd` resolves Kubernetes resources with their external IP addresses based on zones specified in the configuration. This plugin will resolve the following type of resources:

| Kind | Matching Against | External IPs are from |
| ---- | ---------------- | -------- |
| DNSEndponit | all FQDNs from `spec.endpoints.dnszone` matching configured zones | `.spec.endpoints.dnszone.targets` |

| DNSEndpoint | all FQDNs from `spec.endpoints.dnszone` matching configured zones | `.spec.endpoints.dnszone.targets` |

Currently only supports A-type queries, all other queries result in NODATA responses.

Expand All @@ -24,18 +22,19 @@ This plugin is **NOT** supposed to be used for intra-cluster DNS resolution and

The recommended installation method is using the helm chart provided in the repo:

```
```shell
helm install exdns ./charts/coredns
```

## Configure

```
```text
k8s_crd [ZONE...]
```

Optionally, you can specify what kind of resources to watch, default TTL to return in response and a default name to use for zone apex, e.g.

```
```text
k8s_crd example.com {
ttl 10
apex dns1
Expand All @@ -45,26 +44,41 @@ k8s_crd example.com {
## Resolving order

### GeoIP

In case dnsEndpoint object's target has a label of `strategy: geoip` CoreDNS `k8s_crd` plugin will respond in a special way:

* Assuming record has multiple IPs associated with it, and DNS message comes with edns0 `CLIENT-SUBNET` option.
* CoreDNS will compare `DC` tag for IP extracted from `CLIENT-SUBNET` option against available Endpoint.Targets
* CoreDNS will compare the specified field tag (`datacenter` by default, configured via the `geodatafield` plugin option) for IP extracted from `CLIENT-SUBNET` option against available Endpoint.Targets
* Return only IPs where tags match
* If IP has no common tag, all entries are returned.
* CoreDNS must be supplied with a specially crafted GeoIP database in MaxMind DB format and mounted as `/geoip.mmdb` Refer to [./terratest/geogen](./terratest/geogen) for examples.
* CoreDNS must be supplied with a specially crafted GeoIP database in MaxMind DB format and mounted (at `/geoip.mmdb` by default, configured via the `geodatafilepath` plugin option). Refer to [./terratest/geogen](./terratest/geogen) for examples. Using the MaxMind GeoLite2 database is supported using the necessary `geodatafield` to configure the field to use as required.

The following configuration options are available:

```text
k8s_crd example.com {
geodatafilepath /geoip.mmdb
geodatafield country.iso_code
...
}
```

### Weight Round Robin

To enable the weight round robin you have to set the configuration to weight load-balancer:
```

```text
k8s_crd example.com {
loadbalance weight
...
}
```
The dnsEndpoint must also contain information about the percentage distribution per region
and their IP addresses. Thanks to this, the weight round-robin module will know in which
order to return IP addresses. Addresses with high probability will often be at the top of

The dnsEndpoint must also contain information about the percentage distribution per region
and their IP addresses. Thanks to this, the weight round-robin module will know in which
order to return IP addresses. Addresses with high probability will often be at the top of
DNS responses, while those with low probability will be at the bottom.

```yaml
labels:
strategy: roundrobin
Expand All @@ -73,34 +87,35 @@ labels:
weight-za-0-0: 10.10.0.1
weight-us-0-50: 10.20.0.1
```

For more information about balancing, please visit our [go-weight-shuffling](https://github.com/k8gb-io/go-weight-shuffling
) module.

## Build

### With compile-time configuration file

```
$ git clone https://github.com/coredns/coredns
$ cd coredns
$ vim plugin.cfg
```shell
git clone https://github.com/coredns/coredns
cd coredns
vim plugin.cfg
# Replace lines with kubernetes and k8s_external with k8s_crd:github.com/absaoss/k8s_crd
$ go generate
$ go build
$ ./coredns -plugins | grep k8s_crd
go generate
go build
./coredns -plugins | grep k8s_crd
```

### With external golang source code
```
$ git clone https://github.com/absaoss/k8s_crd.git
$ cd k8s_crd
$ go build cmd/coredns.go
$ ./coredns -plugins | grep k8s_crd

```shell
git clone https://github.com/absaoss/k8s_crd.git
cd k8s_crd
go build cmd/coredns.go
./coredns -plugins | grep k8s_crd
```

For more details refer to [this CoreDNS doc](https://coredns.io/2017/07/25/compile-time-enabling-or-disabling-plugins/)


## Notes regarding Zone Apex and NS server resolution

Due to the fact that there is not nice way to discover NS server's own IP to respond to A queries, as a wokaround, it's possible to pass the name of the LoadBalancer service used to expose the CoreDNS instance as an environment variable `EXTERNAL_SVC`. If not set, the default fallback value of `external-dns.kube-system` will be used to look up the external IP of the CoreDNS service.
Due to the fact that there is not nice way to discover NS server's own IP to respond to A queries, as a workaround, it's possible to pass the name of the LoadBalancer service used to expose the CoreDNS instance as an environment variable `EXTERNAL_SVC`. If not set, the default fallback value of `external-dns.kube-system` will be used to look up the external IP of the CoreDNS service.
14 changes: 7 additions & 7 deletions common/k8sctrl/ctrl.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ type KubeController struct {
epc cache.SharedIndexInformer
}

type LookupEndpoint func(indexKey string, clientIP net.IP) (result LocalDNSEndpoint)
type LookupEndpoint func(indexKey string, clientIP net.IP, geoDataFilePath string, geoDataFieldPath ...string) (result LocalDNSEndpoint)

type ResourceWithLookup struct {
Name string
Expand Down Expand Up @@ -137,19 +137,19 @@ func endpointHostnameIndexFunc(obj interface{}) ([]string, error) {
return hostnames, nil
}

func (ctrl *KubeController) getEndpointByName(host string, clientIP net.IP) (lep LocalDNSEndpoint) {
func (ctrl *KubeController) getEndpointByName(host string, clientIP net.IP, geoDataFilePath string, geoDataFieldPath ...string) (lep LocalDNSEndpoint) {
log.Infof("Index key %+v", host)
endpoints := ctrl.getEndpointsByCaseInsensitiveName(host, clientIP)
endpoints := ctrl.getEndpointsByCaseInsensitiveName(host, clientIP, geoDataFilePath, geoDataFieldPath...)
lep = ctrl.margeLocalDNSEndpoints(host, endpoints)
return lep
}

// The function tries to find all case sensitive variants. Returns a map where the call is hostname and the value is LocalDNSEndpoint
func (ctrl *KubeController) getEndpointsByCaseInsensitiveName(host string, clientIP net.IP) (result map[string]LocalDNSEndpoint) {
func (ctrl *KubeController) getEndpointsByCaseInsensitiveName(host string, clientIP net.IP, geoDataFilePath string, geoDataFieldPath ...string) (result map[string]LocalDNSEndpoint) {

// The function extracts LocalDNSEndpoints from *DNSEndpoint. The function is hardwired with a case-sensitive extraction scenario and is only used in a
// single location, so it is currently declared inside the calling function.
extractLocalEndpoints := func(ep *endpoint.DNSEndpoint, ip net.IP, host string) (result []LocalDNSEndpoint) {
extractLocalEndpoints := func(ep *endpoint.DNSEndpoint, ip net.IP, host string, geoDataFieldPath ...string) (result []LocalDNSEndpoint) {
result = []LocalDNSEndpoint{}
for _, e := range ep.Spec.Endpoints {
if strings.EqualFold(e.DNSName, host) {
Expand All @@ -159,7 +159,7 @@ func (ctrl *KubeController) getEndpointsByCaseInsensitiveName(host string, clien
r.TTL = e.RecordTTL
r.Targets = e.Targets
if e.Labels["strategy"] == "geoip" {
targets := r.extractGeo(e, ip)
targets := r.extractGeo(e, ip, geoDataFilePath, geoDataFieldPath...)
if len(targets) > 0 {
r.Targets = targets
}
Expand All @@ -174,7 +174,7 @@ func (ctrl *KubeController) getEndpointsByCaseInsensitiveName(host string, clien
result = make(map[string]LocalDNSEndpoint, 0)
for _, obj := range epList {
ep := obj.(*endpoint.DNSEndpoint)
extracts := extractLocalEndpoints(ep, clientIP, host)
extracts := extractLocalEndpoints(ep, clientIP, host, geoDataFieldPath...)
for _, extracted := range extracts {
if strings.EqualFold(extracted.DNSName, host) {
result[extracted.DNSName] = extracted
Expand Down
26 changes: 13 additions & 13 deletions common/k8sctrl/ctrl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,42 +139,42 @@ func TestKubeController(t *testing.T) {
k8sctrl.epc = mcache

t.Run("get no-geo endpoint by name", func(t *testing.T) {
lep := k8sctrl.getEndpointByName(host, clientIP)
lep := k8sctrl.getEndpointByName(host, clientIP, "")
assert.NotNil(t, lep)
assert.Equal(t, "roundrobin.cloud.example.com: 0, Targets: [10.0.0.1 10.0.0.2], Labels: map[strategy:roundrobin]", lep.String())
})

t.Run("valid uppercase domain query", func(t *testing.T) {
lep := k8sctrl.getEndpointByName(hOSTCaseInsensitive, clientIP)
lep := k8sctrl.getEndpointByName(hOSTCaseInsensitive, clientIP, "")
assert.NotNil(t, lep)
sort.Strings(lep.Targets)
assert.Equal(t, "roundrobin-case-insensitive.CLOUD.EXAMPLE.COM: 0, Targets: [1.1.1.1 1.1.1.2 2.2.2.2], Labels: map[strategy:roundrobin]", lep.String())

lep = k8sctrl.getEndpointByName(hostCaseInsensitive, clientIP)
lep = k8sctrl.getEndpointByName(hostCaseInsensitive, clientIP, "")
assert.NotNil(t, lep)
sort.Strings(lep.Targets)
assert.Equal(t, "roundrobin-case-insensitive.cloud.example.com: 0, Targets: [1.1.1.1 1.1.1.2 2.2.2.2], Labels: map[strategy:roundrobin]", lep.String())
})

t.Run("handle multiple embedded endpoints", func(t *testing.T) {
lep := k8sctrl.getEndpointByName(embeddedCaseInsensitive, clientIP)
lep := k8sctrl.getEndpointByName(embeddedCaseInsensitive, clientIP, "")
assert.NotNil(t, lep)
sort.Strings(lep.Targets)
assert.Equal(t, "embedded.cloud.example.com: 0, Targets: [10.10.10.2 10.10.10.30 10.10.10.32], Labels: map[strategy:roundrobin]", lep.String())

lep = k8sctrl.getEndpointByName(embeddedCaseSensitive, clientIP)
lep = k8sctrl.getEndpointByName(embeddedCaseSensitive, clientIP, "")
assert.NotNil(t, lep)
sort.Strings(lep.Targets)
assert.Equal(t, "embedded.CLOUD.EXAMPLE.COM: 0, Targets: [10.10.10.2 10.10.10.30 10.10.10.32], Labels: map[strategy:roundrobin]", lep.String())
})

t.Run("handle multiple embedded endpoints but one EP is empty", func(t *testing.T) {
epEmbedded.Spec.Endpoints[1].Targets = []string{}
lep := k8sctrl.getEndpointByName(embeddedCaseInsensitive, clientIP)
lep := k8sctrl.getEndpointByName(embeddedCaseInsensitive, clientIP, "")
assert.NotNil(t, lep)
assert.Equal(t, "embedded.cloud.example.com: 0, Targets: [10.10.10.2], Labels: map[strategy:roundrobin]", lep.String())

lep = k8sctrl.getEndpointByName(embeddedCaseSensitive, clientIP)
lep = k8sctrl.getEndpointByName(embeddedCaseSensitive, clientIP, "")
assert.NotNil(t, lep)
assert.Equal(t, "embedded.CLOUD.EXAMPLE.COM: 0, Targets: [10.10.10.2], Labels: map[strategy:roundrobin]", lep.String())

Expand All @@ -184,33 +184,33 @@ func TestKubeController(t *testing.T) {
epEmbedded.Spec.Endpoints[1].Targets = []string{}
epEmbedded.Spec.Endpoints[2].Targets = []string{}
epEmbedded.Spec.Endpoints[0].Targets = []string{}
lep := k8sctrl.getEndpointByName(embeddedCaseInsensitive, clientIP)
lep := k8sctrl.getEndpointByName(embeddedCaseInsensitive, clientIP, "")
assert.NotNil(t, lep)
assert.Equal(t, "embedded.cloud.example.com: 0, Targets: [], Labels: map[strategy:roundrobin]", lep.String())

lep = k8sctrl.getEndpointByName(embeddedCaseSensitive, clientIP)
lep = k8sctrl.getEndpointByName(embeddedCaseSensitive, clientIP, "")
assert.NotNil(t, lep)
assert.Equal(t, "embedded.CLOUD.EXAMPLE.COM: 0, Targets: [], Labels: map[strategy:roundrobin]", lep.String())
})

t.Run("EP has no dns endpoints", func(t *testing.T) {
epEmbedded.Spec.Endpoints = []*endpoint.Endpoint{}
lep := k8sctrl.getEndpointByName(embeddedCaseInsensitive, clientIP)
lep := k8sctrl.getEndpointByName(embeddedCaseInsensitive, clientIP, "")
assert.NotNil(t, lep)
assert.Equal(t, "embedded.cloud.example.com: 0, Targets: [], Labels: map[]", lep.String())

lep = k8sctrl.getEndpointByName(embeddedCaseSensitive, clientIP)
lep = k8sctrl.getEndpointByName(embeddedCaseSensitive, clientIP, "")
assert.NotNil(t, lep)
assert.Equal(t, "embedded.CLOUD.EXAMPLE.COM: 0, Targets: [], Labels: map[]", lep.String())
})

t.Run("EP has nil dns endpoints", func(t *testing.T) {
epEmbedded.Spec.Endpoints = nil
lep := k8sctrl.getEndpointByName(embeddedCaseInsensitive, clientIP)
lep := k8sctrl.getEndpointByName(embeddedCaseInsensitive, clientIP, "")
assert.NotNil(t, lep)
assert.Equal(t, "embedded.cloud.example.com: 0, Targets: [], Labels: map[]", lep.String())

lep = k8sctrl.getEndpointByName(embeddedCaseSensitive, clientIP)
lep = k8sctrl.getEndpointByName(embeddedCaseSensitive, clientIP, "")
assert.NotNil(t, lep)
assert.Equal(t, "embedded.CLOUD.EXAMPLE.COM: 0, Targets: [], Labels: map[]", lep.String())
})
Expand Down
54 changes: 40 additions & 14 deletions common/k8sctrl/ep.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,11 @@ import (
"net"

"github.com/oschwald/maxminddb-golang"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/external-dns/endpoint"
)

type geo struct {
DC string `maxminddb:"datacenter"`
}
type geo map[string]interface{}

type LocalDNSEndpoint struct {
Targets []string
Expand All @@ -41,37 +40,64 @@ func (lep LocalDNSEndpoint) String() string {
return fmt.Sprintf("%s: %v, Targets: %v, Labels: %v", lep.DNSName, lep.TTL, lep.Targets, lep.Labels)
}

func (lep LocalDNSEndpoint) extractGeo(endpoint *endpoint.Endpoint, clientIP net.IP) (result []string) {
db, err := maxminddb.Open("geoip.mmdb")
func (lep LocalDNSEndpoint) extractGeo(endpoint *endpoint.Endpoint, clientIP net.IP, geoDataFilePath string, geoDataFieldPath ...string) (result []string) {
if geoDataFilePath == "" {
return nil
}

db, err := maxminddb.Open(geoDataFilePath)
if err != nil {
log.Fatal(err)
}
defer db.Close() // nolint:errcheck

clientGeo := &geo{}
err = db.Lookup(clientIP, clientGeo)
var clientGeo geo
err = db.Lookup(clientIP, &clientGeo)
if err != nil {
return nil
}

if clientGeo.DC == "" {
log.Infof("empty DC %+v", clientGeo)
log.Infof("extracted client geo data: %+v", clientGeo)

if len(geoDataFieldPath) == 0 {
log.Info("no geo data field specified")
return result
}

clientGeoData, found, err := unstructured.NestedString(clientGeo, geoDataFieldPath...)
if err != nil {
log.Infof("error retrieving client geo data for field %+v: %v", geoDataFieldPath, err)
return result
}

if !found || clientGeoData == "" {
log.Infof("client geo data field %+v not found", geoDataFieldPath)
return result
}

log.Infof("clientDC: %+v", clientGeo)
log.Infof("client geo data field value for %+v: %+v", geoDataFieldPath, clientGeoData)

for _, ip := range endpoint.Targets {
geoData := &geo{}
var endpointGeo geo
log.Infof("processing IP %+v", ip)
err = db.Lookup(net.ParseIP(ip), geoData)
err = db.Lookup(net.ParseIP(ip), &endpointGeo)
if err != nil {
log.Error(err)
continue
}
endpointGeoData, found, err := unstructured.NestedString(endpointGeo, geoDataFieldPath...)
if err != nil {
log.Infof("error retrieving endpoint geo data for field %+v: %v", geoDataFieldPath, err)
return result
}

if !found || endpointGeoData == "" {
log.Infof("endpoint geo data field %+v not found", geoDataFieldPath)
return result
}

log.Infof("IP info: %+v", geoData.DC)
if clientGeo.DC == geoData.DC {
log.Infof("endpoint data field value for %+v: %+v", geoDataFieldPath, clientGeoData)
if clientGeoData == endpointGeoData {
result = append(result, ip)
}
}
Expand Down
4 changes: 2 additions & 2 deletions service/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (gw *Gateway) ServeDNS(_ context.Context, w dns.ResponseWriter, r *dns.Msg)
}
}

var ep = k8sctrl.Resources.DNSEndpoint.Lookup(indexKey, clientIP)
var ep = k8sctrl.Resources.DNSEndpoint.Lookup(indexKey, clientIP, gw.opts.geoDataFilePath, gw.opts.geoDataField...)
log.Debugf("Computed response addresses %v", ep.Targets)
m := new(dns.Msg)
m.SetReply(state.Req)
Expand Down Expand Up @@ -160,7 +160,7 @@ func (gw *Gateway) selfAddress(state request.Request) (records []dns.RR) {
index = defaultSvc
}

var ep = k8sctrl.Resources.DNSEndpoint.Lookup(index, net.ParseIP(state.IP()))
var ep = k8sctrl.Resources.DNSEndpoint.Lookup(index, net.ParseIP(state.IP()), gw.opts.geoDataFilePath, gw.opts.geoDataField...)
m := new(dns.Msg)
m.SetReply(state.Req)
return gw.A(state, netutils.TargetToIP(ep.Targets), ep.TTL)
Expand Down
Loading
Loading