diff --git a/docs/tutorials/pihole.md b/docs/tutorials/pihole.md index 722be11b00..751651ed05 100644 --- a/docs/tutorials/pihole.md +++ b/docs/tutorials/pihole.md @@ -1,7 +1,7 @@ # Setting up ExternalDNS for Pi-hole This tutorial describes how to setup ExternalDNS to sync records with Pi-hole's Custom DNS. -Pi-hole has an internal list it checks last when resolving requests. This list can contain any number of arbitrary A or CNAME records. +Pi-hole has an internal list it checks last when resolving requests. This list can contain any number of arbitrary A, AAAA or CNAME records. There is a pseudo-API exposed that ExternalDNS is able to use to manage these records. __NOTE:__ Your Pi-hole must be running [version 5.9 or newer](https://pi-hole.net/blog/2022/02/12/pi-hole-ftl-v5-14-web-v5-11-and-core-v5-9-released). @@ -91,7 +91,7 @@ spec: args: - --source=service - --source=ingress - # Pihole only supports A/CNAME records so there is no mechanism to track ownership. + # Pihole only supports A/AAAA/CNAME records so there is no mechanism to track ownership. # You don't need to set this flag, but if you leave it unset, you will receive warning # logs when ExternalDNS attempts to create TXT records. - --registry=noop diff --git a/provider/pihole/client.go b/provider/pihole/client.go index 4ca6552587..f7410b10a0 100644 --- a/provider/pihole/client.go +++ b/provider/pihole/client.go @@ -145,6 +145,7 @@ func (p *piholeClient) listRecords(ctx context.Context, rtype string) ([]*endpoi if !ok { return out, nil } +loop: for _, rec := range data { name := rec[0] target := rec[1] @@ -152,6 +153,16 @@ func (p *piholeClient) listRecords(ctx context.Context, rtype string) ([]*endpoi log.Debugf("Skipping %s that does not match domain filter", name) continue } + switch rtype { + case endpoint.RecordTypeA: + if strings.Contains(target, ":") { + continue loop + } + case endpoint.RecordTypeAAAA: + if strings.Contains(target, ".") { + continue loop + } + } out = append(out, &endpoint.Endpoint{ DNSName: name, Targets: []string{target}, @@ -180,7 +191,7 @@ func (p *piholeClient) cnameRecordsScript() string { func (p *piholeClient) urlForRecordType(rtype string) (string, error) { switch rtype { - case endpoint.RecordTypeA: + case endpoint.RecordTypeA, endpoint.RecordTypeAAAA: return p.aRecordsScript(), nil case endpoint.RecordTypeCNAME: return p.cnameRecordsScript(), nil @@ -287,7 +298,7 @@ func (p *piholeClient) newDNSActionForm(action string, ep *endpoint.Endpoint) *u form.Add("action", action) form.Add("domain", ep.DNSName) switch ep.RecordType { - case endpoint.RecordTypeA: + case endpoint.RecordTypeA, endpoint.RecordTypeAAAA: form.Add("ip", ep.Targets[0]) case endpoint.RecordTypeCNAME: form.Add("target", ep.Targets[0]) diff --git a/provider/pihole/client_test.go b/provider/pihole/client_test.go index 8e9626a61d..8e87d2bde3 100644 --- a/provider/pihole/client_test.go +++ b/provider/pihole/client_test.go @@ -113,12 +113,16 @@ func TestListRecords(t *testing.T) { `)) return } + // Pihole makes no distinction between A and AAAA records w.Write([]byte(` { "data": [ ["test1.example.com", "192.168.1.1"], ["test2.example.com", "192.168.1.2"], - ["test3.match.com", "192.168.1.3"] + ["test3.match.com", "192.168.1.3"], + ["test1.example.com", "fc00::1:192:168:1:1"], + ["test2.example.com", "fc00::1:192:168:1:2"], + ["test3.match.com", "fc00::1:192:168:1:3"] ] } `)) @@ -157,6 +161,29 @@ func TestListRecords(t *testing.T) { } } + // Test retrieve AAAA records unfiltered + arecs, err = cl.listRecords(context.Background(), endpoint.RecordTypeAAAA) + if err != nil { + t.Fatal(err) + } + if len(arecs) != 3 { + t.Fatal("Expected 3 AAAA records returned, got:", len(arecs)) + } + // Ensure records were parsed correctly + expected = [][]string{ + {"test1.example.com", "fc00::1:192:168:1:1"}, + {"test2.example.com", "fc00::1:192:168:1:2"}, + {"test3.match.com", "fc00::1:192:168:1:3"}, + } + for idx, rec := range arecs { + if rec.DNSName != expected[idx][0] { + t.Error("Got invalid DNS Name:", rec.DNSName, "expected:", expected[idx][0]) + } + if rec.Targets[0] != expected[idx][1] { + t.Error("Got invalid target:", rec.Targets[0], "expected:", expected[idx][1]) + } + } + // Test retrieve CNAME records unfiltered cnamerecs, err := cl.listRecords(context.Background(), endpoint.RecordTypeCNAME) if err != nil { @@ -209,6 +236,27 @@ func TestListRecords(t *testing.T) { } } + // Test retrieve AAAA records filtered + arecs, err = cl.listRecords(context.Background(), endpoint.RecordTypeAAAA) + if err != nil { + t.Fatal(err) + } + if len(arecs) != 1 { + t.Fatal("Expected 1 AAAA record returned, got:", len(arecs)) + } + // Ensure records were parsed correctly + expected = [][]string{ + {"test3.match.com", "fc00::1:192:168:1:3"}, + } + for idx, rec := range arecs { + if rec.DNSName != expected[idx][0] { + t.Error("Got invalid DNS Name:", rec.DNSName, "expected:", expected[idx][0]) + } + if rec.Targets[0] != expected[idx][1] { + t.Error("Got invalid target:", rec.Targets[0], "expected:", expected[idx][1]) + } + } + // Test retrieve CNAME records filtered cnamerecs, err = cl.listRecords(context.Background(), endpoint.RecordTypeCNAME) if err != nil { @@ -246,6 +294,11 @@ func TestCreateRecord(t *testing.T) { if r.Form.Get("ip") != ep.Targets[0] { t.Error("Invalid ip in form:", r.Form.Get("ip"), "Expected:", ep.Targets[0]) } + // Pihole makes no distinction between A and AAAA records + case endpoint.RecordTypeAAAA: + if r.Form.Get("ip") != ep.Targets[0] { + t.Error("Invalid ip in form:", r.Form.Get("ip"), "Expected:", ep.Targets[0]) + } case endpoint.RecordTypeCNAME: if r.Form.Get("target") != ep.Targets[0] { t.Error("Invalid target in form:", r.Form.Get("target"), "Expected:", ep.Targets[0]) @@ -281,6 +334,16 @@ func TestCreateRecord(t *testing.T) { t.Fatal(err) } + // Test create AAAA record + ep = &endpoint.Endpoint{ + DNSName: "test.example.com", + Targets: []string{"fc00::1:192:168:1:1"}, + RecordType: endpoint.RecordTypeAAAA, + } + if err := cl.createRecord(context.Background(), ep); err != nil { + t.Fatal(err) + } + // Test create CNAME record ep = &endpoint.Endpoint{ DNSName: "test.example.com", @@ -307,6 +370,11 @@ func TestDeleteRecord(t *testing.T) { if r.Form.Get("ip") != ep.Targets[0] { t.Error("Invalid ip in form:", r.Form.Get("ip"), "Expected:", ep.Targets[0]) } + // Pihole makes no distinction between A and AAAA records + case endpoint.RecordTypeAAAA: + if r.Form.Get("ip") != ep.Targets[0] { + t.Error("Invalid ip in form:", r.Form.Get("ip"), "Expected:", ep.Targets[0]) + } case endpoint.RecordTypeCNAME: if r.Form.Get("target") != ep.Targets[0] { t.Error("Invalid target in form:", r.Form.Get("target"), "Expected:", ep.Targets[0]) @@ -342,6 +410,16 @@ func TestDeleteRecord(t *testing.T) { t.Fatal(err) } + // Test delete AAAA record + ep = &endpoint.Endpoint{ + DNSName: "test.example.com", + Targets: []string{"fc00::1:192:168:1:1"}, + RecordType: endpoint.RecordTypeAAAA, + } + if err := cl.deleteRecord(context.Background(), ep); err != nil { + t.Fatal(err) + } + // Test delete CNAME record ep = &endpoint.Endpoint{ DNSName: "test.example.com", diff --git a/provider/pihole/pihole.go b/provider/pihole/pihole.go index 6b1f352d9c..312054e1b5 100644 --- a/provider/pihole/pihole.go +++ b/provider/pihole/pihole.go @@ -71,10 +71,15 @@ func (p *PiholeProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, err if err != nil { return nil, err } + aaaaRecords, err := p.api.listRecords(ctx, endpoint.RecordTypeAAAA) + if err != nil { + return nil, err + } cnameRecords, err := p.api.listRecords(ctx, endpoint.RecordTypeCNAME) if err != nil { return nil, err } + aRecords = append(aRecords, aaaaRecords...) return append(aRecords, cnameRecords...), nil } diff --git a/provider/pihole/pihole_test.go b/provider/pihole/pihole_test.go index bd19196afd..5c99c13940 100644 --- a/provider/pihole/pihole_test.go +++ b/provider/pihole/pihole_test.go @@ -112,6 +112,21 @@ func TestProvider(t *testing.T) { Targets: []string{"192.168.1.3"}, RecordType: endpoint.RecordTypeA, }, + { + DNSName: "test1.example.com", + Targets: []string{"fc00::1:192:168:1:1"}, + RecordType: endpoint.RecordTypeAAAA, + }, + { + DNSName: "test2.example.com", + Targets: []string{"fc00::1:192:168:1:2"}, + RecordType: endpoint.RecordTypeAAAA, + }, + { + DNSName: "test3.example.com", + Targets: []string{"fc00::1:192:168:1:3"}, + RecordType: endpoint.RecordTypeAAAA, + }, } if err := p.ApplyChanges(context.Background(), &plan.Changes{ Create: records, @@ -125,11 +140,11 @@ func TestProvider(t *testing.T) { if err != nil { t.Fatal(err) } - if len(newRecords) != 3 { - t.Fatal("Expected list of 3 records, got:", records) + if len(newRecords) != 6 { + t.Fatal("Expected list of 6 records, got:", records) } - if len(requests.createRequests) != 3 { - t.Fatal("Expected 3 create requests, got:", requests.createRequests) + if len(requests.createRequests) != 6 { + t.Fatal("Expected 6 create requests, got:", requests.createRequests) } if len(requests.deleteRequests) != 0 { t.Fatal("Expected no delete requests, got:", requests.deleteRequests) @@ -163,15 +178,37 @@ func TestProvider(t *testing.T) { Targets: []string{"192.168.1.2"}, RecordType: endpoint.RecordTypeA, }, + { + DNSName: "test1.example.com", + Targets: []string{"fc00::1:192:168:1:1"}, + RecordType: endpoint.RecordTypeAAAA, + }, + { + DNSName: "test2.example.com", + Targets: []string{"fc00::1:192:168:1:2"}, + RecordType: endpoint.RecordTypeAAAA, + }, } - recordToDelete := endpoint.Endpoint{ + recordToDeleteA := endpoint.Endpoint{ DNSName: "test3.example.com", Targets: []string{"192.168.1.3"}, RecordType: endpoint.RecordTypeA, } if err := p.ApplyChanges(context.Background(), &plan.Changes{ Delete: []*endpoint.Endpoint{ - &recordToDelete, + &recordToDeleteA, + }, + }); err != nil { + t.Fatal(err) + } + recordToDeleteAAAA := endpoint.Endpoint{ + DNSName: "test3.example.com", + Targets: []string{"fc00::1:192:168:1:3"}, + RecordType: endpoint.RecordTypeAAAA, + } + if err := p.ApplyChanges(context.Background(), &plan.Changes{ + Delete: []*endpoint.Endpoint{ + &recordToDeleteAAAA, }, }); err != nil { t.Fatal(err) @@ -182,14 +219,14 @@ func TestProvider(t *testing.T) { if err != nil { t.Fatal(err) } - if len(newRecords) != 2 { - t.Fatal("Expected list of 2 records, got:", records) + if len(newRecords) != 4 { + t.Fatal("Expected list of 4 records, got:", records) } if len(requests.createRequests) != 0 { t.Fatal("Expected no create requests, got:", requests.createRequests) } - if len(requests.deleteRequests) != 1 { - t.Fatal("Expected 1 delete request, got:", requests.deleteRequests) + if len(requests.deleteRequests) != 2 { + t.Fatal("Expected 2 delete request, got:", requests.deleteRequests) } for idx, record := range records { @@ -201,8 +238,11 @@ func TestProvider(t *testing.T) { } } - if !reflect.DeepEqual(requests.deleteRequests[0], &recordToDelete) { - t.Error("Unexpected delete request, got:", requests.deleteRequests[0], "expected:", recordToDelete) + if !reflect.DeepEqual(requests.deleteRequests[0], &recordToDeleteA) { + t.Error("Unexpected delete request, got:", requests.deleteRequests[0], "expected:", recordToDeleteA) + } + if !reflect.DeepEqual(requests.deleteRequests[1], &recordToDeleteAAAA) { + t.Error("Unexpected delete request, got:", requests.deleteRequests[1], "expected:", recordToDeleteAAAA) } requests.clear() @@ -220,6 +260,16 @@ func TestProvider(t *testing.T) { Targets: []string{"10.0.0.1"}, RecordType: endpoint.RecordTypeA, }, + { + DNSName: "test1.example.com", + Targets: []string{"fc00::1:192:168:1:1"}, + RecordType: endpoint.RecordTypeAAAA, + }, + { + DNSName: "test2.example.com", + Targets: []string{"fc00::1:10:0:0:1"}, + RecordType: endpoint.RecordTypeAAAA, + }, } if err := p.ApplyChanges(context.Background(), &plan.Changes{ UpdateOld: []*endpoint.Endpoint{ @@ -233,6 +283,16 @@ func TestProvider(t *testing.T) { Targets: []string{"192.168.1.2"}, RecordType: endpoint.RecordTypeA, }, + { + DNSName: "test1.example.com", + Targets: []string{"fc00::1:192:168:1:1"}, + RecordType: endpoint.RecordTypeAAAA, + }, + { + DNSName: "test2.example.com", + Targets: []string{"fc00::1:192:168:1:2"}, + RecordType: endpoint.RecordTypeAAAA, + }, }, UpdateNew: []*endpoint.Endpoint{ { @@ -245,6 +305,16 @@ func TestProvider(t *testing.T) { Targets: []string{"10.0.0.1"}, RecordType: endpoint.RecordTypeA, }, + { + DNSName: "test1.example.com", + Targets: []string{"fc00::1:192:168:1:1"}, + RecordType: endpoint.RecordTypeAAAA, + }, + { + DNSName: "test2.example.com", + Targets: []string{"fc00::1:10:0:0:1"}, + RecordType: endpoint.RecordTypeAAAA, + }, }, }); err != nil { t.Fatal(err) @@ -255,14 +325,14 @@ func TestProvider(t *testing.T) { if err != nil { t.Fatal(err) } - if len(newRecords) != 2 { - t.Fatal("Expected list of 2 records, got:", records) + if len(newRecords) != 4 { + t.Fatal("Expected list of 4 records, got:", newRecords) } - if len(requests.createRequests) != 1 { - t.Fatal("Expected 1 create request, got:", requests.createRequests) + if len(requests.createRequests) != 2 { + t.Fatal("Expected 2 create request, got:", requests.createRequests) } - if len(requests.deleteRequests) != 1 { - t.Fatal("Expected 1 delete request, got:", requests.deleteRequests) + if len(requests.deleteRequests) != 2 { + t.Fatal("Expected 2 delete request, got:", requests.deleteRequests) } for idx, record := range records { @@ -274,22 +344,53 @@ func TestProvider(t *testing.T) { } } - expectedCreate := endpoint.Endpoint{ + expectedCreateA := endpoint.Endpoint{ DNSName: "test2.example.com", Targets: []string{"10.0.0.1"}, RecordType: endpoint.RecordTypeA, } - expectedDelete := endpoint.Endpoint{ + expectedDeleteA := endpoint.Endpoint{ DNSName: "test2.example.com", Targets: []string{"192.168.1.2"}, RecordType: endpoint.RecordTypeA, } - - if !reflect.DeepEqual(requests.createRequests[0], &expectedCreate) { - t.Error("Unexpected create request, got:", requests.createRequests[0], "expected:", &expectedCreate) + expectedCreateAAAA := endpoint.Endpoint{ + DNSName: "test2.example.com", + Targets: []string{"fc00::1:10:0:0:1"}, + RecordType: endpoint.RecordTypeAAAA, + } + expectedDeleteAAAA := endpoint.Endpoint{ + DNSName: "test2.example.com", + Targets: []string{"fc00::1:192:168:1:2"}, + RecordType: endpoint.RecordTypeAAAA, + } + + for _, request := range requests.createRequests { + switch request.RecordType { + case endpoint.RecordTypeA: + if !reflect.DeepEqual(request, &expectedCreateA) { + t.Error("Unexpected create request, got:", request, "expected:", &expectedCreateA) + } + case endpoint.RecordTypeAAAA: + if !reflect.DeepEqual(request, &expectedCreateAAAA) { + t.Error("Unexpected create request, got:", request, "expected:", &expectedCreateAAAA) + } + default: + } } - if !reflect.DeepEqual(requests.deleteRequests[0], &expectedDelete) { - t.Error("Unexpected delete request, got:", requests.deleteRequests[0], "expected:", &expectedDelete) + + for _, request := range requests.deleteRequests { + switch request.RecordType { + case endpoint.RecordTypeA: + if !reflect.DeepEqual(request, &expectedDeleteA) { + t.Error("Unexpected delete request, got:", request, "expected:", &expectedDeleteA) + } + case endpoint.RecordTypeAAAA: + if !reflect.DeepEqual(request, &expectedDeleteAAAA) { + t.Error("Unexpected delete request, got:", request, "expected:", &expectedDeleteAAAA) + } + default: + } } requests.clear()