From 12bcac933a99cec779c52d4702c85644ef9e555f Mon Sep 17 00:00:00 2001 From: W Anders Date: Mon, 6 May 2024 17:37:57 -0600 Subject: [PATCH] initial commit --- .github/workflows/coverage.yaml | 54 ++ .gitignore | 1 + .testing/configuration/plugins.py | 11 + .testing/docker-compose.yml | 39 ++ .testing/env/netbox.env | 34 ++ .testing/env/postgres.env | 3 + .testing/env/redis-cache.env | 1 + .testing/env/redis.env | 1 + .testing/init/init.go | 68 +++ .testing/init/nameservers.json | 8 + .testing/init/records.json | 250 ++++++++++ .testing/init/zones.json | 209 ++++++++ .testing/netbox.Containerfile | 13 + .testing/requirements-plugin.txt | 1 + .testing/script.sh | 10 + .testing/tls/ca.pem | 14 + .testing/tls/client-key.pem | 5 + .testing/tls/client.pem | 15 + .vscode/launch.json | 15 + .vscode/settings.json | 3 + .vscode/tasks.json | 80 +++ LICENSE | 21 + README.md | 149 ++++++ go.mod | 41 ++ go.sum | 85 ++++ internal/netbox/api.go | 111 +++++ internal/netbox/record.go | 99 ++++ internal/netbox/zone.go | 34 ++ lookup.go | 273 +++++++++++ netboxdns.go | 109 +++++ netboxdns_test.go | 788 ++++++++++++++++++++++++++++++ parse.go | 179 +++++++ record.go | 78 +++ setup.go | 26 + setup_test.go | 199 ++++++++ 35 files changed, 3027 insertions(+) create mode 100644 .github/workflows/coverage.yaml create mode 100644 .gitignore create mode 100644 .testing/configuration/plugins.py create mode 100644 .testing/docker-compose.yml create mode 100644 .testing/env/netbox.env create mode 100644 .testing/env/postgres.env create mode 100644 .testing/env/redis-cache.env create mode 100644 .testing/env/redis.env create mode 100644 .testing/init/init.go create mode 100644 .testing/init/nameservers.json create mode 100644 .testing/init/records.json create mode 100644 .testing/init/zones.json create mode 100644 .testing/netbox.Containerfile create mode 100644 .testing/requirements-plugin.txt create mode 100644 .testing/script.sh create mode 100644 .testing/tls/ca.pem create mode 100644 .testing/tls/client-key.pem create mode 100644 .testing/tls/client.pem create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/netbox/api.go create mode 100644 internal/netbox/record.go create mode 100644 internal/netbox/zone.go create mode 100644 lookup.go create mode 100644 netboxdns.go create mode 100644 netboxdns_test.go create mode 100644 parse.go create mode 100644 record.go create mode 100644 setup.go create mode 100644 setup_test.go diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml new file mode 100644 index 0000000..5d25bdc --- /dev/null +++ b/.github/workflows/coverage.yaml @@ -0,0 +1,54 @@ +name: Build and Test +on: + push: + branches: + - master + - fix-* + - feat-* + - update-* + paths: + - '**.go' + - go.mod + - go.sum + pull_request: + types: + - opened + - synchronize + - reopened + paths: + - '**.go' + - go.mod + - go.sum +jobs: + build-test-publish-coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - run: go build + - run: | + docker compose -p coredns-netbox-plugin-dns -f ${{ github.workspace }}/.testing/docker-compose.yml up -d && \ + until [[ "`docker inspect -f {{.State.Health.Status}} coredns-netbox-plugin-dns-netbox-1`" == "healthy" ]]; do + echo "Waiting for Netbox to come online..." + sleep 5 + done && \ + go run ${{ github.workspace }}/.testing/init/init.go + - run: go test -coverprofile='coverage.out' + - uses: sonarsource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.organization=doubleu-labs + -Dsonar.projectKey=doubleu-labs_coredns-netbox-plugin-dns + -Dsonar.go.coverage.reportPaths=coverage.out + -Dsonar.verbose=true + -Dsonar.sources=. + -Dsonar.exclusions=**/*_test.go,.testing/* + -Dsonar.tests=. + -Dsonar.test.inclusions=**/*_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b3ac10 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +coverage.out \ No newline at end of file diff --git a/.testing/configuration/plugins.py b/.testing/configuration/plugins.py new file mode 100644 index 0000000..20aaf64 --- /dev/null +++ b/.testing/configuration/plugins.py @@ -0,0 +1,11 @@ + +PLUGINS = [ + 'netbox_dns', +] + +PLUGINS_CONFIG = { + 'netbox_dns': { + 'feature_ipam_coupling': True, + 'tolerate_underscores_in_hostnames': True, + }, +} diff --git a/.testing/docker-compose.yml b/.testing/docker-compose.yml new file mode 100644 index 0000000..185d24a --- /dev/null +++ b/.testing/docker-compose.yml @@ -0,0 +1,39 @@ +services: + netbox: + image: localhost/netbox-plugin-dns:latest + build: + context: . + dockerfile: ./netbox.Containerfile + depends_on: + - postgres + - redis + - redis-cache + env_file: env/netbox.env + user: 'unit:root' + healthcheck: + start_period: 60s + timeout: 3s + interval: 15s + test: "curl -f http://localhost:8080/api/ || exit 1" + ports: + - "9999:8080" + + postgres: + image: docker.io/library/postgres:16 + env_file: env/postgres.env + + redis: + image: docker.io/library/redis:7 + command: + - sh + - -c + - redis-server --appendonly yes --requirepass $$REDIS_PASSWORD + env_file: env/redis.env + + redis-cache: + image: docker.io/library/redis:7 + command: + - sh + - -c + - redis-server --requirepass $$REDIS_PASSWORD + env_file: env/redis-cache.env diff --git a/.testing/env/netbox.env b/.testing/env/netbox.env new file mode 100644 index 0000000..10831f2 --- /dev/null +++ b/.testing/env/netbox.env @@ -0,0 +1,34 @@ +CORS_ORIGIN_ALLOW_ALL=True +DB_HOST=postgres +DB_NAME=netbox +DB_PASSWORD=J5brHrAXFLQSif0K +DB_USER=netbox +EMAIL_FROM=netbox@bar.com +EMAIL_PASSWORD= +EMAIL_PORT=25 +EMAIL_SERVER=localhost +EMAIL_SSL_CERTFILE= +EMAIL_SSL_KEYFILE= +EMAIL_TIMEOUT=5 +EMAIL_USERNAME=netbox +# EMAIL_USE_SSL and EMAIL_USE_TLS are mutually exclusive, i.e. they can't both be `true`! +EMAIL_USE_SSL=false +EMAIL_USE_TLS=false +GRAPHQL_ENABLED=true +HOUSEKEEPING_INTERVAL=86400 +MEDIA_ROOT=/opt/netbox/netbox/media +METRICS_ENABLED=false +REDIS_CACHE_DATABASE=1 +REDIS_CACHE_HOST=redis-cache +REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY=false +REDIS_CACHE_PASSWORD=t4Ph722qJ5QHeQ1qfu36 +REDIS_CACHE_SSL=false +REDIS_DATABASE=0 +REDIS_HOST=redis +REDIS_INSECURE_SKIP_TLS_VERIFY=false +REDIS_PASSWORD=H733Kdjndks81 +REDIS_SSL=false +RELEASE_CHECK_URL=https://api.github.com/repos/netbox-community/netbox/releases +SECRET_KEY='r(m)9nLGnz$(_q3N4z1k(EFsMCjjjzx08x9VhNVcfd%6RF#r!6DE@+V5Zk2X' +WEBHOOKS_ENABLED=true +SUPERUSER_API_TOKEN=w5pgWXPqZVmngLN4w4XwuPvZfUC72ytDxnnHgEmI \ No newline at end of file diff --git a/.testing/env/postgres.env b/.testing/env/postgres.env new file mode 100644 index 0000000..045e64b --- /dev/null +++ b/.testing/env/postgres.env @@ -0,0 +1,3 @@ +POSTGRES_DB=netbox +POSTGRES_PASSWORD=J5brHrAXFLQSif0K +POSTGRES_USER=netbox \ No newline at end of file diff --git a/.testing/env/redis-cache.env b/.testing/env/redis-cache.env new file mode 100644 index 0000000..9afa252 --- /dev/null +++ b/.testing/env/redis-cache.env @@ -0,0 +1 @@ +REDIS_PASSWORD=t4Ph722qJ5QHeQ1qfu36 \ No newline at end of file diff --git a/.testing/env/redis.env b/.testing/env/redis.env new file mode 100644 index 0000000..dcb03dd --- /dev/null +++ b/.testing/env/redis.env @@ -0,0 +1 @@ +REDIS_PASSWORD=H733Kdjndks81 \ No newline at end of file diff --git a/.testing/init/init.go b/.testing/init/init.go new file mode 100644 index 0000000..cb58e25 --- /dev/null +++ b/.testing/init/init.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "runtime" +) + +var ( + apiRoot = "http://localhost:9999/api/plugins/netbox-dns" + token = "w5pgWXPqZVmngLN4w4XwuPvZfUC72ytDxnnHgEmI" + execdir string +) + +func init() { + _, filename, _, ok := runtime.Caller(0) + if !ok { + panic("unable to get current filename") + } + execdir = filepath.Dir(filename) +} + +func post(client *http.Client, path string, filepath string) (string, []byte) { + file, err := os.Open(filepath) + if err != nil { + log.Fatal(err) + } + defer file.Close() + stat, _ := file.Stat() + req, err := http.NewRequest("POST", apiRoot+path, file) + if err != nil { + log.Fatal(err) + } + req.Header.Set("Authorization", fmt.Sprintf("Token %s", token)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json; indent=4") + req.ContentLength = stat.Size() + + resp, err := client.Do(req) + if err != nil { + log.Fatal(err) + } + content, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return resp.Status, content +} + +func main() { + nameservers := filepath.Join(execdir, "nameservers.json") + zones := filepath.Join(execdir, "zones.json") + records := filepath.Join(execdir, "records.json") + client := &http.Client{} + + nsStatus, nsContent := post(client, "/nameservers/", nameservers) + log.Printf("nameservers: %s\n%s", nsStatus, nsContent) + + zoneStatus, zoneContent := post(client, "/zones/", zones) + log.Printf("zones: %s\n%s", zoneStatus, zoneContent) + + recordStatus, recordContent := post(client, "/records/", records) + log.Printf("records: %s\n%s", recordStatus, recordContent) +} diff --git a/.testing/init/nameservers.json b/.testing/init/nameservers.json new file mode 100644 index 0000000..b87b2b9 --- /dev/null +++ b/.testing/init/nameservers.json @@ -0,0 +1,8 @@ +[ + { + "name": "dns01.example.com" + }, + { + "name": "dns02.example.com" + } +] \ No newline at end of file diff --git a/.testing/init/records.json b/.testing/init/records.json new file mode 100644 index 0000000..094a726 --- /dev/null +++ b/.testing/init/records.json @@ -0,0 +1,250 @@ +[ + { + "zone": { + "name": "example.com" + }, + "type": "A", + "name": "dns01", + "value": "10.0.0.10" + }, + { + "zone": { + "name": "example.com" + }, + "type": "AAAA", + "name": "dns01", + "value": "2001:db8:dead:beef::1:10" + }, + { + "zone": { + "name": "example.com" + }, + "type": "A", + "name": "dns02", + "value": "10.0.0.11" + }, + { + "zone": { + "name": "example.com" + }, + "type": "AAAA", + "name": "dns02", + "value": "2001:db8:dead:beef::1:11" + }, + { + "zone": { + "name": "example.com" + }, + "type": "A", + "name": "aservice", + "value": "10.0.0.12" + }, + { + "zone": { + "name": "example.com" + }, + "type": "AAAA", + "name": "aservice", + "value": "2001:db8:dead:beef::1:12" + }, + { + "zone": { + "name": "example.com" + }, + "type": "MX", + "name": "@", + "value": "10 mail.example.com" + }, + { + "zone": { + "name": "example.com" + }, + "type": "A", + "name": "mail", + "value": "10.0.0.13" + }, + { + "zone": { + "name": "example.com" + }, + "type": "AAAA", + "name": "mail", + "value": "2001:db8:dead:beef::1:13" + }, + { + "zone": { + "name": "example.com" + }, + "type": "TXT", + "name": "@", + "value": "v=spf1 ip4:10.0.0.13 ip6:2001:db8:dead:beef::1:13 a -all" + }, + { + "zone": { + "name": "example.com" + }, + "type": "TXT", + "name": "@", + "value": "v=DMARC1;p=none;sp=quarantine;pct=100;rua=admin@example.com;" + }, + { + "zone": { + "name": "example.com" + }, + "type": "TXT", + "name": "@", + "value": "\"some value\"\\r\\n\"another value\"" + }, + { + "zone": { + "name": "example.com" + }, + "type": "TXT", + "name": "@", + "value": "\"newline record\"\\n\"second value\"" + }, + { + "zone": { + "name": "example.com" + }, + "type": "TXT", + "name": "@", + "value": "\"my value\" \"second my value\" \"third my value\"" + }, + { + "zone": { + "name": "example.com" + }, + "type": "A", + "name": "puppet-server-a", + "value": "10.0.0.15" + }, + { + "zone": { + "name": "example.com" + }, + "type": "AAAA", + "name": "puppet-server-a", + "value": "2001:db8:dead:beef::1:15" + }, + { + "zone": { + "name": "example.com" + }, + "type": "SRV", + "name": "_x-puppet._tcp", + "value": "0 5 8140 puppet-server-a.example.com" + }, + { + "zone": { + "name": "example.com" + }, + "type": "A", + "name": "puppet-server-b", + "value": "10.0.0.16" + }, + { + "zone": { + "name": "example.com" + }, + "type": "AAAA", + "name": "puppet-server-b", + "value": "2001:db8:dead:beef::1:16" + }, + { + "zone": { + "name": "example.com" + }, + "type": "SRV", + "name": "_x-puppet._tcp", + "value": "0 5 8140 puppet-server-b.example.com" + }, + { + "zone": { + "name": "example.com" + }, + "type": "A", + "name": "web", + "value": "10.0.0.17" + }, + { + "zone": { + "name": "example.com" + }, + "type": "AAAA", + "name": "web", + "value": "2001:db8:dead:beef::1:17" + }, + { + "zone": { + "name": "example.com" + }, + "type": "CNAME", + "name": "www", + "value": "web.example.com" + }, + { + "zone": { + "name": "example.com" + }, + "type": "NS", + "name": "sub", + "value": "dns01.example.com" + }, + { + "zone": { + "name": "example.com" + }, + "type": "NS", + "name": "sub", + "value": "dns02.example.com" + }, + { + "zone": { + "name": "example.com" + }, + "type": "NS", + "name": "subtwo", + "value": "dns01.example.com" + }, + { + "zone": { + "name": "example.com" + }, + "type": "NS", + "name": "subtwo", + "value": "dns02.example.com" + }, + { + "zone": { + "name": "sub.example.com" + }, + "type": "A", + "name": "myservice", + "value": "10.0.1.10" + }, + { + "zone": { + "name": "sub.example.com" + }, + "type": "AAAA", + "name": "myservice", + "value": "2001:db8:dead:beef::2:10" + }, + { + "zone": { + "name": "subtwo.example.com" + }, + "type": "A", + "name": "myotherservice", + "value": "10.0.2.10" + }, + { + "zone": { + "name": "subtwo.example.com" + }, + "type": "AAAA", + "name": "myotherservice", + "value": "2001:db8:dead:beef::3:10" + } +] \ No newline at end of file diff --git a/.testing/init/zones.json b/.testing/init/zones.json new file mode 100644 index 0000000..d5cf55d --- /dev/null +++ b/.testing/init/zones.json @@ -0,0 +1,209 @@ +[ + { + "name": "example.com", + "nameservers": [ + { + "name": "dns01.example.com" + }, + { + "name": "dns02.example.com" + } + ], + "default_ttl": 3600, + "soa_expire": 2419200, + "soa_minimum": 3600, + "soa_mname": { + "name": "dns01.example.com" + }, + "soa_ttl": 86400, + "soa_refresh": 43200, + "soa_retry": 7200, + "soa_rname": "admin.example.com", + "soa_serial_auto": false, + "soa_serial": 1 + }, + { + "name": "0.0.10.in-addr.arpa", + "nameservers": [ + { + "name": "dns01.example.com" + }, + { + "name": "dns02.example.com" + } + ], + "default_ttl": 3600, + "soa_expire": 2419200, + "soa_minimum": 3600, + "soa_mname": { + "name": "dns01.example.com" + }, + "soa_ttl": 86400, + "soa_refresh": 43200, + "soa_retry": 7200, + "soa_rname": "admin.example.com", + "soa_serial_auto": false, + "soa_serial": 1 + }, + { + "name": "1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa", + "nameservers": [ + { + "name": "dns01.example.com" + }, + { + "name": "dns02.example.com" + } + ], + "default_ttl": 3600, + "soa_expire": 2419200, + "soa_minimum": 3600, + "soa_mname": { + "name": "dns01.example.com" + }, + "soa_ttl": 86400, + "soa_refresh": 43200, + "soa_retry": 7200, + "soa_rname": "admin.example.com", + "soa_serial_auto": false, + "soa_serial": 1 + }, + { + "name": "sub.example.com", + "nameservers": [ + { + "name": "dns01.example.com" + }, + { + "name": "dns02.example.com" + } + ], + "default_ttl": 3600, + "soa_expire": 2419200, + "soa_minimum": 3600, + "soa_mname": { + "name": "dns01.example.com" + }, + "soa_ttl": 86400, + "soa_refresh": 43200, + "soa_retry": 7200, + "soa_rname": "admin.example.com", + "soa_serial_auto": false, + "soa_serial": 1 + }, + { + "name": "1.0.10.in-addr.arpa", + "nameservers": [ + { + "name": "dns01.example.com" + }, + { + "name": "dns02.example.com" + } + ], + "default_ttl": 3600, + "soa_expire": 2419200, + "soa_minimum": 3600, + "soa_mname": { + "name": "dns01.example.com" + }, + "soa_ttl": 86400, + "soa_refresh": 43200, + "soa_retry": 7200, + "soa_rname": "admin.example.com", + "soa_serial_auto": false, + "soa_serial": 1 + }, + { + "name": "2.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa", + "nameservers": [ + { + "name": "dns01.example.com" + }, + { + "name": "dns02.example.com" + } + ], + "default_ttl": 3600, + "soa_expire": 2419200, + "soa_minimum": 3600, + "soa_mname": { + "name": "dns01.example.com" + }, + "soa_ttl": 86400, + "soa_refresh": 43200, + "soa_retry": 7200, + "soa_rname": "admin.example.com", + "soa_serial_auto": false, + "soa_serial": 1 + }, + { + "name": "subtwo.example.com", + "nameservers": [ + { + "name": "dns01.example.com" + }, + { + "name": "dns02.example.com" + } + ], + "default_ttl": 3600, + "soa_expire": 2419200, + "soa_minimum": 3600, + "soa_mname": { + "name": "dns01.example.com" + }, + "soa_ttl": 86400, + "soa_refresh": 43200, + "soa_retry": 7200, + "soa_rname": "admin.example.com", + "soa_serial_auto": false, + "soa_serial": 1 + }, + { + "name": "2.0.10.in-addr.arpa", + "nameservers": [ + { + "name": "dns01.example.com" + }, + { + "name": "dns02.example.com" + } + ], + "default_ttl": 3600, + "soa_expire": 2419200, + "soa_minimum": 3600, + "soa_mname": { + "name": "dns01.example.com" + }, + "soa_ttl": 86400, + "soa_refresh": 43200, + "soa_retry": 7200, + "soa_rname": "admin.example.com", + "soa_serial_auto": false, + "soa_serial": 1 + }, + { + "name": "3.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa", + "nameservers": [ + { + "name": "dns01.example.com" + }, + { + "name": "dns02.example.com" + } + ], + "default_ttl": 3600, + "soa_expire": 2419200, + "soa_minimum": 3600, + "soa_mname": { + "name": "dns01.example.com" + }, + "soa_ttl": 86400, + "soa_refresh": 43200, + "soa_retry": 7200, + "soa_rname": "admin.example.com", + "soa_serial_auto": false, + "soa_serial": 1 + } +] \ No newline at end of file diff --git a/.testing/netbox.Containerfile b/.testing/netbox.Containerfile new file mode 100644 index 0000000..0951e40 --- /dev/null +++ b/.testing/netbox.Containerfile @@ -0,0 +1,13 @@ +FROM docker.io/netboxcommunity/netbox:latest + +COPY ./requirements-plugin.txt /opt/netbox/ +RUN /opt/netbox/venv/bin/pip install \ + --no-warn-script-location \ + -r /opt/netbox/requirements-plugin.txt + +COPY configuration/plugins.py /etc/netbox/config/plugins.py +RUN SECRET_KEY="dummydummydummydummydummydummydummydummydummydummy" \ + /opt/netbox/venv/bin/python \ + /opt/netbox/netbox/manage.py \ + collectstatic --no-input + diff --git a/.testing/requirements-plugin.txt b/.testing/requirements-plugin.txt new file mode 100644 index 0000000..46b9cf3 --- /dev/null +++ b/.testing/requirements-plugin.txt @@ -0,0 +1 @@ +netbox-plugin-dns >= 0.22.8 diff --git a/.testing/script.sh b/.testing/script.sh new file mode 100644 index 0000000..9c797a5 --- /dev/null +++ b/.testing/script.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +docker compose -p coredns-netbox-plugin-dns -f ./.testing/docker-compose.yml up -d && \ + +until [[ "`docker inspect -f {{.State.Health.Status}} coredns-netbox-plugin-dns-netbox-1`" == "healthy" ]]; do + echo "Waiting for Netbox to come online..." + sleep 5; +done && \ + +go run ./.testing/init/init.go diff --git a/.testing/tls/ca.pem b/.testing/tls/ca.pem new file mode 100644 index 0000000..cc4150d --- /dev/null +++ b/.testing/tls/ca.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICHDCCAcKgAwIBAgIUKqkR+rk8a0RIfpAdkRRr/jgdhhQwCgYIKoZIzj0EAwIw +bDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNp +c2NvMRcwFQYDVQQKEw5OZXRib3ggVGVzdGluZzEfMB0GA1UEAxMWTmV0Ym94IFRl +c3RpbmcgUm9vdCBDQTAeFw0yNDA1MDIwMDU1MDBaFw0yOTA1MDEwMDU1MDBaMGwx +CzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNj +bzEXMBUGA1UEChMOTmV0Ym94IFRlc3RpbmcxHzAdBgNVBAMTFk5ldGJveCBUZXN0 +aW5nIFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARInLLhQRy22ueR +FMUVzpzhllZkEkuOMQzDR83X9B5oKWWIhbEtjILLYOigf4BstV4O4NoaNbTF9gON +PRtkVL0ao0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQU7MXIkjUgpd/cvpDUOxIDczDKFR4wCgYIKoZIzj0EAwIDSAAwRQIhAPWc +++vwGVv0BNU2bWNLDQO8/T0izNv78rHfNuxKx/+OAiATSCSs7yDId9RW02sADVrL +4JeoHAlc/qsMltuTeXV+kA== +-----END CERTIFICATE----- diff --git a/.testing/tls/client-key.pem b/.testing/tls/client-key.pem new file mode 100644 index 0000000..99b3815 --- /dev/null +++ b/.testing/tls/client-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEILxY99DUkfyC9uFgkLzJoce2BkEwxI2FiBttKptbOFgBoAoGCCqGSM49 +AwEHoUQDQgAEM1w4sKz9to1SpdZ5whJK41t5JVAYivmFklD87IAQOKXqt5DKAX9r +Z8f/95FVt8qGOYkG4OYP4sCfi8g2pnd6Jg== +-----END EC PRIVATE KEY----- diff --git a/.testing/tls/client.pem b/.testing/tls/client.pem new file mode 100644 index 0000000..3aef19b --- /dev/null +++ b/.testing/tls/client.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICTzCCAfSgAwIBAgIUaz+i0MMtm6NZ73aB6OPZ7V4N8WIwCgYIKoZIzj0EAwIw +bDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNp +c2NvMRcwFQYDVQQKEw5OZXRib3ggVGVzdGluZzEfMB0GA1UEAxMWTmV0Ym94IFRl +c3RpbmcgUm9vdCBDQTAeFw0yNDA1MDIwMTA0MDBaFw0yOTA1MDEwMTA0MDBaMGsx +CzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNj +bzEXMBUGA1UEChMOTmV0Ym94IFRlc3RpbmcxHjAcBgNVBAMTFU5ldGJveCBUZXN0 +aW5nIENsaWVudDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABDNcOLCs/baNUqXW +ecISSuNbeSVQGIr5hZJQ/OyAEDil6reQygF/a2fH//eRVbfKhjmJBuDmD+LAn4vI +NqZ3eiajdTBzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAM +BgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTo33pMbsD0Qe4bfVoEA850oKgqEzAfBgNV +HSMEGDAWgBTsxciSNSCl39y+kNQ7EgNzMMoVHjAKBggqhkjOPQQDAgNJADBGAiEA +qLETeHL3iuG1Vxdey+VhEU4q5Xfp59mvR6YJksBT3oECIQCRSDRSo0t9nQh6U9wg +C/KvjPFLc0pYblQiiuQOlDtjXg== +-----END CERTIFICATE----- diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..cdbb6ae --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Test", + "type": "go", + "request": "launch", + "mode": "test", + "program": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..35a780a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "go.testFlags": ["-v"] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..9f9be7a --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,80 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "[Start] Netbox test instance", + "type": "docker-compose", + "dockerCompose": { + "projectName": "coredns-netbox-plugin-dns", + "up": { + "detached": true, + "build": true, + }, + "files": [ + "${workspaceFolder}/.testing/docker-compose.yml" + ] + } + }, + { + "label": "[Stop] Netbox test instance", + "type": "docker-compose", + "dockerCompose": { + "projectName": "coredns-netbox-plugin-dns", + "down": { + "removeVolumes": true + }, + "files": [ + "${workspaceFolder}/.testing/docker-compose.yml" + ] + } + }, + { + "label": "Go Coverage", + "type": "shell", + "command": "go", + "args": [ + "test", + "-short", + "-coverprofile=${workspaceFolder}/coverage.out", + "./..." + ], + "options": { + "env": { + "CGO_ENABLED": "0" + } + }, + "problemMatcher": { + "pattern": { + "regexp": ".*" + } + } + }, + { + "label": "Go View Coverage", + "type": "shell", + "linux": { + "command": "setsid", + "args": [ + "go", + "tool", + "cover", + "-html=${workspaceFolder}/coverage.out" + ] + }, + "windows": { + "command": "go", + "args": [ + "tool", + "cover", + "-html=${workspaceFolder}/coverage.out" + ] + }, + "dependsOn": "Go Coverage", + "problemMatcher": { + "pattern": { + "regexp": ".*" + } + } + }, + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..05a74e8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 W Anders + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..49b12d6 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# netboxdns + +[![Go Reference](https://pkg.go.dev/badge/github.com/doubleu-labs/coredns-netbox-plugin-dns.svg)](https://pkg.go.dev/github.com/doubleu-labs/coredns-netbox-plugin-dns) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=doubleu-labs_coredns-netbox-plugin-dns&metric=coverage)](https://sonarcloud.io/summary/overall?id=doubleu-labs_coredns-netbox-plugin-dns) +[![Go Report Card](https://goreportcard.com/badge/github.com/doubleu-labs/coredns-netbox-plugin-dns)](https://goreportcard.com/report/github.com/doubleu-labs/coredns-netbox-plugin-dns) + +*netboxdns* - provides resolution using +[Netbox DNS Plugin (netbox-plugin-dns)](https://github.com/peteeckel/netbox-plugin-dns) + +## Description + +The *netboxdns* plugin provides resolution for zones configured using +[netbox-plugin-dns](https://github.com/peteeckel/netbox-plugin-dns). + +**Depends on `netbox-plugin-dns` version `0.22.8` or greater.** + +The account that the API token is tied to will need the following permissions: + +- `netbox_dns.view_zone` +- `netbox_dns.view_record` + +## Syntax + +Available configuration options: + +```nginx +netboxdns [ZONES...] { + token TOKEN + url URL + timeout DURATION + fallthrough [ZONES...] + tls CERT KET CACERT +} +``` + +* **ZONES**: A space-delimited list of zones that the plugin will answer for + +* **`token TOKEN` (REQUIRED)**: The API token used to authenticate requests +to the Netbox instance + +* **`url URL` (REQUIRED)**: The URL that Netbox is accessible at + +* **`timeout DURATION`** (DEFAULT=`5s`): A duration to time-out requests to the +Netbox API + +* **`fallthrough`**: If no record exists, send the request to the next plugin. + * **(OPTIONAL) `ZONES...`**: A space-delimited list of zones that requests + should be forwarded to the next plugin. If requests are not in the specified + zones, an empty reponse is returned. + +* **`tls`**: Used to authenticate to the Netbox instance if it is using HTTPS. + * `0 arguments`: Creates a TLS configuration that uses system CA certificates + to validate the connection to the Netbox instance. Use when Netbox is using + a server certificate signed by a public CA. The client is not authenticated + by the server. + + * `1 argument`: Path to the CA PEM file. Creates a TLS configuration that uses + the specified CA certificate to validate the connection to the Netbox + instance. Use when Netbox is using a server certificate signed by a private + CA. The client is not authenticated by the server. + + * `2 arguments`: Paths to the client certificate and private key PEM files. + Creates a TLS configuration that uses system CA certificates to validate the + connection to the Netbox instance. Use when certificates are needed to + authenticate to the Netbox instance (mTLS) (Netbox Cloud). + + * `3 arguments`: Paths to the client certificate, private key, and CA PEM + files. Creates a TLS configuration that uses the specified CA certificate to + validate the connection to the Netbox instance. Use when certificates are + needed to authenticate to the Netbox instance (mTLS) and Netbox is using a + server certificate signed by a private CA. + +## Building + +Clone the [coredns](https://github.com/coredns/coredns) repository and change +into it's directory. + +```sh +git clone https://github.com/coredns/coredns.git +``` + +```sh +cd coredns +``` + +Fetch the plugin and add it to `coredns`'s `go.mod` file: + +```sh +go get -u github.com/doubleu-labs/coredns-netbox-plugin-dns +``` + +Update `plugin.cfg` in the root of the directory. The `netboxdns` declaration +should be inserted after `cache` if you want responses from Netbox to be +cached. + +```sh +# Using sed +sed -i '/^cache:cache/a netboxdns:github.com/doubleu-labs/coredns-netbox-plugin-dns' plugin.cfg +``` + +```powershell +# Using Powershell +(Get-Content plugin.cfg).` +Replace("cache:cache", "cache:cache`nnetboxdns:github.com/doubleu-labs/coredns-netbox-plugin-dns") | ` +Set-Content -Path plugin.cfg +``` + +Build using `make`: + +```sh +make +``` + +Or if `make` is not available, simply run: + +```sh +go generate && go build +``` + +The `coredns` binary will be in the root of the project directory, unless +otherwise specified by the `-o` flag. + +## Contributing + +A [Docker Compose file](./.testing/docker-compose.yml) is provided to setup a +minimal Netbox instance to run tests against. If using Visual Studio Code, two +tasks are configured to start and stop this instance. Use `Ctrl+Shift+P` and +select `[Start] Netbox test instance`. + +Check that Netbox is finished with the initial setup by watching the container +logs using: + +```sh +docker logs -f coredns-netbox-plugin-dns-netbox-1 +``` + +The test instance will be available at +[http://localhost:9999](http://localhost:9999/) with the `admin:admin` username +and password. When you see healthcheck requests, invoke +[init.go](./.testing/init/init.go) to populate the test dataset. + +```sh +go run .testing/init/init.go +``` + +This standalone application POSTs the contents of the +JSON files in [.testing/init](./.testing/init/) to populate the database. If +adding a new feature or bugfix that requires additional records, be sure to add +the Zone or Record to the appropriate JSON file. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8037cb0 --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module github.com/doubleu-labs/coredns-netbox-plugin-dns + +go 1.22.2 + +require ( + github.com/coredns/caddy v1.1.1 + github.com/coredns/coredns v1.11.3 + github.com/miekg/dns v1.1.59 +) + +require ( + github.com/apparentlymart/go-cidr v1.1.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/pprof v0.0.0-20240430035430-e4905b036c4e // indirect + github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/onsi/ginkgo/v2 v2.17.2 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.19.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.53.0 // indirect + github.com/prometheus/procfs v0.14.0 // indirect + github.com/quic-go/quic-go v0.43.0 // indirect + go.uber.org/mock v0.4.0 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.20.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6 // indirect + google.golang.org/grpc v1.63.2 // indirect + google.golang.org/protobuf v1.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..95bb42a --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4tdgBZjnU= +github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coredns/caddy v1.1.1 h1:2eYKZT7i6yxIfGP3qLJoJ7HAsDJqYB+X68g4NYjSrE0= +github.com/coredns/caddy v1.1.1/go.mod h1:A6ntJQlAWuQfFlsd9hvigKbo2WS0VUs2l1e2F+BawD4= +github.com/coredns/coredns v1.11.3 h1:8RjnpZc42db5th84/QJKH2i137ecJdzZK1HJwhetSPk= +github.com/coredns/coredns v1.11.3/go.mod h1:lqFkDsHjEUdY7LJ75Nib3lwqJGip6ewWOqNIf8OavIQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20240430035430-e4905b036c4e h1:RsXNnXE59RTt8o3DcA+w7ICdRfR2l+Bb5aE0YMpNTO8= +github.com/google/pprof v0.0.0-20240430035430-e4905b036c4e/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= +github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk= +github.com/onsi/ginkgo/v2 v2.17.2 h1:7eMhcy3GimbsA3hEnVKdw/PQM9XN9krpKVXsZdph0/g= +github.com/onsi/ginkgo/v2 v2.17.2/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc= +github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE= +github.com/onsi/gomega v1.33.0/go.mod h1:+925n5YtiFsLzzafLUHzVMBpvvRAzrydIBiSIxjX3wY= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= +github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= +github.com/prometheus/procfs v0.14.0 h1:Lw4VdGGoKEZilJsayHf0B+9YgLGREba2C6xr+Fdfq6s= +github.com/prometheus/procfs v0.14.0/go.mod h1:XL+Iwz8k8ZabyZfMFHPiilCniixqQarAy5Mu67pHlNQ= +github.com/quic-go/quic-go v0.43.0 h1:sjtsTKWX0dsHpuMJvLxGqoQdtgJnbAPWY+W+5vjYW/g= +github.com/quic-go/quic-go v0.43.0/go.mod h1:132kz4kL3F9vxhW3CtQJLDVwcFe5wdWeJXXijhsO57M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= +golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= +golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6 h1:DujSIu+2tC9Ht0aPNA7jgj23Iq8Ewi5sgkQ++wdvonE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= +google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/netbox/api.go b/internal/netbox/api.go new file mode 100644 index 0000000..874ae3c --- /dev/null +++ b/internal/netbox/api.go @@ -0,0 +1,111 @@ +package netbox + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +type APIRequestClient struct { + Client *http.Client + NetboxURL *url.URL + Token string + UserAgent string +} + +type APIResultModel interface { + Record | Zone +} + +type APIManyResponse[T APIResultModel] struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []T `json:"results"` +} + +func doGet( + requestClient *APIRequestClient, + url string, +) (*http.Response, error) { + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + request.Header.Set( + "Authorization", + fmt.Sprintf("Token %s", requestClient.Token), + ) + + request.Header.Set("User-Agent", requestClient.UserAgent) + + return requestClient.Client.Do(request) +} + +func responseError(response *http.Response) error { + if response.StatusCode != http.StatusOK { + return fmt.Errorf( + "request error [%d] %q", + response.StatusCode, + response.Status, + ) + } + return nil +} + +func get[T APIResultModel]( + requestClient *APIRequestClient, + url string, +) (T, error) { + var out T + response, err := doGet(requestClient, url) + if err != nil { + return out, err + } + defer response.Body.Close() + if err := responseError(response); err != nil { + return out, err + } + decoder := json.NewDecoder(response.Body) + if err := decoder.Decode(&out); err != nil { + return out, fmt.Errorf("could not unmarshal response: %w", err) + } + return out, nil +} + +func getMany[T APIResultModel]( + requestClient *APIRequestClient, + url string, +) ([]T, error) { + nextUrl := url + var out []T + + for nextUrl != "" { + response, err := doGet(requestClient, nextUrl) + if err != nil { + return out, err + } + defer response.Body.Close() + + if err := responseError(response); err != nil { + return out, err + } + + var apiResponse APIManyResponse[T] + decoder := json.NewDecoder(response.Body) + if err := decoder.Decode(&apiResponse); err != nil { + return out, fmt.Errorf("could not unmarshal response: %w", err) + } + + if out == nil { + out = make([]T, 0, apiResponse.Count) + } + out = append(out, apiResponse.Results...) + + nextUrl = apiResponse.Next + } + + return out, nil +} diff --git a/internal/netbox/record.go b/internal/netbox/record.go new file mode 100644 index 0000000..1bb9616 --- /dev/null +++ b/internal/netbox/record.go @@ -0,0 +1,99 @@ +package netbox + +import ( + "net/url" + "strconv" +) + +type Record struct { + Type string `json:"type"` + Value string `json:"value"` + TTL *uint32 `json:"ttl"` + Zone Zone `json:"zone"` + FQDN string `json:"fqdn"` +} + +type RecordQuery struct { + FQDN string + Name string + Type []string + Zone *Zone +} + +func (recordQuery *RecordQuery) Encode() string { + out := url.Values{} + + if recordQuery.FQDN != "" { + out.Set("fqdn", recordQuery.FQDN) + } + + if recordQuery.Name != "" { + out.Set("name", recordQuery.Name) + } + + if len(recordQuery.Type) != 0 { + for _, t := range recordQuery.Type { + out.Add("type", t) + } + } + + if recordQuery.Zone != nil { + out.Set("zone_id", strconv.Itoa(recordQuery.Zone.ID)) + } + + return out.Encode() +} + +func urlRecords(netboxurl *url.URL) *url.URL { + return netboxurl.JoinPath("records", "/") +} + +func GetRecordsQuery( + requestClient *APIRequestClient, + query *RecordQuery, +) ([]Record, error) { + requestUrl := urlRecords(requestClient.NetboxURL) + requestUrl.RawQuery = query.Encode() + records, err := getMany[Record](requestClient, requestUrl.String()) + if err != nil { + return nil, err + } + if query.Zone != nil { + for k, record := range records { + if record.TTL == nil { + records[k].TTL = &query.Zone.DefaultTTL + } + } + } else { + resolvedRecords, err := resolveRecordTTLs(requestClient, records) + if err != nil { + return records, err + } + records = resolvedRecords + } + return records, nil +} + +func resolveRecordTTLs( + requestClient *APIRequestClient, + records []Record, +) ([]Record, error) { + zoneTTL := make(map[int]uint32) + for k, record := range records { + if record.TTL != nil { + continue + } + if ttl, ok := zoneTTL[record.Zone.ID]; ok { + records[k].TTL = &ttl + continue + } + zoneUrl := urlZoneID(requestClient.NetboxURL, record.Zone.ID) + zone, err := get[Zone](requestClient, zoneUrl.String()) + if err != nil { + return records, err + } + zoneTTL[zone.ID] = zone.DefaultTTL + records[k].TTL = &zone.DefaultTTL + } + return records, nil +} diff --git a/internal/netbox/zone.go b/internal/netbox/zone.go new file mode 100644 index 0000000..57d7f0f --- /dev/null +++ b/internal/netbox/zone.go @@ -0,0 +1,34 @@ +package netbox + +import ( + "net/url" + "strconv" +) + +type Zone struct { + DefaultTTL uint32 `json:"default_ttl"` + ID int `json:"id"` + Name string `json:"name"` + NameServers []SOAMName `json:"nameservers"` +} + +type SOAMName struct { + Name string `json:"name"` +} + +func urlZones(netboxurl *url.URL) *url.URL { + return netboxurl.JoinPath("zones", "/") +} + +func urlZoneID(netboxurl *url.URL, id int) *url.URL { + return netboxurl.JoinPath("zones", "/", strconv.Itoa(id), "/") +} + +func GetZones(requestClient *APIRequestClient) ([]Zone, error) { + requestUrl := urlZones(requestClient.NetboxURL) + zones, err := getMany[Zone](requestClient, requestUrl.String()) + if err != nil { + return nil, err + } + return zones, nil +} diff --git a/lookup.go b/lookup.go new file mode 100644 index 0000000..75e4c5a --- /dev/null +++ b/lookup.go @@ -0,0 +1,273 @@ +package netboxdns + +import ( + "strings" + + "github.com/doubleu-labs/coredns-netbox-plugin-dns/internal/netbox" + "github.com/miekg/dns" +) + +type lookupResult int + +const ( + lookupSuccess lookupResult = iota + lookupNameError // NXDomain + lookupDelegation // Delegate, non-authoritative +) + +type lookupResponse struct { + Answer []dns.RR + Ns []dns.RR + Extra []dns.RR + LookupResult lookupResult +} + +func (netboxdns *NetboxDNS) lookup( + name string, + qtype uint16, + family int, +) (*lookupResponse, error) { + nameTrimmed := strings.TrimSuffix(name, ".") + // check if zone exists on Netbox + zone, err := netboxdns.matchZone(nameTrimmed) + if err != nil { + return nil, err + } + if zone == nil { + return &lookupResponse{LookupResult: lookupNameError}, nil + } + + // check if qname is for zone origin + if nameTrimmed == zone.Name { + originResponse, err := netboxdns.processOrigin(qtype, zone, family) + if err != nil { + return nil, err + } + if originResponse != nil { + return originResponse, nil + } + } + + // lookup exact request + direct, err := netboxdns.lookupDirect(nameTrimmed, qtype, zone, family) + if err != nil { + return nil, err + } + if direct != nil { + return direct, nil + } + + // if no exact records exist for the request, check if the qname is a + // delegate zone + delegate, err := netboxdns.lookupDelegate(nameTrimmed, zone, family) + if err != nil { + return nil, err + } + if delegate != nil { + return delegate, nil + } + + return &lookupResponse{LookupResult: lookupNameError}, nil +} + +func (netboxdns *NetboxDNS) matchZone(qname string) (*netbox.Zone, error) { + managedZones, err := netbox.GetZones(netboxdns.requestClient) + if err != nil { + return nil, err + } + var out *netbox.Zone + for _, managedZone := range managedZones { + if dns.IsSubDomain(managedZone.Name, qname) { + if out == nil { + out = &managedZone + } + if len(managedZone.Name) > len(out.Name) { + out = &managedZone + } + } + } + return out, nil +} + +func (netboxdns *NetboxDNS) processOrigin( + qtype uint16, + zone *netbox.Zone, + family int, +) (*lookupResponse, error) { + var queryType []string + switch qtype { + case dns.TypeSOA: + queryType = []string{"SOA", "NS"} + case dns.TypeNS: + queryType = []string{"NS"} + default: + return nil, nil + } + records, err := netbox.GetRecordsQuery( + netboxdns.requestClient, + &netbox.RecordQuery{ + Name: "@", + Type: queryType, + Zone: zone, + }, + ) + if err != nil { + return nil, err + } + rrs, err := recordsToRR(records) + if err != nil { + return nil, err + } + answer := filterRRByType(rrs, dns.TypeSOA) + ns := filterRRByType(rrs, dns.TypeNS) + extraRecords, err := netboxdns.processExtra(ns, zone, family) + if err != nil { + return nil, err + } + if len(extraRecords) == 0 { + // if no A/AAAA records exist for the NS in the specified zone, check if + // the server has records anywhere + extraRecords, err = netboxdns.processExtra(ns, nil, family) + if err != nil { + return nil, err + } + } + extra, err := recordsToRR(extraRecords) + if err != nil { + return nil, err + } + if qtype == dns.TypeNS { + answer = ns + ns = nil + } + return &lookupResponse{ + Answer: answer, + Ns: ns, + Extra: extra, + }, nil +} + +func (netboxdns *NetboxDNS) processExtra( + answer []dns.RR, + zone *netbox.Zone, + family int, +) ([]netbox.Record, error) { + var out []netbox.Record + for _, rr := range answer { + name := "" + switch t := rr.(type) { + case *dns.SRV: + name = t.Target + case *dns.MX: + name = t.Mx + case *dns.NS: + name = t.Ns + case *dns.CNAME: + name = t.Target + } + if len(name) == 0 { + continue + } + var reqType []string + switch family { + case 1: + reqType = []string{"A"} + case 2: + reqType = []string{"AAAA"} + } + records, err := netbox.GetRecordsQuery( + netboxdns.requestClient, + &netbox.RecordQuery{ + FQDN: strings.TrimSuffix(name, "."), + Type: reqType, + Zone: zone, + }, + ) + if err != nil { + return out, err + } + out = append(out, records...) + } + return out, nil +} + +func (netboxdns *NetboxDNS) lookupDirect( + qname string, + qtype uint16, + zone *netbox.Zone, + family int, +) (*lookupResponse, error) { + records, err := netbox.GetRecordsQuery( + netboxdns.requestClient, + &netbox.RecordQuery{ + FQDN: qname, + Type: []string{dns.TypeToString[qtype]}, + Zone: zone, + }, + ) + if err != nil { + return nil, err + } + + if len(records) > 0 { + answer, err := recordsToRR(records) + if err != nil { + return nil, err + } + extraRecords, err := netboxdns.processExtra(answer, zone, family) + if err != nil { + return nil, err + } + extra, err := recordsToRR(extraRecords) + if err != nil { + return nil, err + } + if qtype == dns.TypeCNAME { + answer = append(answer, extra...) + extra = nil + } + return &lookupResponse{ + Answer: answer, + Extra: extra, + }, nil + } + return nil, nil +} + +func (netboxdns *NetboxDNS) lookupDelegate( + qname string, + zone *netbox.Zone, + family int, +) (*lookupResponse, error) { + records, err := netbox.GetRecordsQuery( + netboxdns.requestClient, + &netbox.RecordQuery{ + FQDN: qname, + Type: []string{"NS"}, + Zone: zone, + }, + ) + if err != nil { + return nil, err + } + if len(records) > 0 { + ns, err := recordsToRR(records) + if err != nil { + return nil, err + } + extraRecords, err := netboxdns.processExtra(ns, nil, family) + if err != nil { + return nil, err + } + extra, err := recordsToRR(extraRecords) + if err != nil { + return nil, err + } + return &lookupResponse{ + Ns: ns, + Extra: extra, + LookupResult: lookupDelegation, + }, nil + } + return nil, nil +} diff --git a/netboxdns.go b/netboxdns.go new file mode 100644 index 0000000..86d94c9 --- /dev/null +++ b/netboxdns.go @@ -0,0 +1,109 @@ +package netboxdns + +import ( + "context" + "net/http" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/fall" + "github.com/coredns/coredns/plugin/pkg/log" + "github.com/coredns/coredns/request" + "github.com/doubleu-labs/coredns-netbox-plugin-dns/internal/netbox" + "github.com/miekg/dns" +) + +const ( + defaultHTTPClientTimeout time.Duration = time.Second * 5 + pluginName string = "netboxdns" +) + +var logger log.P + +func init() { + logger = log.NewWithPlugin(pluginName) +} + +type NetboxDNS struct { + Next plugin.Handler + + requestClient *netbox.APIRequestClient + + zones []string + fall fall.F +} + +func NewNetboxDNS() *NetboxDNS { + return &NetboxDNS{ + requestClient: &netbox.APIRequestClient{ + Client: &http.Client{ + Timeout: defaultHTTPClientTimeout, + }, + }, + zones: []string{"."}, + } +} + +// Name implements the plugin.Handler interface +func (NetboxDNS) Name() string { + return pluginName +} + +// ServeDNS implements the plugin.Handler interface +func (netboxdns *NetboxDNS) ServeDNS( + reqContext context.Context, + respWriter dns.ResponseWriter, + reqMsg *dns.Msg, +) (int, error) { + state := request.Request{W: respWriter, Req: reqMsg} + qname := state.QName() + qtype := state.QType() + + // check if plugin is configured to respond to the requested zone + respondingZone := plugin.Zones(netboxdns.zones).Matches(qname) + if respondingZone == "" { + return netboxdns.nextOrFailure(reqContext, respWriter, reqMsg) + } + + response, err := netboxdns.lookup(qname, qtype, state.Family()) + if err != nil { + return dns.RcodeServerFailure, err + } + if response.LookupResult == lookupNameError && + netboxdns.fall.Through(qname) { + return netboxdns.nextOrFailure(reqContext, respWriter, reqMsg) + } + + respMsg := &dns.Msg{ + Answer: response.Answer, + Ns: response.Ns, + Extra: response.Extra, + } + respMsg.SetReply(reqMsg) + respMsg.Authoritative = true + + switch response.LookupResult { + case lookupSuccess: + case lookupNameError: + respMsg.Rcode = dns.RcodeNameError + case lookupDelegation: + respMsg.Authoritative = false + } + + respWriter.WriteMsg(respMsg) + return dns.RcodeSuccess, nil +} + +func (netboxdns *NetboxDNS) nextOrFailure( + ctx context.Context, + writer dns.ResponseWriter, + request *dns.Msg, +) (int, error) { + return plugin.NextOrFailure( + pluginName, + netboxdns.Next, + ctx, + writer, + request, + ) +} diff --git a/netboxdns_test.go b/netboxdns_test.go new file mode 100644 index 0000000..5da7dd7 --- /dev/null +++ b/netboxdns_test.go @@ -0,0 +1,788 @@ +package netboxdns + +import ( + "context" + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + "github.com/doubleu-labs/coredns-netbox-plugin-dns/internal/netbox" + "github.com/miekg/dns" +) + +func TestPluginName(t *testing.T) { + netboxdns := &NetboxDNS{} + name := netboxdns.Name() + if name != pluginName { + t.Errorf("plugin name is wrong. how did this happen...") + } +} + +type testFamily int + +const ( + testFamilyV4 testFamily = iota + testFamilyV6 +) + +var testFamilyToString map[testFamily]string = map[testFamily]string{ + testFamilyV4: "V4", + testFamilyV6: "V6", +} + +const ( + testInstanceToken string = "w5pgWXPqZVmngLN4w4XwuPvZfUC72ytDxnnHgEmI" + testInstanceUrlHost string = "localhost:9999" + testInstanceUrlPath string = "/api/plugins/netbox-dns/" +) + +var netboxdnsPlugin NetboxDNS = NetboxDNS{ + Next: test.ErrorHandler(), + zones: []string{"."}, + requestClient: &netbox.APIRequestClient{ + Client: &http.Client{ + Timeout: defaultHTTPClientTimeout, + }, + NetboxURL: &url.URL{ + Scheme: "http", + Host: testInstanceUrlHost, + Path: testInstanceUrlPath, + }, + Token: testInstanceToken, + }, +} + +func RunTestLookup(t *testing.T, tcs []test.Case, family testFamily) { + for _, tc := range tcs { + tcName := fmt.Sprintf( + "%s %s %s", + tc.Qname, + dns.TypeToString[tc.Qtype], + testFamilyToString[family], + ) + t.Run(tcName, func(t *testing.T) { + msg := tc.Msg() + respWriter := GetTestResponseWriter(family) + rec := dnstest.NewRecorder(respWriter) + _, err := netboxdnsPlugin.ServeDNS(context.Background(), rec, msg) + if err != nil { + t.Errorf("expected no error, got %v", err) + return + } + resp := rec.Msg + if resp == nil { + t.Fatal("got nil response message") + } + if ok := RunTestLookupCheckCNAME(t, tc, resp); !ok { + return + } + if err := test.SortAndCheck(resp, tc); err != nil { + t.Logf("%s\n", rec.Msg) + t.Error(err) + } + }) + } +} + +func GetTestResponseWriter(family testFamily) dns.ResponseWriter { + var respWriter dns.ResponseWriter + switch family { + case testFamilyV4: + respWriter = &test.ResponseWriter{} + case testFamilyV6: + respWriter = &test.ResponseWriter6{} + } + return respWriter +} + +func RunTestLookupCheckCNAME(t *testing.T, tc test.Case, resp *dns.Msg) bool { + if err := test.CNAMEOrder(resp); err != nil { + t.Errorf("cname response out of order") + } + if tc.Qtype == dns.TypeCNAME { + if err := test.Header(tc, resp); err != nil { + t.Error(err) + } + if err := test.Section(tc, test.Answer, resp.Answer); err != nil { + t.Error(err) + } + if err := test.Section(tc, test.Ns, resp.Ns); err != nil { + t.Error(err) + } + if err := test.Section(tc, test.Extra, resp.Extra); err != nil { + t.Error(err) + } + return false + } + return true +} + +var ( + exampledotcomName string = "example.com." + subdotexampledotcomName string = "sub.example.com." + subtwodotexampledotcomName string = "subtwo.example.com." + + exampledotcomNS []dns.RR = []dns.RR{ + test.NS("example.com. 3600 IN NS dns01.example.com."), + test.NS("example.com. 3600 IN NS dns02.example.com."), + } + subdotexampledotcomNS []dns.RR = []dns.RR{ + test.NS("sub.example.com. 3600 IN NS dns01.example.com."), + test.NS("sub.example.com. 3600 IN NS dns02.example.com."), + } + subtwodotexampledotcomNS []dns.RR = []dns.RR{ + test.NS("subtwo.example.com. 3600 IN NS dns01.example.com."), + test.NS("subtwo.example.com. 3600 IN NS dns02.example.com."), + } + exampledotcomNS1Record4 dns.RR = test.A("dns01.example.com. 3600 IN A 10.0.0.10") + exampledotcomNS2Record4 dns.RR = test.A("dns02.example.com. 3600 IN A 10.0.0.11") + exampledotcomNSAddr4 []dns.RR = []dns.RR{ + exampledotcomNS1Record4, + exampledotcomNS2Record4, + } + exampledotcomNS1Record6 dns.RR = test.AAAA("dns01.example.com. 3600 IN AAAA 2001:db8:dead:beef::1:10") + exampledotcomNS2Record6 dns.RR = test.AAAA("dns02.example.com. 3600 IN AAAA 2001:db8:dead:beef::1:11") + exampledotcomNSAddr6 []dns.RR = []dns.RR{ + exampledotcomNS1Record6, + exampledotcomNS2Record6, + } +) + +var ( + testLookupForwardZonesCasesv4 []test.Case = []test.Case{ + { + Qname: exampledotcomName, Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("example.com. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: exampledotcomNS, + Extra: exampledotcomNSAddr4, + }, + { + Qname: subdotexampledotcomName, Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("sub.example.com. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: []dns.RR{ + test.NS("sub.example.com. 3600 IN NS dns01.example.com"), + test.NS("sub.example.com. 3600 IN NS dns02.example.com"), + }, + Extra: exampledotcomNSAddr4, + }, + { + Qname: subtwodotexampledotcomName, Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("subtwo.example.com. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: []dns.RR{ + test.NS("subtwo.example.com. 3600 IN NS dns01.example.com"), + test.NS("subtwo.example.com. 3600 IN NS dns02.example.com"), + }, + Extra: exampledotcomNSAddr4, + }, + } + + testLookupReverseZonesCasesv4 []test.Case = []test.Case{ + { + Qname: "0.0.10.in-addr.arpa.", Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("0.0.10.in-addr.arpa. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: []dns.RR{ + test.NS("0.0.10.in-addr.arpa. 3600 IN NS dns01.example.com"), + test.NS("0.0.10.in-addr.arpa. 3600 IN NS dns02.example.com"), + }, + Extra: exampledotcomNSAddr4, + }, + { + Qname: "1.0.10.in-addr.arpa.", Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("1.0.10.in-addr.arpa. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: []dns.RR{ + test.NS("1.0.10.in-addr.arpa. 3600 IN NS dns01.example.com"), + test.NS("1.0.10.in-addr.arpa. 3600 IN NS dns02.example.com"), + }, + Extra: exampledotcomNSAddr4, + }, + { + Qname: "2.0.10.in-addr.arpa.", Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("2.0.10.in-addr.arpa. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: []dns.RR{ + test.NS("2.0.10.in-addr.arpa. 3600 IN NS dns01.example.com"), + test.NS("2.0.10.in-addr.arpa. 3600 IN NS dns02.example.com"), + }, + Extra: exampledotcomNSAddr4, + }, + } + + testLookupForwardZonesCasesv6 []test.Case = []test.Case{ + { + Qname: exampledotcomName, Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("example.com. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: exampledotcomNS, + Extra: exampledotcomNSAddr6, + }, + { + Qname: subdotexampledotcomName, Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("sub.example.com. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: []dns.RR{ + test.NS("sub.example.com. 3600 IN NS dns01.example.com"), + test.NS("sub.example.com. 3600 IN NS dns02.example.com"), + }, + Extra: exampledotcomNSAddr6, + }, + { + Qname: subtwodotexampledotcomName, Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("subtwo.example.com. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: []dns.RR{ + test.NS("subtwo.example.com. 3600 IN NS dns01.example.com"), + test.NS("subtwo.example.com. 3600 IN NS dns02.example.com"), + }, + Extra: exampledotcomNSAddr6, + }, + } + + testLookupReverseZonesCasesv6 []test.Case = []test.Case{ + { + Qname: "1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: []dns.RR{ + test.NS("1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN NS dns01.example.com"), + test.NS("1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN NS dns02.example.com"), + }, + Extra: exampledotcomNSAddr6, + }, + { + Qname: "2.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("2.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: []dns.RR{ + test.NS("2.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN NS dns01.example.com"), + test.NS("2.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN NS dns02.example.com"), + }, + Extra: exampledotcomNSAddr6, + }, + { + Qname: "3.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("3.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 86400 IN SOA dns01.example.com. admin.example.com. 1 43200 7200 2419200 3600"), + }, + Ns: []dns.RR{ + test.NS("3.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN NS dns01.example.com"), + test.NS("3.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN NS dns02.example.com"), + }, + Extra: exampledotcomNSAddr6, + }, + } +) + +func TestLookupZones(t *testing.T) { + RunTestLookup(t, testLookupForwardZonesCasesv4, testFamilyV4) + RunTestLookup(t, testLookupReverseZonesCasesv4, testFamilyV4) + RunTestLookup(t, testLookupForwardZonesCasesv6, testFamilyV6) + RunTestLookup(t, testLookupReverseZonesCasesv6, testFamilyV6) +} + +var ( + testLookupRecordV4 []test.Case = []test.Case{ + { + Qname: exampledotcomName, Qtype: dns.TypeNS, + Answer: exampledotcomNS, + Extra: exampledotcomNSAddr4, + }, + { + Qname: "dns01.example.com", Qtype: dns.TypeA, + Answer: []dns.RR{ + exampledotcomNS1Record4, + }, + }, + { + Qname: "dns02.example.com", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("dns02.example.com. 3600 IN A 10.0.0.11"), + }, + }, + { + Qname: exampledotcomName, Qtype: dns.TypeA, + Ns: exampledotcomNS, + Extra: exampledotcomNSAddr4, + }, + { + Qname: "aservice.example.com.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("aservice.example.com. 3600 IN A 10.0.0.12"), + }, + }, + { + Qname: exampledotcomName, Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("example.com. 3600 IN MX 10 mail.example.com."), + }, + Extra: []dns.RR{ + test.A("mail.example.com. 3600 IN A 10.0.0.13"), + }, + }, + { + Qname: "mail.example.com.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("mail.example.com. 3600 IN A 10.0.0.13"), + }, + }, + { + Qname: exampledotcomName, Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT(`example.com. 3600 IN TXT "my value" "second my value" "third my value"`), + test.TXT(`example.com. 3600 IN TXT "newline record" "second value"`), + test.TXT(`example.com. 3600 IN TXT "some value" "another value"`), + test.TXT(`example.com. 3600 IN TXT "v=DMARC1;p=none;sp=quarantine;pct=100;rua=admin@example.com;"`), + test.TXT(`example.com. 3600 IN TXT "v=spf1 ip4:10.0.0.13 ip6:2001:db8:dead:beef::1:13 a -all"`), + }, + }, + { + Qname: "puppet-server-a.example.com.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("puppet-server-a.example.com. 3600 IN A 10.0.0.15"), + }, + }, + { + Qname: "puppet-server-b.example.com.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("puppet-server-b.example.com. 3600 IN A 10.0.0.16"), + }, + }, + { + Qname: "_x-puppet._tcp.example.com.", Qtype: dns.TypeSRV, + Answer: []dns.RR{ + test.SRV("_x-puppet._tcp.example.com. 3600 IN SRV 0 5 8140 puppet-server-a.example.com."), + test.SRV("_x-puppet._tcp.example.com. 3600 IN SRV 0 5 8140 puppet-server-b.example.com."), + }, + Extra: []dns.RR{ + test.A("puppet-server-a.example.com. 3600 IN A 10.0.0.15"), + test.A("puppet-server-b.example.com. 3600 IN A 10.0.0.16"), + }, + }, + { + Qname: "web.example.com.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("web.example.com. 3600 IN A 10.0.0.17"), + }, + }, + { + Qname: "www.example.com.", Qtype: dns.TypeCNAME, + Answer: []dns.RR{ + test.CNAME("www.example.com. 3600 IN CNAME web.example.com."), + test.A("web.example.com. 3600 IN A 10.0.0.17"), + }, + }, + { + Qname: subdotexampledotcomName, Qtype: dns.TypeNS, + Answer: subdotexampledotcomNS, + Extra: exampledotcomNSAddr4, + }, + { + Qname: "myservice.sub.example.com.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("myservice.sub.example.com. 3600 IN A 10.0.1.10"), + }, + }, + { + Qname: subtwodotexampledotcomName, Qtype: dns.TypeNS, + Answer: subtwodotexampledotcomNS, + Extra: exampledotcomNSAddr4, + }, + { + Qname: "myotherservice.subtwo.example.com.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("myotherservice.subtwo.example.com. 3600 IN A 10.0.2.10"), + }, + }, + } + + testLookupPTRV4 []test.Case = []test.Case{ + { + Qname: "10.0.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("10.0.0.10.in-addr.arpa. 3600 IN PTR dns01.example.com."), + }, + }, + { + Qname: "11.0.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("11.0.0.10.in-addr.arpa. 3600 IN PTR dns02.example.com."), + }, + }, + { + Qname: "12.0.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("12.0.0.10.in-addr.arpa. 3600 IN PTR aservice.example.com."), + }, + }, + { + Qname: "13.0.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("13.0.0.10.in-addr.arpa. 3600 IN PTR mail.example.com."), + }, + }, + { + Qname: "15.0.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("15.0.0.10.in-addr.arpa. 3600 IN PTR puppet-server-a.example.com."), + }, + }, + { + Qname: "16.0.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("16.0.0.10.in-addr.arpa. 3600 IN PTR puppet-server-b.example.com."), + }, + }, + { + Qname: "17.0.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("17.0.0.10.in-addr.arpa. 3600 IN PTR web.example.com."), + }, + }, + { + Qname: "10.1.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("10.1.0.10.in-addr.arpa. 3600 IN PTR myservice.sub.example.com."), + }, + }, + { + Qname: "10.2.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("10.2.0.10.in-addr.arpa. 3600 IN PTR myotherservice.subtwo.example.com."), + }, + }, + } + + testLookupRecordV6 []test.Case = []test.Case{ + { + Qname: exampledotcomName, Qtype: dns.TypeNS, + Answer: exampledotcomNS, + Extra: exampledotcomNSAddr6, + }, + { + Qname: "dns01.example.com", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + exampledotcomNS1Record6, + }, + }, + { + Qname: "dns02.example.com", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + exampledotcomNS2Record6, + }, + }, + { + Qname: exampledotcomName, Qtype: dns.TypeAAAA, + Ns: exampledotcomNS, + Extra: exampledotcomNSAddr6, + }, + { + Qname: "aservice.example.com.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + test.AAAA("aservice.example.com. 3600 IN AAAA 2001:db8:dead:beef::1:12"), + }, + }, + { + Qname: exampledotcomName, Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("example.com. 3600 IN MX 10 mail.example.com."), + }, + Extra: []dns.RR{ + test.AAAA("mail.example.com. 3600 IN AAAA 2001:db8:dead:beef::1:13"), + }, + }, + { + Qname: "mail.example.com.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + test.AAAA("mail.example.com. 3600 IN AAAA 2001:db8:dead:beef::1:13"), + }, + }, + { + Qname: exampledotcomName, Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT(`example.com. 3600 IN TXT "my value" "second my value" "third my value"`), + test.TXT(`example.com. 3600 IN TXT "newline record" "second value"`), + test.TXT(`example.com. 3600 IN TXT "some value" "another value"`), + test.TXT(`example.com. 3600 IN TXT "v=DMARC1;p=none;sp=quarantine;pct=100;rua=admin@example.com;"`), + test.TXT(`example.com. 3600 IN TXT "v=spf1 ip4:10.0.0.13 ip6:2001:db8:dead:beef::1:13 a -all"`), + }, + }, + { + Qname: "puppet-server-a.example.com.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + test.AAAA("puppet-server-a.example.com. 3600 IN AAAA 2001:db8:dead:beef::1:15"), + }, + }, + { + Qname: "puppet-server-b.example.com.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + test.AAAA("puppet-server-b.example.com. 3600 IN AAAA 2001:db8:dead:beef::1:16"), + }, + }, + { + Qname: "_x-puppet._tcp.example.com.", Qtype: dns.TypeSRV, + Answer: []dns.RR{ + test.SRV("_x-puppet._tcp.example.com. 3600 IN SRV 0 5 8140 puppet-server-a.example.com."), + test.SRV("_x-puppet._tcp.example.com. 3600 IN SRV 0 5 8140 puppet-server-b.example.com."), + }, + Extra: []dns.RR{ + test.AAAA("puppet-server-a.example.com. 3600 IN AAAA 2001:db8:dead:beef::1:15"), + test.AAAA("puppet-server-b.example.com. 3600 IN AAAA 2001:db8:dead:beef::1:16"), + }, + }, + { + Qname: "web.example.com.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + test.AAAA("web.example.com. 3600 IN AAAA 2001:db8:dead:beef::1:17"), + }, + }, + { + Qname: "www.example.com.", Qtype: dns.TypeCNAME, + Answer: []dns.RR{ + test.CNAME("www.example.com. 3600 IN CNAME web.example.com."), + test.AAAA("web.example.com. 3600 IN AAAA 2001:db8:dead:beef::1:17"), + }, + }, + { + Qname: subdotexampledotcomName, Qtype: dns.TypeNS, + Answer: subdotexampledotcomNS, + Extra: exampledotcomNSAddr6, + }, + { + Qname: "myservice.sub.example.com.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + test.AAAA("myservice.sub.example.com. 3600 IN AAAA 2001:db8:dead:beef::2:10"), + }, + }, + { + Qname: subtwodotexampledotcomName, Qtype: dns.TypeNS, + Answer: subtwodotexampledotcomNS, + Extra: exampledotcomNSAddr6, + }, + { + Qname: "myotherservice.subtwo.example.com.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + test.AAAA("myotherservice.subtwo.example.com. 3600 IN AAAA 2001:db8:dead:beef::3:10"), + }, + }, + } + + testLookupPTRV6 []test.Case = []test.Case{ + { + Qname: "0.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("0.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN PTR dns01.example.com."), + }, + }, + { + Qname: "1.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("1.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN PTR dns02.example.com."), + }, + }, + { + Qname: "2.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("2.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN PTR aservice.example.com."), + }, + }, + { + Qname: "3.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("3.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN PTR mail.example.com."), + }, + }, + { + Qname: "5.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("5.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN PTR puppet-server-a.example.com."), + }, + }, + { + Qname: "6.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("6.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN PTR puppet-server-b.example.com."), + }, + }, + { + Qname: "7.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("7.1.0.0.1.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN PTR web.example.com."), + }, + }, + { + Qname: "0.1.0.0.2.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("0.1.0.0.2.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN PTR myservice.sub.example.com."), + }, + }, + { + Qname: "0.1.0.0.3.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("0.1.0.0.3.0.0.0.0.0.0.0.0.0.0.0.f.e.e.b.d.a.e.d.8.b.d.0.1.0.0.2.ip6.arpa. 3600 IN PTR myotherservice.subtwo.example.com."), + }, + }, + } +) + +func TestLookupRecords(t *testing.T) { + RunTestLookup(t, testLookupRecordV4, testFamilyV4) + RunTestLookup(t, testLookupPTRV4, testFamilyV4) + RunTestLookup(t, testLookupRecordV6, testFamilyV6) + RunTestLookup(t, testLookupPTRV6, testFamilyV6) +} + +var ( + testUnknownRecords []test.Case = []test.Case{ + { + Qname: "noop.com.", Qtype: dns.TypeSOA, + Rcode: dns.RcodeNameError, + }, + } + + testUnknownRecordsV4 []test.Case = []test.Case{ + { + Qname: "noop.example.com.", Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + }, + } + + testUnknownRecordsV6 []test.Case = []test.Case{ + { + Qname: "noop.example.com.", Qtype: dns.TypeAAAA, + Rcode: dns.RcodeNameError, + }, + } +) + +func TestLookupUnknown(t *testing.T) { + RunTestLookup(t, testUnknownRecords, testFamilyV4) + RunTestLookup(t, testUnknownRecordsV4, testFamilyV4) + RunTestLookup(t, testUnknownRecordsV6, testFamilyV6) +} + +func TestOffline(t *testing.T) { + netboxdns := NetboxDNS{ + Next: test.ErrorHandler(), + zones: []string{"."}, + requestClient: &netbox.APIRequestClient{ + Client: &http.Client{ + Timeout: defaultHTTPClientTimeout, + }, + NetboxURL: &url.URL{ + Scheme: "http", + Host: "localhost:9876", + Path: testInstanceUrlPath, + }, + Token: testInstanceToken, + }, + } + tc := test.Case{ + Qname: exampledotcomName, Qtype: dns.TypeA, + } + msg := tc.Msg() + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + _, err := netboxdns.ServeDNS(context.Background(), rec, msg) + if err == nil { + t.Error("expected connection error, got none") + } +} + +func TestUnauthorized(t *testing.T) { + netboxdns := NetboxDNS{ + Next: test.ErrorHandler(), + zones: []string{"."}, + requestClient: &netbox.APIRequestClient{ + Client: &http.Client{ + Timeout: defaultHTTPClientTimeout, + }, + NetboxURL: &url.URL{ + Scheme: "http", + Host: testInstanceUrlHost, + Path: testInstanceUrlPath, + }, + Token: "noop", + }, + } + tc := test.Case{ + Qname: exampledotcomName, Qtype: dns.TypeA, + } + msg := tc.Msg() + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + _, err := netboxdns.ServeDNS(context.Background(), rec, msg) + if err == nil { + t.Error("expected connection error, got none") + } +} + +func TestFallthrough(t *testing.T) { + netboxdns := NetboxDNS{ + Next: test.ErrorHandler(), + zones: []string{exampledotcomName}, + requestClient: &netbox.APIRequestClient{ + Client: &http.Client{ + Timeout: defaultHTTPClientTimeout, + }, + NetboxURL: &url.URL{ + Scheme: "http", + Host: testInstanceUrlHost, + Path: testInstanceUrlPath, + }, + Token: testInstanceToken, + }, + } + netboxdns.fall.SetZonesFromArgs([]string{"out.example.com"}) + tc := test.Case{ + Qname: "a.out.example.com.", Qtype: dns.TypeA, + } + msg := tc.Msg() + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + _, err := netboxdns.ServeDNS(context.Background(), rec, msg) + if err != nil { + t.Errorf("expected fallthrough, got %v", err) + } +} + +func TestUnhandledZone(t *testing.T) { + netboxdns := NetboxDNS{ + Next: test.ErrorHandler(), + zones: []string{exampledotcomName}, + requestClient: &netbox.APIRequestClient{ + Client: &http.Client{ + Timeout: defaultHTTPClientTimeout, + }, + NetboxURL: &url.URL{ + Scheme: "http", + Host: testInstanceUrlHost, + Path: testInstanceUrlPath, + }, + Token: testInstanceToken, + }, + } + tc := test.Case{ + Qname: "www.example.net.", Qtype: dns.TypeA, + } + msg := tc.Msg() + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + _, err := netboxdns.ServeDNS(context.Background(), rec, msg) + if err != nil { + t.Errorf("expected no error, got %v", err) + } +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..f2aad27 --- /dev/null +++ b/parse.go @@ -0,0 +1,179 @@ +package netboxdns + +import ( + "fmt" + "net/http" + "net/url" + "time" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/tls" +) + +type tokenFuncMap map[string]func(*caddy.Controller, *NetboxDNS) error + +var tokenFuncs tokenFuncMap + +func init() { + tokenFuncs = tokenFuncMap{ + "fallthrough": parseFallthrough, + "timeout": parseTimeout, + "tls": parseTLS, + "token": parseToken, + "url": parseUrl, + } +} + +// Parse netboxdns configuration +func Parse(controller *caddy.Controller, netboxdns *NetboxDNS) error { + instances := 0 + for controller.Next() { + if instances > 0 { + return plugin.ErrOnce + } + instances++ + parseZones(controller, netboxdns) + if err := parseConfigTokens(controller, netboxdns); err != nil { + return err + } + } + + if err := parseValidate(controller, netboxdns); err != nil { + return err + } + + fullPluginURL := netboxdns.requestClient.NetboxURL.JoinPath( + "api", + "plugins", + "netbox-dns", + ) + netboxdns.requestClient.NetboxURL = fullPluginURL + + netboxdns.requestClient.UserAgent = fmt.Sprintf( + "coredns plugin %s", + pluginName, + ) + + return nil +} + +func parseZones(controller *caddy.Controller, netboxdns *NetboxDNS) { + zones := plugin.OriginsFromArgsOrServerBlock( + controller.RemainingArgs(), + controller.ServerBlockKeys, + ) + if len(zones) > 0 { + netboxdns.zones = zones + } +} + +func parseConfigTokens(controller *caddy.Controller, netboxdns *NetboxDNS) error { + for controller.NextBlock() { + tokenName := controller.Val() + tokenFunc, ok := tokenFuncs[tokenName] + if !ok { + return unknownToken(controller, tokenName) + } + if err := tokenFunc(controller, netboxdns); err != nil { + return err + } + } + return nil +} + +func unknownToken(controller *caddy.Controller, unknownToken string) error { + expectedTokenString := "" + i := 0 + for tokenName := range tokenFuncs { + expectedTokenString += fmt.Sprintf("%q", tokenName) + if i+1 < len(tokenFuncs) { + expectedTokenString += ", " + } + if i == len(tokenFuncs)-2 { + expectedTokenString += "or " + } + i++ + } + return controller.Errf( + "unknown token %q; expected %s", + unknownToken, + expectedTokenString, + ) +} + +func parseFallthrough( + controller *caddy.Controller, + netboxdns *NetboxDNS, +) error { + netboxdns.fall.SetZonesFromArgs(controller.RemainingArgs()) + return nil +} + +func parseTimeout(controller *caddy.Controller, netboxdns *NetboxDNS) error { + if !controller.NextArg() { + return controller.Err(`no value for "timeout" provided`) + } + duration, err := time.ParseDuration(controller.Val()) + if err != nil { + return controller.Errf( + `there was an error parsing "timeout": %q`, + err.Error(), + ) + } + netboxdns.requestClient.Client.Timeout = duration + return nil +} + +func parseTLS(controller *caddy.Controller, netboxdns *NetboxDNS) error { + args := controller.RemainingArgs() + tlsConfig, err := tls.NewTLSConfigFromArgs(args...) + if err != nil { + return err + } + netboxdns.requestClient.Client.Transport = &http.Transport{ + TLSClientConfig: tlsConfig, + } + return nil +} + +func parseToken(controller *caddy.Controller, netboxdns *NetboxDNS) error { + if !controller.NextArg() { + return controller.Err(`no value for "token" provided`) + } + netboxdns.requestClient.Token = controller.Val() + return nil +} + +func parseUrl(controller *caddy.Controller, netboxdns *NetboxDNS) error { + if !controller.NextArg() { + return controller.Err(`no value for "url" provided`) + } + netboxUrl, err := url.Parse(controller.Val()) + if err != nil { + return controller.Errf( + `there was an error parsing "url": %q`, + err.Error(), + ) + } + netboxdns.requestClient.NetboxURL = netboxUrl + return nil +} + +func parseValidate(controller *caddy.Controller, netboxdns *NetboxDNS) error { + tokenEmpty := netboxdns.requestClient.Token == "" + urlEmpty := netboxdns.requestClient.NetboxURL == nil || + netboxdns.requestClient.NetboxURL.Host == "" + if tokenEmpty && urlEmpty { + return controller.Err( + `values are required for "token" and "url"`, + ) + } + if tokenEmpty { + return controller.Err(`value is required for "token"`) + } + if urlEmpty { + return controller.Err(`value is required for "url"`) + } + return nil +} diff --git a/record.go b/record.go new file mode 100644 index 0000000..2aba60d --- /dev/null +++ b/record.go @@ -0,0 +1,78 @@ +package netboxdns + +import ( + "fmt" + "regexp" + "strings" + + "github.com/doubleu-labs/coredns-netbox-plugin-dns/internal/netbox" + "github.com/miekg/dns" +) + +var txtMultiValueRegexp *regexp.Regexp + +func init() { + txtMultiValueRegexp = regexp.MustCompile(`[^\s"']+|"([^"]*)"|'([^']*)`) +} + +func recordsToRR(records []netbox.Record) ([]dns.RR, error) { + out := make([]dns.RR, 0, len(records)) + for _, record := range records { + qtype := dns.StringToType[record.Type] + switch qtype { + case dns.TypeTXT: + out = append(out, recordToTXT(record)) + default: + rrStr := fmt.Sprintf( + "%s %d IN %s %s", + record.FQDN, + *record.TTL, + record.Type, + record.Value, + ) + rr, err := dns.NewRR(rrStr) + if err != nil { + return out, err + } + out = append(out, rr) + } + } + return out, nil +} + +func recordToTXT(record netbox.Record) *dns.TXT { + txt := make([]string, 0) + if strings.HasPrefix(record.Value, `"`) { + values := txtMultiValueRegexp.FindAllString(record.Value, -1) + for i := range values { + values[i] = strings.Trim(values[i], `"`) + values[i] = strings.ReplaceAll(values[i], "\\r\\n", "") + values[i] = strings.ReplaceAll(values[i], "\\n", "") + values[i] = strings.TrimSpace(values[i]) + if values[i] != "" { + txt = append(txt, values[i]) + } + } + } else { + txt = append(txt, record.Value) + } + return &dns.TXT{ + Hdr: dns.RR_Header{ + Name: record.FQDN, + Ttl: *record.TTL, + Class: dns.ClassINET, + Rrtype: dns.TypeTXT, + }, + Txt: txt, + } +} + +func filterRRByType(rrs []dns.RR, recordType uint16) []dns.RR { + out := make([]dns.RR, 0) + for _, rr := range rrs { + if rr.Header().Rrtype == recordType { + out = append(out, rr) + } + } + return out +} diff --git a/setup.go b/setup.go new file mode 100644 index 0000000..8e7bc37 --- /dev/null +++ b/setup.go @@ -0,0 +1,26 @@ +package netboxdns + +import ( + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" +) + +func init() { + plugin.Register(pluginName, setup) +} + +func setup(controller *caddy.Controller) error { + netboxdns := NewNetboxDNS() + if err := Parse(controller, netboxdns); err != nil { + return err + } + dnsserver.GetConfig(controller).AddPlugin( + func(next plugin.Handler) plugin.Handler { + netboxdns.Next = next + return netboxdns + }, + ) + logger.Info("successfully started netboxdns") + return nil +} diff --git a/setup_test.go b/setup_test.go new file mode 100644 index 0000000..794375f --- /dev/null +++ b/setup_test.go @@ -0,0 +1,199 @@ +package netboxdns + +import ( + "testing" + + "github.com/coredns/caddy" +) + +type SetupTest struct { + Name string + Corefile string + WantErr bool +} + +var setupTests []SetupTest = []SetupTest{ + { + "no configuration", + `netboxdns`, + true, + }, + { + "unknown token", + `netboxdns { + noop + }`, + true, + }, + { + "no netbox token specified", + `netboxdns { + url http://localhost:9999/ + }`, + true, + }, + { + "no value for netbox token", + `netboxdns { + url http://localhost:9999/ + token + }`, + true, + }, + { + "no netbox url specified", + `netboxdns { + token sometoken + }`, + true, + }, + { + "no value for netbox url", + `netboxdns { + token sometoken + url + }`, + true, + }, + { + "minimum valid configuration", + `netboxdns { + token sometoken + url http://localhost:9999/ + }`, + false, + }, + { + "invalid netbox url value", + `netboxdns { + token sometoken + url "http://local host:9999/" + }`, + true, + }, + { + "multiple configurations", + `netboxdns { + token sometoken + url http://localhost:9999/ + } + netboxdns { + token sometoken + url http://localhost:9999/ + }`, + true, + }, + { + "configuration with responsible zone", + `netboxdns example.com { + token sometoken + url http://localhost:9999/ + }`, + false, + }, + { + "no value for timeout specified", + `netboxdns { + token sometoken + url http://localhost:9999/ + timeout + }`, + true, + }, + { + "minimum configuration with timeout", + `netboxdns { + token sometoken + url http://localhost:9999/ + timeout 10s + }`, + false, + }, + { + "invalid timeout", + `netboxdns { + token sometoken + url http://localhost:9999/ + timeout 10g + }`, + true, + }, + { + "minimum configuration fallthrough all zones", + `netboxdns { + token sometoken + url http://localhost:9999/ + fallthrough + }`, + false, + }, + { + "minimum configuration fallthrough specified zone", + `netboxdns { + token sometoken + url http://localhost:9999/ + fallthrough example.net + }`, + false, + }, + { + "minimum configuration tls system ca", + `netboxdns { + token sometoken + url http://localhost:9999/ + tls + }`, + false, + }, + { + "minimum configuration tls nonexistant file", + `netboxdns { + token sometoken + url http://localhost:9999/ + tls noop.pem + }`, + true, + }, + { + "minimum configuration tls private ca", + `netboxdns { + token sometoken + url http://localhost:9999/ + tls .testing/tls/ca.pem + }`, + false, + }, + { + "minimum configuration tls client auth", + `netboxdns { + token sometoken + url http://localhost:9999/ + tls .testing/tls/client.pem .testing/tls/client-key.pem + }`, + false, + }, + { + "minimum configuration tls client auth private ca", + `netboxdns { + token sometoken + url http://localhost:9999/ + tls .testing/tls/client.pem .testing/tls/client-key.pem .testing/tls/ca.pem + }`, + false, + }, +} + +func TestSetup(t *testing.T) { + for _, tt := range setupTests { + t.Run(tt.Name, func(t *testing.T) { + controller := caddy.NewTestController("dns", tt.Corefile) + if err := setup(controller); (err != nil) != tt.WantErr { + t.Errorf( + "setup error: %v, wanterr: %t", + err, + tt.WantErr, + ) + } + }) + } +}