From 595113b0e0bf6a1014c217cba2200d4ae6d01cd3 Mon Sep 17 00:00:00 2001 From: Steve Holmes Date: Tue, 31 Dec 2024 18:43:46 +0000 Subject: [PATCH] Initial version --- .env_template | 3 + .gitignore | 3 + LICENSE | 21 +++++ README.md | 85 ++++++++++++++++++ client.go | 195 ++++++++++++++++++++++++++++++++++++++++ go.mod | 14 +++ go.sum | 12 +++ libdns.go | 225 +++++++++++++++++++++++++++++++++++++++++++++++ libdns_test.go | 196 +++++++++++++++++++++++++++++++++++++++++ models.go | 35 ++++++++ provider.go | 51 +++++++++++ provider_test.go | 155 ++++++++++++++++++++++++++++++++ 12 files changed, 995 insertions(+) create mode 100644 .env_template create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 client.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 libdns.go create mode 100644 libdns_test.go create mode 100644 models.go create mode 100644 provider.go create mode 100644 provider_test.go diff --git a/.env_template b/.env_template new file mode 100644 index 0000000..91762e7 --- /dev/null +++ b/.env_template @@ -0,0 +1,3 @@ +export LIBDNS_DNSEXIT_API_KEY= +export LIBDNS_DNSEXIT_ZONE= +export LIBDNS_DNSEXIT_DEBUG=TRUE/FALSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1403d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +_gitignore/ +.env + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..40f7762 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Stephen Holmes + +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..a0867a5 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +DNSExit for [`libdns`](https://github.com/libdns/libdns) +======================= + +[![Go Reference](https://pkg.go.dev/badge/test.svg)](https://pkg.go.dev/github.com/libdns/dnsexit) + +This package implements the [libdns interfaces](https://github.com/libdns/libdns) for DNSExit, allowing you to manage DNS records. + +Configuration +============= + +[DNSExit API documentation](https://dnsexit.com/dns/dns-api/) details the process of getting an API key. + +To run clone the `.env_template` to a file named `.env` and populate with the API key and zone. Note that setting the environment variable 'LIBDNS_DNSEXIT_DEBUG=TRUE' will output the request body, which includes the API key. + +Example +======= + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/libdns/dnsexit" + "github.com/libdns/libdns" +) + +func main() { + key := os.Getenv("LIBDNS_DNSEXIT_API_KEY") + if key == "" { + fmt.Println("LIBDNS_DNSEXIT_API_KEY not set") + return + } + + zone := os.Getenv("LIBDNS_DNSEXIT_ZONE") + if zone == "" { + fmt.Println("LIBDNS_DNSEXIT_ZONE not set") + return + } + + p := &dnsexit.Provider{ + APIKey: key, + } + + records := []libdns.Record{ + { + Type: "A", + Name: "test", + Value: "198.51.100.1", + }, + { + Type: "AAAA", + Name: "test", + Value: "2001:0db8::1", + }, + { + Type: "TXT", + Name: "test", + Value: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + }, + } + + ctx := context.Background() + _, err := p.SetRecords(ctx, zone, records) + if err != nil { + fmt.Printf("Error: %v", err) + return + } +} +``` + +Caveats +======= + +The API does not include a GET method, so fetching records is done via Google DNS. There will be some latency. + +If an 'A' and 'AAAA' record have the same name, deleting either of them will remove both records. + +If multiple record updates are sent in one request, the API may return a code other than 0, to indicate partial success. This is currently judged as a fail and API error message is returned instead of the successfully amended records. + +MX records have mail-zone and mail-server properties, which do not exist in the LibDNS record type, so updating these has not been fully implemented. 'name' can be used instead to specify the mail server, but there is no way to specify the mail-zone. See https://dnsexit.com/dns/dns-api/#example-update-mx + +For [Dynamic DNS](https://dnsexit.com/dns/dns-api/#dynamic-ip-update) DNSExit recommend their dedicated GET endpoint, which can set the domain's IP to the one making the request. That is not implemented in this library. diff --git a/client.go b/client.go new file mode 100644 index 0000000..1f4f4cb --- /dev/null +++ b/client.go @@ -0,0 +1,195 @@ +package dnsexit + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/netip" + "os" + "time" + + "github.com/go-resty/resty/v2" + "github.com/libdns/libdns" + "github.com/pkg/errors" +) + +const ( + // API URL to POST updates to + updateURL = "https://api.dnsexit.com/dns/" +) + +var ( + // Set environment variable to "TRUE" to enable debug logging + debug = (os.Getenv("LIBDNS_DNSEXIT_DEBUG") == "TRUE") + client = resty.New() +) + +// Query Google DNS for A/AAAA/TXT record for a given DNS name +func (p *Provider) getDomain(ctx context.Context, zone string) ([]libdns.Record, error) { + p.mutex.Lock() + defer p.mutex.Unlock() + + var libRecords []libdns.Record + + // The API only supports adding/updating/deleting records and no way + // to get current records. So instead, we just make + // simple DNS queries to get the A, AAAA, and TXT records. + r := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 10 * time.Second} + return d.DialContext(ctx, network, "8.8.8.8:53") + }, + } + + ips, err := r.LookupHost(ctx, zone) + if err != nil { + var dnsErr *net.DNSError + // Ignore missing dns record + if !(errors.As(err, &dnsErr) && dnsErr.IsNotFound) { + return libRecords, errors.Wrapf(err, "error looking up host") + } + } + + for _, ip := range ips { + parsed, err := netip.ParseAddr(ip) + if err != nil { + return libRecords, errors.Wrapf(err, "error parsing ip") + } + + if parsed.Is4() { + libRecords = append(libRecords, libdns.Record{ + Type: "A", + Name: "@", + Value: ip, + }) + } else { + libRecords = append(libRecords, libdns.Record{ + Type: "AAAA", + Name: "@", + Value: ip, + }) + } + } + + txt, err := r.LookupTXT(ctx, zone) + if err != nil { + var dnsErr *net.DNSError + // Ignore missing dns record + if !(errors.As(err, &dnsErr) && dnsErr.IsNotFound) { + return libRecords, errors.Wrapf(err, "error looking up txt") + } + } + for _, t := range txt { + if t == "" { + continue + } + libRecords = append(libRecords, libdns.Record{ + Type: "TXT", + Name: "@", + Value: t, + }) + } + + return libRecords, nil +} + +// Set or clear the value of a DNS entry +func (p *Provider) amendRecords(zone string, records []libdns.Record, action Action) ([]libdns.Record, error) { + + var payloadRecords []dnsExitRecord + p.mutex.Lock() + defer p.mutex.Unlock() + + //////////////////////////////////////////////// + // BUILD PAYLOAD + //////////////////////////////////////////////// + for _, record := range records { + if record.TTL/time.Second < 600 { + record.TTL = 600 * time.Second + } + ttlInSeconds := int(record.TTL / time.Second) + + relativeName := libdns.RelativeName(record.Name, zone) + trimmedName := relativeName + if relativeName == "@" { + trimmedName = "" + } + + currentRecord := dnsExitRecord{} + currentRecord.Type = record.Type + currentRecord.Name = trimmedName + + if action != deleteRecords { + recordValue := record.Value + currentRecord.Content = &recordValue + recordPriority := int(record.Priority) + currentRecord.Priority = &recordPriority + recordTTL := ttlInSeconds + currentRecord.TTL = &recordTTL + } + if action == setRecords { + truevalue := true + currentRecord.Overwrite = &truevalue + } + payloadRecords = append(payloadRecords, currentRecord) + } + + payload := dnsExitPayload{} + payload.Apikey = p.APIKey + payload.Zone = zone + + switch action { + case deleteRecords: + payload.DeleteRecords = &payloadRecords + case setRecords: + fallthrough + case appendRecords: + payload.AddRecords = &payloadRecords + default: + return nil, errors.New(fmt.Sprintf("Unknown action type: %d", action)) + } + + //////////////////////////////////////////////// + //SEND PAYLOAD + //////////////////////////////////////////////// + + // Explore response object + reqBody, err := json.Marshal(payload) + if err != nil { + return nil, err + } + if debug { + fmt.Println("Request Info:") + fmt.Println("Body:", string(reqBody)) + } + // Make the API request to DNSExit + // POST Struct, default is JSON content type. No need to set one + resp, err := client.R(). + SetBody(payload). + SetResult(&dnsExitResponse{}). + SetError(&dnsExitResponse{}). + Post(updateURL) + + if err != nil { + return nil, err + } + + //TODO - query the response code and text to determine which updates where successful, and return both records and response text in all cases, rather than just assuming all records for a 0 code and no records for other codes. + + // On any non-zero return code return the API response as the error text. + if !isResposeStatusOK(resp.Body()) { + respBody := string(resp.String()) + return nil, errors.New(fmt.Sprintf("API request failed, response=%s", respBody)) + } + + return records, nil +} + +// Convert API response code to human friendly error +func isResposeStatusOK(body []byte) bool { + var respJson dnsExitResponse + json.Unmarshal(body, &respJson) + return respJson.Code == 0 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c661ba9 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/libdns/dnsexit + +go 1.22.0 + +toolchain go1.22.2 + +require ( + github.com/go-resty/resty/v2 v2.16.2 + github.com/joho/godotenv v1.5.1 + github.com/libdns/libdns v0.2.2 + github.com/pkg/errors v0.9.1 +) + +require golang.org/x/net v0.33.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..968f1f0 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/go-resty/resty/v2 v2.16.2 h1:CpRqTjIzq/rweXUt9+GxzzQdlkqMdt8Lm/fuK/CAbAg= +github.com/go-resty/resty/v2 v2.16.2/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s= +github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/libdns.go b/libdns.go new file mode 100644 index 0000000..8a9a06f --- /dev/null +++ b/libdns.go @@ -0,0 +1,225 @@ +// Package libdns defines core interfaces that should be implemented by DNS +// provider clients. They are small and idiomatic Go interfaces with +// well-defined semantics. +// +// Records are described independently of any particular zone, a convention +// that grants Record structs portability across zones. As such, record names +// are partially qualified, i.e. relative to the zone. For example, an A +// record called "sub" in zone "example.com." represents a fully-qualified +// domain name (FQDN) of "sub.example.com.". Implementations should expect +// that input records conform to this standard, while also ensuring that +// output records do; adjustments to record names may need to be made before +// or after provider API calls, for example, to maintain consistency with +// all other libdns packages. Helper functions are available in this package +// to convert between relative and absolute names. +// +// Although zone names are a required input, libdns does not coerce any +// particular representation of DNS zones; only records. Since zone name and +// records are separate inputs in libdns interfaces, it is up to the caller +// to pair a zone's name with its records in a way that works for them. +// +// All interface implementations must be safe for concurrent/parallel use, +// meaning 1) no data races, and 2) simultaneous method calls must result +// in either both their expected outcomes or an error. +// +// For example, if AppendRecords() is called at the same time and two API +// requests are made to the provider at the same time, the result of both +// requests must be visible after they both complete; if the provider does +// not synchronize the writing of the zone file and one request overwrites +// the other, then the client implementation must take care to synchronize +// on behalf of the incompetent provider. This synchronization need not be +// global; for example: the scope of synchronization might only need to be +// within the same zone, allowing multiple requests at once as long as all +// of them are for different zones. (Exact logic depends on the provider.) +package dnsexit + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" +) + +// RecordGetter can get records from a DNS zone. +type RecordGetter interface { + // GetRecords returns all the records in the DNS zone. + // + // Implementations must honor context cancellation and be safe for + // concurrent use. + GetRecords(ctx context.Context, zone string) ([]Record, error) +} + +// RecordAppender can non-destructively add new records to a DNS zone. +type RecordAppender interface { + // AppendRecords creates the requested records in the given zone + // and returns the populated records that were created. It never + // changes existing records. + // + // Implementations must honor context cancellation and be safe for + // concurrent use. + AppendRecords(ctx context.Context, zone string, recs []Record) ([]Record, error) +} + +// RecordSetter can set new or update existing records in a DNS zone. +type RecordSetter interface { + // SetRecords updates the zone so that the records described in the + // input are reflected in the output. It may create or overwrite + // records or -- depending on the record type -- delete records to + // maintain parity with the input. No other records are affected. + // It returns the records which were set. + // + // Records that have an ID associating it with a particular resource + // on the provider will be directly replaced. If no ID is given, this + // method may use what information is given to do lookups and will + // ensure that only necessary changes are made to the zone. + // + // Implementations must honor context cancellation and be safe for + // concurrent use. + SetRecords(ctx context.Context, zone string, recs []Record) ([]Record, error) +} + +// RecordDeleter can delete records from a DNS zone. +type RecordDeleter interface { + // DeleteRecords deletes the given records from the zone if they exist. + // It returns the records that were deleted. + // + // Records that have an ID to associate it with a particular resource on + // the provider will be directly deleted. If no ID is given, this method + // may use what information is given to do lookups and delete only + // matching records. + // + // Implementations must honor context cancellation and be safe for + // concurrent use. + DeleteRecords(ctx context.Context, zone string, recs []Record) ([]Record, error) +} + +// ZoneLister can list available DNS zones. +type ZoneLister interface { + // ListZones returns the list of available DNS zones for use by + // other libdns methods. + // + // Implementations must honor context cancellation and be safe for + // concurrent use. + ListZones(ctx context.Context) ([]Zone, error) +} + +// Record is a generalized representation of a DNS record. +// +// The values of this struct should be free of zone-file-specific syntax, +// except if this struct's fields do not sufficiently represent all the +// fields of a certain record type; in that case, the remaining data for +// which there are not specific fields should be stored in the Value as +// it appears in the zone file. +type Record struct { + // provider-specific metadata + ID string + + // general record fields + Type string + Name string // partially-qualified (relative to zone) + Value string + TTL time.Duration + + // type-dependent record fields + Priority uint // HTTPS, MX, SRV, and URI records + Weight uint // SRV and URI records +} + +// Zone is a generalized representation of a DNS zone. +type Zone struct { + Name string +} + +// ToSRV parses the record into a SRV struct with fully-parsed, literal values. +// +// EXPERIMENTAL; subject to change or removal. +func (r Record) ToSRV() (SRV, error) { + if r.Type != "SRV" { + return SRV{}, fmt.Errorf("record type not SRV: %s", r.Type) + } + + fields := strings.Fields(r.Value) + if len(fields) != 2 { + return SRV{}, fmt.Errorf("malformed SRV value; expected: ' '") + } + + port, err := strconv.Atoi(fields[0]) + if err != nil { + return SRV{}, fmt.Errorf("invalid port %s: %v", fields[0], err) + } + if port < 0 { + return SRV{}, fmt.Errorf("port cannot be < 0: %d", port) + } + + parts := strings.SplitN(r.Name, ".", 3) + if len(parts) < 3 { + return SRV{}, fmt.Errorf("name %v does not contain enough fields; expected format: '_service._proto.name'", r.Name) + } + + return SRV{ + Service: strings.TrimPrefix(parts[0], "_"), + Proto: strings.TrimPrefix(parts[1], "_"), + Name: parts[2], + Priority: r.Priority, + Weight: r.Weight, + Port: uint(port), + Target: fields[1], + }, nil +} + +// SRV contains all the parsed data of an SRV record. +// +// EXPERIMENTAL; subject to change or removal. +type SRV struct { + Service string // no leading "_" + Proto string // no leading "_" + Name string + Priority uint + Weight uint + Port uint + Target string +} + +// ToRecord converts the parsed SRV data to a Record struct. +// +// EXPERIMENTAL; subject to change or removal. +func (s SRV) ToRecord() Record { + return Record{ + Type: "SRV", + Name: fmt.Sprintf("_%s._%s.%s", s.Service, s.Proto, s.Name), + Priority: s.Priority, + Weight: s.Weight, + Value: fmt.Sprintf("%d %s", s.Port, s.Target), + } +} + +// RelativeName makes fqdn relative to zone. For example, for a FQDN of +// "sub.example.com" and a zone of "example.com", it outputs "sub". +// +// If fqdn cannot be expressed relative to zone, the input fqdn is returned. +func RelativeName(fqdn, zone string) string { + // liberally ignore trailing dots on both fqdn and zone, because + // the relative name won't have a trailing dot anyway; I assume + // this won't be problematic...? + // (initially implemented because Cloudflare returns "fully- + // qualified" domains in their records without a trailing dot, + // but the input zone typically has a trailing dot) + return strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(fqdn, "."), strings.TrimSuffix(zone, ".")), ".") +} + +// AbsoluteName makes name into a fully-qualified domain name (FQDN) by +// prepending it to zone and tidying up the dots. For example, an input +// of name "sub" and zone "example.com." will return "sub.example.com.". +func AbsoluteName(name, zone string) string { + if zone == "" { + return strings.Trim(name, ".") + } + if name == "" || name == "@" { + return zone + } + if !strings.HasSuffix(name, ".") { + name += "." + } + return name + zone +} diff --git a/libdns_test.go b/libdns_test.go new file mode 100644 index 0000000..3ebe12f --- /dev/null +++ b/libdns_test.go @@ -0,0 +1,196 @@ +package dnsexit + +import ( + "fmt" + "testing" +) + +func ExampleRelativeName() { + fmt.Println(RelativeName("sub.example.com.", "example.com.")) + // Output: sub +} + +func ExampleAbsoluteName() { + fmt.Println(AbsoluteName("sub", "example.com.")) + // Output: sub.example.com. +} + +func TestRelativeName(t *testing.T) { + for i, test := range []struct { + fqdn, zone string + expect string + }{ + { + fqdn: "", + zone: "", + expect: "", + }, + { + fqdn: "", + zone: "example.com", + expect: "", + }, + { + fqdn: "example.com", + zone: "", + expect: "example.com", + }, + { + fqdn: "sub.example.com", + zone: "example.com", + expect: "sub", + }, + { + fqdn: "foo.bar.example.com", + zone: "bar.example.com", + expect: "foo", + }, + { + fqdn: "foo.bar.example.com", + zone: "example.com", + expect: "foo.bar", + }, + { + fqdn: "foo.bar.example.com.", + zone: "example.com.", + expect: "foo.bar", + }, + { + fqdn: "foo.bar.example.com", + zone: "example.com.", + expect: "foo.bar", + }, + { + fqdn: "foo.bar.example.com.", + zone: "example.com", + expect: "foo.bar", + }, + { + fqdn: "example.com", + zone: "example.net", + expect: "example.com", + }, + } { + actual := RelativeName(test.fqdn, test.zone) + if actual != test.expect { + t.Errorf("Test %d: FQDN=%s ZONE=%s - expected '%s' but got '%s'", + i, test.fqdn, test.zone, test.expect, actual) + } + } +} + +func TestAbsoluteName(t *testing.T) { + for i, test := range []struct { + name, zone string + expect string + }{ + { + name: "", + zone: "example.com", + expect: "example.com", + }, + { + name: "@", + zone: "example.com.", + expect: "example.com.", + }, + { + name: "www", + zone: "example.com.", + expect: "www.example.com.", + }, + { + name: "www", + zone: "example.com.", + expect: "www.example.com.", + }, + { + name: "www.", + zone: "example.com.", + expect: "www.example.com.", + }, + { + name: "foo.bar", + zone: "example.com.", + expect: "foo.bar.example.com.", + }, + { + name: "foo.bar.", + zone: "example.com.", + expect: "foo.bar.example.com.", + }, + { + name: "foo", + zone: "", + expect: "foo", + }, + } { + actual := AbsoluteName(test.name, test.zone) + if actual != test.expect { + t.Errorf("Test %d: NAME=%s ZONE=%s - expected '%s' but got '%s'", + i, test.name, test.zone, test.expect, actual) + } + } +} + +func TestSRVRecords(t *testing.T) { + for i, test := range []struct { + rec Record + srv SRV + }{ + { + rec: Record{ + Type: "SRV", + Name: "_service._proto.name", + Priority: 15, + Weight: 30, + Value: "5223 example.com", + }, + srv: SRV{ + Service: "service", + Proto: "proto", + Name: "name", + Priority: 15, + Weight: 30, + Port: 5223, + Target: "example.com", + }, + }, + { + rec: Record{ + Type: "SRV", + Name: "_service._proto.sub.example", + Priority: 15, + Weight: 30, + Value: "5223 foo", + }, + srv: SRV{ + Service: "service", + Proto: "proto", + Name: "sub.example", + Priority: 15, + Weight: 30, + Port: 5223, + Target: "foo", + }, + }, + } { + // Record -> SRV + actualSRV, err := test.rec.ToSRV() + if err != nil { + t.Errorf("Test %d: Record -> SRV: Expected no error, but got: %v", i, err) + continue + } + if actualSRV != test.srv { + t.Errorf("Test %d: Record -> SRV: For record %+v:\nEXPECTED %+v\nGOT %+v", + i, test.rec, test.srv, actualSRV) + } + + // Record -> SRV + actualRec := test.srv.ToRecord() + if actualRec != test.rec { + t.Errorf("Test %d: SRV -> Record: For SRV %+v:\nEXPECTED %+v\nGOT %+v", + i, test.srv, test.rec, actualRec) + } + } +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..95a9a93 --- /dev/null +++ b/models.go @@ -0,0 +1,35 @@ +package dnsexit + +type Action int64 + +const ( + setRecords Action = iota + appendRecords + deleteRecords +) + +type dnsExitPayload struct { + Apikey string `json:"apikey"` + Zone string `json:"domain"` + AddRecords *[]dnsExitRecord `json:"add,omitempty"` + DeleteRecords *[]dnsExitRecord `json:"delete,omitempty"` +} + +// TODO - look at co-ercing properties of LibDns mx records into MailZone/MailServer properties +// MailZone string `json:"mail-zone,omitempty"` // "mail-zone":"", +// MailServer string `json:"mail-server,omitempty"` // "mail-server":"mail2.dnsexit.com", + +type dnsExitRecord struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Content *string `json:"content,omitempty"` + Priority *int `json:"priority,omitempty"` + TTL *int `json:"ttl,omitempty"` + Overwrite *bool `json:"overwrite,omitempty"` +} + +type dnsExitResponse struct { + Code int `json:"code"` + Details []string `json:"details"` + Message string `json:"message"` +} diff --git a/provider.go b/provider.go new file mode 100644 index 0000000..1acaa18 --- /dev/null +++ b/provider.go @@ -0,0 +1,51 @@ +// Package dnsexit implements a DNS record management client compatible +// with the libdns interfaces for DNSExit. +package dnsexit + +import ( + "context" + "sync" + + "github.com/libdns/libdns" +) + +// Provider facilitates DNS record manipulation with DNSExit. +type Provider struct { + APIKey string `json:"api_key,omitempty"` + mutex sync.Mutex +} + +// GetRecords lists all the records in the zone. +// NOTE: DNSExit API does not facilitate this, so Google DNS is used. +func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { + libRecords, err := p.getDomain(ctx, zone) + if err != nil { + return nil, err + } + + return libRecords, nil +} + +// AppendRecords adds records to the zone. It returns the records that were added. This function will fail if a record with the same name already exists. +func (p *Provider) AppendRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + return p.amendRecords(zone, records, appendRecords) +} + +// SetRecords sets the records in the zone, either by updating existing records or creating new ones. +// It returns the updated records. +func (p *Provider) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + return p.amendRecords(zone, records, setRecords) +} + +// DeleteRecords deletes the records from the zone. It returns the records that were deleted. +func (p *Provider) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + return p.amendRecords(zone, records, deleteRecords) +} + +// Interface guards +var ( + _ libdns.RecordGetter = (*Provider)(nil) + _ libdns.RecordAppender = (*Provider)(nil) + _ libdns.RecordSetter = (*Provider)(nil) + _ libdns.RecordDeleter = (*Provider)(nil) +) diff --git a/provider_test.go b/provider_test.go new file mode 100644 index 0000000..16b941f --- /dev/null +++ b/provider_test.go @@ -0,0 +1,155 @@ +package dnsexit + +import ( + "context" + "log" + "os" + "reflect" + "testing" + + "github.com/joho/godotenv" + "github.com/libdns/libdns" +) + +var ( + apiKey string + zone string +) + +var ( + provider Provider +) + +// It is best to run these tests against a throwaway domain to prevent mistakes (DNSExit provides free domains). There is no cleanup after each test, so they do not work well as a suite, and are best run individually. Because the getRecords functionality uses Google DNS, there need to be records in the domain, and they need to have replicated to Google's DNS servers before this test will pass. (You can use https://toolbox.googleapps.com/apps/dig/ to verify before running.) +func init() { + envErr := godotenv.Load() + if envErr != nil { + log.Fatalf("Unable to load environment variables from file") + } + apiKey = os.Getenv("LIBDNS_DNSEXIT_API_KEY") + zone = os.Getenv("LIBDNS_DNSEXIT_ZONE") + debug = os.Getenv("LIBDNS_DNSEXIT_DEBUG") == "TRUE" + if apiKey == "" { + log.Fatalf("API key needs to be provided in env var LIBDNS_DNSEXIT_API_KEY") + } + if zone == "" { + log.Fatalf("DNS zone needs to be provided in env var LIBDNS_DNSEXIT_ZONE") + } + if zone[len(zone)-1:] != "." { + // Zone names come from caddy with trailing period + zone += "." + } + provider = Provider{APIKey: apiKey} +} + +func TestAppendRecords(t *testing.T) { + ctx := context.Background() + + records := []libdns.Record{ + { + Type: "A", + Name: "test001", + Value: "192.0.2.1", + }, + { + Type: "AAAA", + Name: "test001", + Value: "2001:0db8:2::1", + }, + { + Type: "TXT", + Name: "test001", + Value: "ZYXWVUTSRQPONMLKJIHGFEDCBA", + }, + } + + createdRecords, err := provider.AppendRecords(ctx, zone, records) + if err != nil { + t.Errorf("%v", err) + } + + if len(records) != len(createdRecords) { + t.Errorf("Number of appended records does not match number of records") + } + if !(reflect.DeepEqual(records, createdRecords)) { + t.Errorf("Appended records do not match") + } +} + +func TestGetRecords(t *testing.T) { + ctx := context.Background() + + records, err := provider.GetRecords(ctx, zone) + if err != nil { + t.Errorf("%v", err) + } + + if len(records) == 0 { + t.Errorf("No records") + } +} + +func TestSetRecords(t *testing.T) { + ctx := context.Background() + + goodRecords := []libdns.Record{ + { + Type: "A", + Name: "test001", + Value: "198.51.100.1", + }, + { + Type: "AAAA", + Name: "test001", + Value: "2001:0db8::1", + }, + { + Type: "TXT", + Name: "test001", + Value: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + }, + { + Type: "A", + Name: "test002", + Value: "198.51.100.2", + }, + { + Type: "A", + Name: "test003", + Value: "198.51.100.3", + }, + } + + createdRecords, err := provider.SetRecords(ctx, zone, goodRecords) + if err != nil { + t.Fatalf("adding records failed: %v", err) + } + + if len(goodRecords) != len(createdRecords) { + t.Fatalf("Number of added records does not match number of records") + } +} + +func TestDeleteRecords(t *testing.T) { + ctx := context.Background() + + records := []libdns.Record{ + { + Type: "AAAA", + Name: "test001", + }, + { + Type: "TXT", + Name: "test001", + }, + } + + deletedRecords, err := provider.DeleteRecords(ctx, zone, records) + if err != nil { + t.Errorf("deleting records failed: %v", err) + } + + if len(records) != len(deletedRecords) { + t.Errorf("Number of deleted records does not match number of records") + } +}