From 855f0d6e422020714e45760315d2f04c5e382433 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 11 Mar 2025 10:47:49 -0600 Subject: [PATCH 01/13] RelativeName returns "@" instead of "" --- libdns.go | 10 ++++++++-- libdns_test.go | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/libdns.go b/libdns.go index 3b8a3eb..7456f07 100644 --- a/libdns.go +++ b/libdns.go @@ -196,7 +196,9 @@ func (s SRV) ToRecord() Record { } // RelativeName makes fqdn relative to zone. For example, for a FQDN of -// "sub.example.com" and a zone of "example.com", it outputs "sub". +// "sub.example.com" and a zone of "example.com", it returns "sub". +// +// If fqdn is the same as zone (and both are non-empty), "@" is returned. // // If fqdn cannot be expressed relative to zone, the input fqdn is returned. func RelativeName(fqdn, zone string) string { @@ -206,7 +208,11 @@ func RelativeName(fqdn, zone string) string { // (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, ".")), ".") + rel := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(fqdn, "."), strings.TrimSuffix(zone, ".")), ".") + if rel == "" && fqdn != "" && zone != "" { + return "@" + } + return rel } // AbsoluteName makes name into a fully-qualified domain name (FQDN) by diff --git a/libdns_test.go b/libdns_test.go index b63f04b..a22d02f 100644 --- a/libdns_test.go +++ b/libdns_test.go @@ -30,11 +30,31 @@ func TestRelativeName(t *testing.T) { zone: "example.com", expect: "", }, + { + fqdn: "example.com.", + zone: "example.com.", + expect: "@", + }, + { + fqdn: "example.com", + zone: "example.com.", + expect: "@", + }, + { + fqdn: "example.com.", + zone: "example.com", + expect: "@", + }, { fqdn: "example.com", zone: "", expect: "example.com", }, + { + fqdn: "example.com.", + zone: "", + expect: "example.com", + }, { fqdn: "sub.example.com", zone: "example.com", From 013384db2596983450a19ddaa27f7f16fa69a922 Mon Sep 17 00:00:00 2001 From: Max Chernoff Date: Sat, 15 Mar 2025 06:36:41 -0600 Subject: [PATCH 02/13] Document exact semantics of DNS interfaces (#152) * Document exact semantics of DNS interfaces Closes #145 Co-authored-by: Matt Holt * Reformat documentation for better readability on pkg.go.dev --------- Co-authored-by: Matt Holt --- libdns.go | 379 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 286 insertions(+), 93 deletions(-) diff --git a/libdns.go b/libdns.go index 7456f07..d484282 100644 --- a/libdns.go +++ b/libdns.go @@ -1,36 +1,41 @@ -// Package libdns defines core interfaces that should be implemented by DNS +// 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 +// well-defined semantics for the purposes of reading and manipulating DNS +// records using DNS provider APIs. +// +// This documentation uses the definitions for terms from [RFC 7719]. +// +// 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. +// 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.) +// meaning 1) no data races, and 2) simultaneous method calls must result in +// either both their expected outcomes or an error. +// +// For example, if [libdns.RecordAppender.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.) +// +// [RFC 7719]: https://datatracker.ietf.org/doc/html/rfc7719 package libdns import ( @@ -41,98 +46,281 @@ import ( "time" ) -// RecordGetter can get records from a DNS zone. +// [RecordGetter] can get records from a DNS zone. +// +// “GetRecords” returns all the records in the DNS zone. +// +// DNSSEC-related records are typically not included in the output, but this +// behavior is implementation-defined. *If* an implementation includes DNSSEC +// records in the output, this behavior should be documented. +// +// Implementations must honor context cancellation and be safe for concurrent +// use. 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. +// [RecordAppender] can non-destructively add new records to a DNS zone. +// +// “AppendRecords” creates the requested records in the given zone and returns +// the populated records that were created. It never changes existing records. +// Therefore, it is invalid to use this method with “CNAME”-type records. +// +// Implementations must honor context cancellation and be safe for concurrent +// use. 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. +// [RecordSetter] can set new or update existing records in a DNS zone. +// +// “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. +// +// For any (name, type) pair in the input, “SetRecords” ensures that the only +// records in the output zone with that (name, type) pair are those that were +// provided in the input. +// +// In RFC 7719 terms, “SetRecords” appends, modifies, or deletes records in the +// zone so that for each RRset in the input, the records provided in the input +// are the only members of their RRset in the output zone. +// +// Implementations may decide whether or not to support DNSSEC-related records +// in calls to “SetRecords”, but should document their decision. Note that the +// decision to support DNSSEC records in “SetRecords” is independent of the +// decision to support them in [libdns.RecordGetter.GetRecords], so end-users +// should not blindly call “SetRecords” on the output of +// [libdns.RecordGetter.GetRecords]. +// +// Implementations must honor context cancellation and be safe for concurrent +// use. +// +// # Examples +// +// Example 1: +// +// ;; Original zone +// example.com. 3600 IN A 192.0.2.1 +// example.com. 3600 IN A 192.0.2.2 +// example.com. 3600 IN TXT "hello world" +// +// ;; Input +// example.com. 3600 IN A 192.0.2.3 +// +// ;; Resultant zone +// example.com. 3600 IN A 192.0.2.3 +// example.com. 3600 IN TXT "hello world" +// +// Example 2: +// +// ;; Original zone +// a.example.com. 3600 IN AAAA 2001:db8::1 +// a.example.com. 3600 IN AAAA 2001:db8::2 +// b.example.com. 3600 IN AAAA 2001:db8::3 +// b.example.com. 3600 IN AAAA 2001:db8::4 +// +// ;; Input +// a.example.com. 3600 IN AAAA 2001:db8::1 +// a.example.com. 3600 IN AAAA 2001:db8::2 +// a.example.com. 3600 IN AAAA 2001:db8::5 +// +// ;; Resultant zone +// a.example.com. 3600 IN AAAA 2001:db8::1 +// a.example.com. 3600 IN AAAA 2001:db8::2 +// a.example.com. 3600 IN AAAA 2001:db8::5 +// b.example.com. 3600 IN AAAA 2001:db8::3 +// b.example.com. 3600 IN AAAA 2001:db8::4 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. +// [RecordDeleter] can delete records from a DNS zone. +// +// “DeleteRecords” deletes the given records from the zone if they exist in the +// zone and exactly match the input. If the input records do not exist in the +// zone, they are silently ignored. “DeleteRecords” returns only the the records +// that were deleted, and does not return any records that were provided in the +// input but did not exist in the zone. +// +// “DeleteRecords” only deletes records from the zone that *exactly* match the +// input records—that is, the name, type, TTL, and value all must be identical +// to a record in the zone for it to be deleted. +// +// As a special case, you may leave any of the fields [libdns.Record.Type], +// [libdns.Record.TTL], or [libdns.Record.Value] empty ("", 0, and "" +// respectively). In this case, “DeleteRecords” will delete any records that +// match the other fields, regardless of the value of the fields that were left +// empty. Note that this behavior does *not* apply to the [libdns.Record.Name] +// field, which must always be specified. +// +// Note that it is semantically invalid to remove the last “NS” record from a +// zone, so attempting to do is undefined behavior. +// +// Implementations must honor context cancellation and be safe for concurrent +// use. 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. +// [ZoneLister] can list available DNS zones. +// +// “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. 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. +// [Record] is a generalized representation of a DNS record. +// +// # Type +// +// The [libdns.Record.Type] field specifies the type of the record as an +// uppercase string. Implementations may or may not support any given record +// type, and may support additional private record types with +// implementation-defined behavior. +// +// Examples: +// - “A” +// - “AAAA” +// - “CNAME” +// - “MX” +// - “TXT” +// +// # Name +// +// The [libdns.Record.Name] field specifies the name of the record. It is +// partially qualified relative to the current zone. This field is called a +// “Label” by RFC 7719. You may use “@” to represent the root of the zone. +// +// (For the following examples, assume the zone is “example.com.”) +// +// Examples: +// - “www” (for “www.example.com.”) +// - “@” (for “example.com.”) +// - “subdomain” (for “subdomain.example.com.”) +// - “sub.subdomain” (for “sub.subdomain.example.com.”) // -// 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. +// Invalid: +// - “www.example.com.” (fully-qualified) +// - “example.net.” (fully-qualified) +// - "" (empty) +// +// Valid, but probably doesn't do what you want: +// - “www.example.net” (refers to “www.example.net.example.com.”) +// +// # Value +// +// The [libdns.Record.Value] field specifies the value of the record. This field +// should be formatted in the standard zone file syntax, but should omit any +// fields that are covered by other fields in this struct. +// +// Examples: +// +// - (A) “192.0.2.1” +// +// - (AAAA) “2001:db8::1” +// +// - (CNAME) “example.com.” +// +// Even though the value is traditionally called the “target”, it is +// included only in the “Value” field here. +// +// - (MX) “mail.example.com.” +// +// Note that this excludes the priority field! +// +// - (TXT) “Hello, world!” +// +// - (SRV) “8080 example.com.” +// +// Note that this excludes the priority and weight fields, but includes the +// port. Also note that the target is included here, and not in the +// [libdns.Record.Target] field. +// +// - (HTTPS) “alpn=h2,h3 port=443” +// +// Note that this excludes the priority field and target fields. +// +// # TTL +// +// The [libdns.Record.TTL] field specifies the time-to-live of the record. This +// is represented in the DNS as an unsigned integral number of seconds, but is +// provided here as a [time.Duration]. Fractions of seconds will be rounded down +// (aka truncated). A value of “0” means that the record should not be cached. +// +// Note that some providers may reject or silently increase TTLs that are below +// a certain threshold, and that DNS resolvers may choose to ignore your TTL +// settings, so it is recommended to not rely on the exact TTL value. +// +// # Priority +// +// The [libdns.Record.Priority] field specifies the priority of the record. This +// field is only applicable for certain record types, but is mandatory for those +// types. +// +// Examples: +// +// - (MX) “10” +// +// Note that this is traditionally called the “preference” in the DNS. +// +// - (SRV) “10” +// +// - (URI) “10” +// +// - (HTTPS) “10” +// +// - (SVCB) “10” +// +// # Weight +// +// The [libdns.Record.Weight] field specifies the weight of the record. This +// field is only applicable for certain record types, but is mandatory for those +// types. +// +// Examples: +// - (SRV) “20” +// - (URI) “20” +// +// # Target +// +// The [libdns.Record.Target] field specifies the target of the record. This +// field is only valid for “HTTPS” and “SVCB” records, and *not* for “SRV”, +// “MX”, or “CNAME” records, which store their targets in the +// [libdns.Record.Value] field. This field must be set to the fully-qualified +// domain name (FQDN) of the target (which includes the trailing dot). +// +// Examples: +// - (HTTPS) “example.com.” +// - (SVCB) “example.com.” type Record struct { // provider-specific metadata ID string // general record fields Type string - Name string // partially-qualified (relative to zone) + Name string Value string TTL time.Duration // common, type-dependent record fields - Priority uint // HTTPS, MX, SRV, and URI records - Weight uint // SRV and URI records - Target string // HTTPS records + Priority uint + Weight uint + Target string } -// Zone is a generalized representation of a DNS zone. +// [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. +// [Record.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) { @@ -169,7 +357,7 @@ func (r Record) ToSRV() (SRV, error) { }, nil } -// SRV contains all the parsed data of an SRV record. +// [SRV] contains all the parsed data of an “SRV” record. // // EXPERIMENTAL; subject to change or removal. type SRV struct { @@ -182,7 +370,7 @@ type SRV struct { Target string } -// ToRecord converts the parsed SRV data to a Record struct. +// [SRV.ToRecord] converts the parsed SRV data to a [Record] struct. // // EXPERIMENTAL; subject to change or removal. func (s SRV) ToRecord() Record { @@ -195,12 +383,13 @@ func (s SRV) ToRecord() Record { } } -// RelativeName makes fqdn relative to zone. For example, for a FQDN of -// "sub.example.com" and a zone of "example.com", it returns "sub". +// [RelativeName] makes “fqdn” relative to “zone”. For example, for a FQDN of +// “sub.example.com” and a zone of “example.com.”, it returns “sub”. // -// If fqdn is the same as zone (and both are non-empty), "@" is returned. +// If “fqdn” is the same as “zone” (and both are non-empty), “@” is returned. // -// If fqdn cannot be expressed relative to zone, the input fqdn is returned. +// 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 @@ -215,9 +404,13 @@ func RelativeName(fqdn, zone string) string { return rel } -// 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.". +// [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.”. +// +// Using “@” as the name is the recommended way to represent the root of the +// zone; however, unlike the [Record] struct, using the empty string "" for the +// name *is* permitted here, and will be identically to “@”. func AbsoluteName(name, zone string) string { if zone == "" { return strings.Trim(name, ".") From 1cd79c4a54f0ecb74afe7599b6de8889f0f4738b Mon Sep 17 00:00:00 2001 From: Matt Holt Date: Sun, 6 Apr 2025 20:26:46 -0600 Subject: [PATCH 03/13] All-new Record abstraction and exported APIs (#153) --- README.md | 33 +-- libdns.go | 543 ++++++++++++++++++------------------------------- libdns_test.go | 71 +------ record.go | 503 +++++++++++++++++++++++++++++++++++++++++++++ record_test.go | 479 +++++++++++++++++++++++++++++++++++++++++++ rrtypes.go | 375 ++++++++++++++++++++++++++++++++++ 6 files changed, 1575 insertions(+), 429 deletions(-) create mode 100644 record.go create mode 100644 record_test.go create mode 100644 rrtypes.go diff --git a/README.md b/README.md index b5f77b4..defa89a 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,15 @@ libdns - Universal DNS provider APIs for Go `libdns` is a collection of free-range DNS provider client implementations written in Go! With libdns packages, your Go program can manage DNS records across any supported providers. A "provider" is a service or program that manages a DNS zone. -This repository defines the core interfaces that provider packages should implement. They are small and idiomatic Go interfaces with well-defined semantics. +This repository defines the core interfaces that provider packages should implement. They are small and idiomatic Go interfaces with well-defined semantics for managing DNS records. The interfaces include: - [`RecordGetter`](https://pkg.go.dev/github.com/libdns/libdns#RecordGetter) to list records. -- [`RecordAppender`](https://pkg.go.dev/github.com/libdns/libdns#RecordAppender) to append new records. -- [`RecordSetter`](https://pkg.go.dev/github.com/libdns/libdns#RecordSetter) to set (create or change existing) records. +- [`RecordAppender`](https://pkg.go.dev/github.com/libdns/libdns#RecordAppender) to create new records. +- [`RecordSetter`](https://pkg.go.dev/github.com/libdns/libdns#RecordSetter) to set (create or update) records. - [`RecordDeleter`](https://pkg.go.dev/github.com/libdns/libdns#RecordDeleter) to delete records. +- [`ZoneLister`](https://pkg.go.dev/github.com/libdns/libdns#ZoneLister) to list zones. [See full godoc for detailed documentation.](https://pkg.go.dev/github.com/libdns/libdns) @@ -39,19 +40,19 @@ provider := cloudflare.Provider{APIToken: "topsecret"} // list records recs, err := provider.GetRecords(ctx, zone) -// create records (AppendRecords is similar) +// create records (AppendRecords is similar, with different semantics) newRecs, err := provider.SetRecords(ctx, zone, []libdns.Record{ - { - Type: "A", - Name: "sub", - Value: "1.2.3.4", + libdns.A{ + Name: "@", + Value: netip.MustParseAddr("1.2.3.4"), }, }) -// delete records (this example uses provider-assigned ID) +// delete records deletedRecs, err := provider.DeleteRecords(ctx, zone, []libdns.Record{ - { - ID: "foobar", + libdns.TXT{ + Name: "subdomain", + Text: "txt value I want to delete" }, }) @@ -62,7 +63,7 @@ deletedRecs, err := provider.DeleteRecords(ctx, zone, []libdns.Record{ ## Implementing new provider packages -Provider packages are 100% written and maintained by the community! Collectively, we all maintain the packages for providers we individually use. +Provider packages are 100% written and maintained by the community! Collectively, we as members of the community each maintain the packages for providers we personally use. **[Instructions for adding new libdns packages](https://github.com/libdns/libdns/wiki/Implementing-a-libdns-package)** are on this repo's wiki. Please feel free to contribute yours! @@ -79,13 +80,15 @@ This is incredibly useful when you are maintaining your own zone file, but risky **[go-acme/lego](https://github.com/go-acme/lego)** has support for a huge number of DNS providers (75+!), but their APIs are only capable of setting and deleting TXT records for ACME challenges. -**`libdns`** takes inspiration from the above projects but aims for a more generally-useful set of APIs that homogenize pretty well across providers. In contrast to the above projects, libdns can add, set, delete, and get arbitrary records from a zone without obliterating it (although syncing up an entire zone is also possible!). Its APIs also include context so long-running calls can be cancelled early, for example to accommodate on-line config changes downstream. libdns interfaces are also smaller and more composable. Additionally, libdns can grow to support a nearly infinite number of DNS providers without added bloat, because each provider implementation is a separate Go module, which keeps your builds lean and fast. +**[miekg/dns](https://github.com/miekg/dns)** is a comprehensive, low-level DNS library for Go programs. It is well-maintained and extremely thorough, but also a bit lower-level than our needs require. + +**`libdns`** takes inspiration from the above projects but aims for a more generally-useful set of high-level APIs that homogenize pretty well across providers. In contrast to the above projects, libdns can add, set, delete, and get arbitrary records from a zone without obliterating it (although syncing up an entire zone is also possible!). Its APIs also include context so long-running calls can be cancelled early, for example to accommodate on-line config changes downstream. libdns interfaces are also smaller and more composable. Additionally, libdns can grow to support a nearly infinite number of DNS providers without added bloat, because each provider implementation is a separate Go module, which keeps your builds lean and fast. In summary, the goal is that libdns providers can do what the above libraries/tools can do, but with more flexibility: they can create and delete TXT records for ACME challenges, they can replace entire zones, but they can also do incremental changes or simply read records. ## Record abstraction -How records are represented across providers varies widely, and each kind of record has different fields and semantics. In time, our goal is for the `libdns.Record` type to be able to represent most of them as concisely and simply as possible, with the interface methods able to deliver on most of the possible zone operations. +How records are represented across providers varies widely, and each kind of record has different fields and semantics. -Realistically, libdns should enable most common record manipulations, but may not be able to fit absolutely 100% of all possibilities with DNS in a provider-agnostic way. That is probably OK; and given the wide varieties in DNS record types and provider APIs, it would be unreasonable to expect otherwise. We are not aiming for 100% fulfillment of 100% of users' requirements; more like 100% fulfillment of ~90% of users' requirements. +Realistically, libdns should enable most common record manipulations, but may not be able to fit absolutely 100% of all possibilities with DNS in a provider-agnostic way. That is probably OK; and given the wide varieties in DNS record types and provider APIs, it would be unreasonable to expect otherwise. Our goal is 100% fulfillment of ~90% of use cases / user requirements, not 100% fulfillment of 100% of use cases. diff --git a/libdns.go b/libdns.go index d484282..00938f9 100644 --- a/libdns.go +++ b/libdns.go @@ -1,394 +1,234 @@ -// Package [libdns] defines core interfaces that should be implemented by DNS -// provider clients. They are small and idiomatic Go interfaces with -// well-defined semantics for the purposes of reading and manipulating DNS -// records using DNS provider APIs. -// -// This documentation uses the definitions for terms from [RFC 7719]. +// Package [libdns] defines core interfaces that should be implemented by +// packages that interact with DNS provider clients. These interfaces are +// small and idiomatic Go interfaces with well-defined semantics for the +// purposes of reading and manipulating DNS records using DNS provider APIs. +// +// This documentation uses the definitions for terms from RFC 9499: +// https://datatracker.ietf.org/doc/html/rfc9499 +// +// This package represents DNS records in two primary ways: as opaque [RR] +// structs, where the data is serialized as a single string as in a zone file; +// and as individual type structures, where the data is parsed into its separate +// fields for easier manipulation by Go programs (for example: [SRV] and [HTTPS] +// types). This hybrid design offers great flexibility for both DNS provider +// packages and consumer Go programs. +// +// This package represents records flexibly with the [Record] interface, which +// is any type that can transform itself into the [RR] struct, which is a +// type-agnostic [Resource Record] (that is, a name, type, class, TTL, and data). +// Specific record types such as [Address], [SRV], [TXT], and others implement +// the [Record] interface. +// +// Implementations of the libdns interfaces should accept as input any [Record] +// value, and should return as output the concrete struct types that implement +// the [Record] interface (i.e. [Address], [TXT], [ServiceBinding], etc). This +// is important to ensure the provider libraries are robust and also predictable: +// callers can reliably type-switch on the output to immediately access structured +// data about each record without the possibility of errors. Returned values should +// be of types defined by this package to make type-assertions reliable. // // 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. +// grants records portability across zones. As such, record names are partially +// qualified, i.e. relative to the zone. For example, 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; +// see [RelativeName] and [AbsoluteName]. // // 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. +// maintain the pairing between a zone's name and its records. // // 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 [libdns.RecordAppender.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.) -// -// [RFC 7719]: https://datatracker.ietf.org/doc/html/rfc7719 +// either both their expected outcomes or an error. For example, if +// [libdns.RecordAppender.AppendRecords] is called simultaneously, 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 zone. (Exact logic +// depends on the provider.) +// +// Some service providers APIs may enforce rate limits or have sporadic errors. +// It is generally expected that libdns provider packages implement basic retry +// logic (e.g. retry up to 3-5 times with backoff in the event of a connection error +// or some HTTP error that may be recoverable, including 5xx or 429s) when it is +// safe to do so. Retrying/recovering from errors should not add substantial latency, +// though. If it will take longer than a couple seconds, best to return an error. +// +// [Resource Record]: https://en.wikipedia.org/wiki/Domain_Name_System#Resource_records package libdns import ( "context" - "fmt" - "strconv" "strings" - "time" ) // [RecordGetter] can get records from a DNS zone. -// -// “GetRecords” returns all the records in the DNS zone. -// -// DNSSEC-related records are typically not included in the output, but this -// behavior is implementation-defined. *If* an implementation includes DNSSEC -// records in the output, this behavior should be documented. -// -// Implementations must honor context cancellation and be safe for concurrent -// use. type RecordGetter interface { + // GetRecords returns all the records in the DNS zone. + // + // DNSSEC-related records are typically not included in the output, but this + // behavior is implementation-defined. If an implementation includes DNSSEC + // records in the output, this behavior should be documented. + // + // 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. -// -// “AppendRecords” creates the requested records in the given zone and returns -// the populated records that were created. It never changes existing records. -// Therefore, it is invalid to use this method with “CNAME”-type records. -// -// Implementations must honor context cancellation and be safe for concurrent -// use. type RecordAppender interface { + // AppendRecords creates the inputted records in the given zone and returns + // the populated records that were created. It never changes existing records. + // + // Therefore, it makes little sense to use this method with CNAME-type + // records since if there are no existing records with the same name, it + // behaves the same as [libdns.RecordSetter.SetRecords], and if there are + // existing records with the same name, it will either fail or leave the + // zone in an invalid state. + // + // 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. -// -// “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. -// -// For any (name, type) pair in the input, “SetRecords” ensures that the only -// records in the output zone with that (name, type) pair are those that were -// provided in the input. -// -// In RFC 7719 terms, “SetRecords” appends, modifies, or deletes records in the -// zone so that for each RRset in the input, the records provided in the input -// are the only members of their RRset in the output zone. -// -// Implementations may decide whether or not to support DNSSEC-related records -// in calls to “SetRecords”, but should document their decision. Note that the -// decision to support DNSSEC records in “SetRecords” is independent of the -// decision to support them in [libdns.RecordGetter.GetRecords], so end-users -// should not blindly call “SetRecords” on the output of -// [libdns.RecordGetter.GetRecords]. -// -// Implementations must honor context cancellation and be safe for concurrent -// use. -// -// # Examples -// -// Example 1: -// -// ;; Original zone -// example.com. 3600 IN A 192.0.2.1 -// example.com. 3600 IN A 192.0.2.2 -// example.com. 3600 IN TXT "hello world" -// -// ;; Input -// example.com. 3600 IN A 192.0.2.3 -// -// ;; Resultant zone -// example.com. 3600 IN A 192.0.2.3 -// example.com. 3600 IN TXT "hello world" -// -// Example 2: -// -// ;; Original zone -// a.example.com. 3600 IN AAAA 2001:db8::1 -// a.example.com. 3600 IN AAAA 2001:db8::2 -// b.example.com. 3600 IN AAAA 2001:db8::3 -// b.example.com. 3600 IN AAAA 2001:db8::4 -// -// ;; Input -// a.example.com. 3600 IN AAAA 2001:db8::1 -// a.example.com. 3600 IN AAAA 2001:db8::2 -// a.example.com. 3600 IN AAAA 2001:db8::5 -// -// ;; Resultant zone -// a.example.com. 3600 IN AAAA 2001:db8::1 -// a.example.com. 3600 IN AAAA 2001:db8::2 -// a.example.com. 3600 IN AAAA 2001:db8::5 -// b.example.com. 3600 IN AAAA 2001:db8::3 -// b.example.com. 3600 IN AAAA 2001:db8::4 type RecordSetter interface { + // SetRecords updates the zone so that the records described in the input are + // reflected in the output. It may create or update 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. + // + // For any (name, type) pair in the input, SetRecords ensures that the only + // records in the output zone with that (name, type) pair are those that were + // provided in the input. + // + // In RFC 9499 terms, SetRecords appends, modifies, or deletes records in the + // zone so that for each RRset in the input, the records provided in the input + // are the only members of their RRset in the output zone. + // + // Implementations may decide whether or not to support DNSSEC-related records + // in calls to SetRecords, but should document their decision. Note that the + // decision to support DNSSEC records in SetRecords is independent of the + // decision to support them in [libdns.RecordGetter.GetRecords], so callers + // should not blindly call SetRecords with the output of + // [libdns.RecordGetter.GetRecords]. + // + // Calls to SetRecords are presumed to be atomic; that is, if err == nil, + // then all of the requested changes were made; if err != nil, then none of + // the requested changes were made, and the zone is as if the method was + // never called. Some provider APIs may not support atomic operations, so it + // is recommended that implementations synthesize atomicity by transparently + // rolling back changes on failure; if this is not possible, then it should + // be clearly documented that errors may result in partial changes to the + // zone. + // + // If SetRecords is used to add a CNAME record to a name with other existing + // non-DNSSEC records, implementations may either fail with an error, add + // the CNAME and leave the other records in place (in violation of the DNS + // standards), or add the CNAME and remove the other preexisting records. + // Therefore, users should proceed with caution when using SetRecords with + // CNAME records. + // + // Implementations must honor context cancellation and be safe for concurrent + // use. + // + // # Examples + // + // Example 1: + // + // ;; Original zone + // example.com. 3600 IN A 192.0.2.1 + // example.com. 3600 IN A 192.0.2.2 + // example.com. 3600 IN TXT "hello world" + // + // ;; Input + // example.com. 3600 IN A 192.0.2.3 + // + // ;; Resultant zone + // example.com. 3600 IN A 192.0.2.3 + // example.com. 3600 IN TXT "hello world" + // + // Example 2: + // + // ;; Original zone + // a.example.com. 3600 IN AAAA 2001:db8::1 + // a.example.com. 3600 IN AAAA 2001:db8::2 + // b.example.com. 3600 IN AAAA 2001:db8::3 + // b.example.com. 3600 IN AAAA 2001:db8::4 + // + // ;; Input + // a.example.com. 3600 IN AAAA 2001:db8::1 + // a.example.com. 3600 IN AAAA 2001:db8::2 + // a.example.com. 3600 IN AAAA 2001:db8::5 + // + // ;; Resultant zone + // a.example.com. 3600 IN AAAA 2001:db8::1 + // a.example.com. 3600 IN AAAA 2001:db8::2 + // a.example.com. 3600 IN AAAA 2001:db8::5 + // b.example.com. 3600 IN AAAA 2001:db8::3 + // b.example.com. 3600 IN AAAA 2001:db8::4 SetRecords(ctx context.Context, zone string, recs []Record) ([]Record, error) } // [RecordDeleter] can delete records from a DNS zone. -// -// “DeleteRecords” deletes the given records from the zone if they exist in the -// zone and exactly match the input. If the input records do not exist in the -// zone, they are silently ignored. “DeleteRecords” returns only the the records -// that were deleted, and does not return any records that were provided in the -// input but did not exist in the zone. -// -// “DeleteRecords” only deletes records from the zone that *exactly* match the -// input records—that is, the name, type, TTL, and value all must be identical -// to a record in the zone for it to be deleted. -// -// As a special case, you may leave any of the fields [libdns.Record.Type], -// [libdns.Record.TTL], or [libdns.Record.Value] empty ("", 0, and "" -// respectively). In this case, “DeleteRecords” will delete any records that -// match the other fields, regardless of the value of the fields that were left -// empty. Note that this behavior does *not* apply to the [libdns.Record.Name] -// field, which must always be specified. -// -// Note that it is semantically invalid to remove the last “NS” record from a -// zone, so attempting to do is undefined behavior. -// -// Implementations must honor context cancellation and be safe for concurrent -// use. type RecordDeleter interface { + // DeleteRecords deletes the given records from the zone if they exist in the + // zone and exactly match the input. If the input records do not exist in the + // zone, they are silently ignored. DeleteRecords returns only the the records + // that were deleted, and does not return any records that were provided in the + // input but did not exist in the zone. + // + // DeleteRecords only deletes records from the zone that *exactly* match the + // input records—that is, the name, type, TTL, and value all must be identical + // to a record in the zone for it to be deleted. + // + // As a special case, you may leave any of the fields [libdns.Record.Type], + // [libdns.Record.TTL], or [libdns.Record.Value] empty ("", 0, and "" + // respectively). In this case, DeleteRecords will delete any records that + // match the other fields, regardless of the value of the fields that were left + // empty. Note that this behavior does *not* apply to the [libdns.Record.Name] + // field, which must always be specified. + // + // Note that it is semantically invalid to remove the last “NS” record from a + // zone, so attempting to do is undefined behavior. + // + // 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. -// -// “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. type ZoneLister interface { + // ListZones returns the list of available DNS zones for use by other + // [libdns] methods. Not every upstream provider API supports listing + // available zones, and very few [libdns]-dependent packages use this + // method, so this method is optional. + // + // 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. -// -// # Type -// -// The [libdns.Record.Type] field specifies the type of the record as an -// uppercase string. Implementations may or may not support any given record -// type, and may support additional private record types with -// implementation-defined behavior. -// -// Examples: -// - “A” -// - “AAAA” -// - “CNAME” -// - “MX” -// - “TXT” -// -// # Name -// -// The [libdns.Record.Name] field specifies the name of the record. It is -// partially qualified relative to the current zone. This field is called a -// “Label” by RFC 7719. You may use “@” to represent the root of the zone. -// -// (For the following examples, assume the zone is “example.com.”) -// -// Examples: -// - “www” (for “www.example.com.”) -// - “@” (for “example.com.”) -// - “subdomain” (for “subdomain.example.com.”) -// - “sub.subdomain” (for “sub.subdomain.example.com.”) -// -// Invalid: -// - “www.example.com.” (fully-qualified) -// - “example.net.” (fully-qualified) -// - "" (empty) -// -// Valid, but probably doesn't do what you want: -// - “www.example.net” (refers to “www.example.net.example.com.”) -// -// # Value -// -// The [libdns.Record.Value] field specifies the value of the record. This field -// should be formatted in the standard zone file syntax, but should omit any -// fields that are covered by other fields in this struct. -// -// Examples: -// -// - (A) “192.0.2.1” -// -// - (AAAA) “2001:db8::1” -// -// - (CNAME) “example.com.” -// -// Even though the value is traditionally called the “target”, it is -// included only in the “Value” field here. -// -// - (MX) “mail.example.com.” -// -// Note that this excludes the priority field! -// -// - (TXT) “Hello, world!” -// -// - (SRV) “8080 example.com.” -// -// Note that this excludes the priority and weight fields, but includes the -// port. Also note that the target is included here, and not in the -// [libdns.Record.Target] field. -// -// - (HTTPS) “alpn=h2,h3 port=443” -// -// Note that this excludes the priority field and target fields. -// -// # TTL -// -// The [libdns.Record.TTL] field specifies the time-to-live of the record. This -// is represented in the DNS as an unsigned integral number of seconds, but is -// provided here as a [time.Duration]. Fractions of seconds will be rounded down -// (aka truncated). A value of “0” means that the record should not be cached. -// -// Note that some providers may reject or silently increase TTLs that are below -// a certain threshold, and that DNS resolvers may choose to ignore your TTL -// settings, so it is recommended to not rely on the exact TTL value. -// -// # Priority -// -// The [libdns.Record.Priority] field specifies the priority of the record. This -// field is only applicable for certain record types, but is mandatory for those -// types. -// -// Examples: -// -// - (MX) “10” -// -// Note that this is traditionally called the “preference” in the DNS. -// -// - (SRV) “10” -// -// - (URI) “10” -// -// - (HTTPS) “10” -// -// - (SVCB) “10” -// -// # Weight -// -// The [libdns.Record.Weight] field specifies the weight of the record. This -// field is only applicable for certain record types, but is mandatory for those -// types. -// -// Examples: -// - (SRV) “20” -// - (URI) “20” -// -// # Target -// -// The [libdns.Record.Target] field specifies the target of the record. This -// field is only valid for “HTTPS” and “SVCB” records, and *not* for “SRV”, -// “MX”, or “CNAME” records, which store their targets in the -// [libdns.Record.Value] field. This field must be set to the fully-qualified -// domain name (FQDN) of the target (which includes the trailing dot). -// -// Examples: -// - (HTTPS) “example.com.” -// - (SVCB) “example.com.” -type Record struct { - // provider-specific metadata - ID string - - // general record fields - Type string - Name string - Value string - TTL time.Duration - - // common, type-dependent record fields - Priority uint - Weight uint - Target string -} - // [Zone] is a generalized representation of a DNS zone. type Zone struct { Name string } -// [Record.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 -} - -// [SRV.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 returns “sub”. // -// If “fqdn” is the same as “zone” (and both are non-empty), “@” is returned. +// If fqdn is the same as zone (and both are non-empty), “@” is returned. // -// If “fqdn” cannot be expressed relative to “zone”, the input “fqdn” is +// 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 @@ -404,13 +244,19 @@ func RelativeName(fqdn, zone string) string { return rel } -// [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.”. +// [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.”. If +// the name ends with a dot, it will be returned as the FQDN. // // Using “@” as the name is the recommended way to represent the root of the // zone; however, unlike the [Record] struct, using the empty string "" for the -// name *is* permitted here, and will be identically to “@”. +// name *is* permitted here, and will be treated identically to “@”. +// +// In the name already has a trailing dot, it is returned as-is. This is similar +// to the behavior of [path/filepath.Abs], and means that [AbsoluteName] is +// idempotent, so it is safe to call multiple times without first checking if +// the name is absolute or relative. func AbsoluteName(name, zone string) string { if zone == "" { return strings.Trim(name, ".") @@ -418,8 +264,9 @@ func AbsoluteName(name, zone string) string { if name == "" || name == "@" { return zone } - if !strings.HasSuffix(name, ".") { - name += "." + if strings.HasSuffix(name, ".") { + // Already a FQDN, so just return it + return name } - return name + zone + return name + "." + zone } diff --git a/libdns_test.go b/libdns_test.go index a22d02f..1bd780c 100644 --- a/libdns_test.go +++ b/libdns_test.go @@ -124,18 +124,19 @@ func TestAbsoluteName(t *testing.T) { zone: "example.com.", expect: "www.example.com.", }, + // see discussion at https://github.com/libdns/libdns/pull/153#discussion_r2013372378 about these next two { name: "www.", zone: "example.com.", - expect: "www.example.com.", + expect: "www.", }, { - name: "foo.bar", + name: "foo.bar.", zone: "example.com.", - expect: "foo.bar.example.com.", + expect: "foo.bar.", }, { - name: "foo.bar.", + name: "foo.bar", zone: "example.com.", expect: "foo.bar.example.com.", }, @@ -152,65 +153,3 @@ func TestAbsoluteName(t *testing.T) { } } } - -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/record.go b/record.go new file mode 100644 index 0000000..65656bd --- /dev/null +++ b/record.go @@ -0,0 +1,503 @@ +package libdns + +import ( + "fmt" + "net/netip" + "strconv" + "strings" + "time" +) + +// Record is any type that can reduce itself to the [RR] struct. +// +// Primitive equality (“==”) between any two [Record]s is explicitly undefined; +// if implementations need to compare records, they should either define their +// own equality functions or compare the [RR] structs directly. +type Record interface { + RR() RR +} + +// RR represents a [DNS Resource Record], which resembles how records are +// represented by DNS servers in zone files. +// +// The fields in this struct are common to all RRs, with the data field +// being opaque; it has no particular meaning until it is parsed. +// +// [DNS Resource Record]: https://en.wikipedia.org/wiki/Domain_Name_System#Resource_records +type RR struct { + // The name of the record. It is partially qualified, relative to the zone. + // For the sake of consistency, use "@" to represent the root of the zone. + // An empty name typically refers to the last-specified name in the zone + // file, which is only determinable in specific contexts. + // + // (For the following examples, assume the zone is “example.com.”) + // + // Examples: + // - “www” (for “www.example.com.”) + // - “@” (for “example.com.”) + // - “subdomain” (for “subdomain.example.com.”) + // - “sub.subdomain” (for “sub.subdomain.example.com.”) + // + // Invalid: + // - “www.example.com.” (fully-qualified) + // - “example.net.” (fully-qualified) + // - "" (empty) + // + // Valid, but probably doesn't do what you want: + // - “www.example.net” (refers to “www.example.net.example.com.”) + Name string + + // The time-to-live of the record. This is represented in the DNS zone file as + // an unsigned integral number of seconds, but is provided here as a + // [time.Duration] for ease of use in Go code. Fractions of seconds will be + // rounded down (truncated). A value of 0 means that the record should not be + // cached. Some provider implementations may assume a default TTL from 0; to + // avoid this, set TTL to a sub-second duration. + // + // Note that some providers may reject or silently increase TTLs that are below + // a certain threshold, and that DNS resolvers may choose to ignore your TTL + // settings, so it is recommended to not rely on the exact TTL value. + TTL time.Duration + + // The type of the record as an uppercase string. DNS provider packages are + // encouraged to support as many of the most common record types as possible, + // especially: A, AAAA, CNAME, TXT, HTTPS, and SRV. + // + // Other custom record types may be supported with implementation-defined + // behavior. + Type string + + // The data (or "value") of the record. This field should be formatted in + // the *unescaped* standard zone file syntax (technically, the "RDATA" field + // as defined by RFC 1035 §5.1). Due to variances in escape sequences and + // provider support, this field should not contain escapes. More concretely, + // the following [libdns.Record]s + // + // []libdns.TXT{ + // { + // Name: "alpha", + // Text: `quotes " backslashes \000`, + // }, { + // Name: "beta", + // Text: "del: \x7F", + // }, + // } + // + // should be equivalent to the following in zone file syntax: + // + // alpha 0 IN TXT "quotes \" backslashes \\000" + // beta 0 IN TXT "del: \177" + // + // Implementations are not expected to support RFC 3597 “\#” escape + // sequences, but may choose to do so if they wish. + Data string +} + +// RR returns itself. This may be the case when trying to parse an RR type +// that is not (yet) supported/implemented by this package. +func (r RR) RR() RR { return r } + +// Parse returns a type-specific structure for this RR, if it is +// a known/supported type. Otherwise, it returns itself. +// +// Callers will typically want to type-assert (or use a type switch on) +// the return value to extract values or manipulate it. +func (r RR) Parse() (Record, error) { + switch r.Type { + case "A", "AAAA": + return r.toAddress() + case "CAA": + return r.toCAA() + case "CNAME": + return r.toCNAME() + case "HTTPS", "SVCB": + return r.toSVCB() + case "MX": + return r.toMX() + case "NS": + return r.toNS() + case "SRV": + return r.toSRV() + case "TXT": + return r.toTXT() + default: + return r, nil + } +} + +func (r RR) toAddress() (Address, error) { + if r.Type != "A" && r.Type != "AAAA" { + return Address{}, fmt.Errorf("record type not A or AAAA: %s", r.Type) + } + + ip, err := netip.ParseAddr(r.Data) + if err != nil { + return Address{}, fmt.Errorf("invalid IP address %q: %v", r.Data, err) + } + + return Address{ + Name: r.Name, + IP: ip, + TTL: r.TTL, + }, nil +} + +func (r RR) toCAA() (CAA, error) { + if expectedType := "CAA"; r.Type != expectedType { + return CAA{}, fmt.Errorf("record type not %s: %s", expectedType, r.Type) + } + + fields := strings.Fields(r.Data) + if expectedLen := 3; len(fields) != expectedLen { + return CAA{}, fmt.Errorf(`malformed CAA value; expected %d fields in the form 'flags tag "value"'`, expectedLen) + } + + flags, err := strconv.ParseUint(fields[0], 10, 8) + if err != nil { + return CAA{}, fmt.Errorf("invalid flags %s: %v", fields[0], err) + } + tag := fields[1] + value := strings.Trim(fields[2], `"`) + + return CAA{ + Name: r.Name, + TTL: r.TTL, + Flags: uint8(flags), + Tag: tag, + Value: value, + }, nil +} + +func (r RR) toCNAME() (CNAME, error) { + if expectedType := "CNAME"; r.Type != expectedType { + return CNAME{}, fmt.Errorf("record type not %s: %s", expectedType, r.Type) + } + return CNAME{ + Name: r.Name, + TTL: r.TTL, + Target: r.Data, + }, nil +} + +func (r RR) toMX() (MX, error) { + if expectedType := "MX"; r.Type != expectedType { + return MX{}, fmt.Errorf("record type not %s: %s", expectedType, r.Type) + } + + fields := strings.Fields(r.Data) + if expectedLen := 2; len(fields) != expectedLen { + return MX{}, fmt.Errorf("malformed MX value; expected %d fields in the form 'preference target'", expectedLen) + } + + priority, err := strconv.ParseUint(fields[0], 10, 16) + if err != nil { + return MX{}, fmt.Errorf("invalid priority %s: %v", fields[0], err) + } + target := fields[1] + + return MX{ + Name: r.Name, + TTL: r.TTL, + Preference: uint16(priority), + Target: target, + }, nil +} + +func (r RR) toNS() (NS, error) { + if expectedType := "NS"; r.Type != expectedType { + return NS{}, fmt.Errorf("record type not %s: %s", expectedType, r.Type) + } + return NS{ + Name: r.Name, + TTL: r.TTL, + Target: r.Data, + }, nil +} + +func (r RR) toSRV() (SRV, error) { + if expectedType := "SRV"; r.Type != expectedType { + return SRV{}, fmt.Errorf("record type not %s: %s", expectedType, r.Type) + } + + fields := strings.Fields(r.Data) + if expectedLen := 4; len(fields) != expectedLen { + return SRV{}, fmt.Errorf("malformed SRV value; expected %d fields in the form 'priority weight port target'", expectedLen) + } + + priority, err := strconv.ParseUint(fields[0], 10, 16) + if err != nil { + return SRV{}, fmt.Errorf("invalid priority %s: %v", fields[0], err) + } + weight, err := strconv.ParseUint(fields[1], 10, 16) + if err != nil { + return SRV{}, fmt.Errorf("invalid weight %s: %v", fields[0], err) + } + port, err := strconv.ParseUint(fields[2], 10, 16) + if err != nil { + return SRV{}, fmt.Errorf("invalid port %s: %v", fields[0], err) + } + target := fields[3] + + 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], "_"), + Transport: strings.TrimPrefix(parts[1], "_"), + Name: parts[2], + TTL: r.TTL, + Priority: uint16(priority), + Weight: uint16(weight), + Port: uint16(port), + Target: target, + }, nil +} + +func (r RR) toSVCB() (ServiceBinding, error) { + recType := r.Type + if recType != "HTTPS" && recType != "SVCB" { + return ServiceBinding{}, fmt.Errorf("record type not SVCB or HTTPS: %s", r.Type) + } + + paramsParts := strings.SplitN(r.Data, " ", 3) + if minParts := 2; len(paramsParts) < minParts { // SvcParams can be empty + return ServiceBinding{}, fmt.Errorf("malformed HTTPS value; expected at least %d fields in the form 'priority target [SvcParams]'", minParts) + } + + priority, err := strconv.ParseUint(strings.TrimSpace(paramsParts[0]), 10, 16) + if err != nil { + return ServiceBinding{}, fmt.Errorf("invalid priority %s: %v", paramsParts[0], err) + } + target := paramsParts[1] + + svcParams := SvcParams{} + if len(paramsParts) > 2 { + svcParams, err = ParseSvcParams(paramsParts[2]) + if err != nil { + return ServiceBinding{}, fmt.Errorf("invalid SvcParams: %w", err) + } + } + + scheme := "" + var port uint64 = 0 + nameParts := strings.SplitN(r.Name, ".", 3) + if strings.HasPrefix(nameParts[0], "_") && strings.HasPrefix(nameParts[1], "_") { + portStr := strings.TrimPrefix(nameParts[0], "_") + scheme = strings.TrimPrefix(nameParts[1], "_") + + port, err = strconv.ParseUint(portStr, 10, 16) + if err != nil { + return ServiceBinding{}, fmt.Errorf("invalid port %s: %v", portStr, err) + } + nameParts = nameParts[2:] + } else if strings.HasPrefix(nameParts[0], "_") { + scheme = strings.TrimPrefix(nameParts[0], "_") + nameParts = nameParts[1:] + } + + if scheme == "" && recType == "HTTPS" { + scheme = "https" + } else if port > 0 && scheme == "https" && recType == "HTTPS" { + // ok + } else if scheme != "" && recType == "SVCB" { + // ok + } else { + return ServiceBinding{}, fmt.Errorf("invalid name %q; expected format: '_port._proto.name' or '_proto.name'", r.Name) + } + + return ServiceBinding{ + Scheme: scheme, + URLSchemePort: uint16(port), + Name: strings.Join(nameParts, "."), + TTL: r.TTL, + Priority: uint16(priority), + Target: target, + Params: svcParams, + }, nil +} + +func (r RR) toTXT() (TXT, error) { + if expectedType := "TXT"; r.Type != expectedType { + return TXT{}, fmt.Errorf("record type not %s: %s", expectedType, r.Type) + } + return TXT{ + Name: r.Name, + TTL: r.TTL, + Text: r.Data, + }, nil +} + +// SvcParams represents SvcParamKey=SvcParamValue pairs as described in +// RFC 9460 section 2.1. See https://www.rfc-editor.org/rfc/rfc9460#presentation. +// +// Note that this type is not primitively comparable, so using == for +// structs containnig a field of this type will panic. +type SvcParams map[string][]string + +// String serializes svcParams into zone presentation format described by RFC 9460. +func (params SvcParams) String() string { + var sb strings.Builder + for key, vals := range params { + if sb.Len() > 0 { + sb.WriteRune(' ') + } + sb.WriteString(key) + var hasVal, needsQuotes bool + for _, val := range vals { + if len(val) > 0 { + hasVal = true + } + if strings.ContainsAny(val, `" `) { + needsQuotes = true + } + if hasVal && needsQuotes { + break + } + } + if hasVal { + sb.WriteRune('=') + } + if needsQuotes { + sb.WriteRune('"') + } + for i, val := range vals { + if i > 0 { + sb.WriteRune(',') + } + val = strings.ReplaceAll(val, `"`, `\"`) + val = strings.ReplaceAll(val, `,`, `\,`) + sb.WriteString(val) + } + if needsQuotes { + sb.WriteRune('"') + } + } + return sb.String() +} + +// ParseSvcParams parses a SvcParams string described by RFC 9460 into a structured type. +func ParseSvcParams(input string) (SvcParams, error) { + input = strings.TrimSpace(input) + if len(input) > 4096 { + return nil, fmt.Errorf("input too long: %d", len(input)) + } + params := make(SvcParams) + if len(input) == 0 { + return params, nil + } + + // adding a space makes it easier to find the end of last key-value pair + input += " " + + for cursor := 0; cursor < len(input); cursor++ { + var key, rawVal string + + keyValPair: + for i := cursor; i < len(input); i++ { + switch input[i] { + case '=': + key = strings.ToLower(strings.TrimSpace(input[cursor:i])) + i++ + cursor = i + + var quoted bool + if input[cursor] == '"' { + quoted = true + i++ + cursor = i + } + + var escaped bool + + for j := cursor; j < len(input); j++ { + switch input[j] { + case '"': + if !quoted { + return nil, fmt.Errorf("illegal DQUOTE at position %d", j) + } + if !escaped { + // end of quoted value + rawVal = input[cursor:j] + j++ + cursor = j + break keyValPair + } + case '\\': + escaped = true + case ' ', '\t', '\n', '\r': + if !quoted { + // end of unquoted value + rawVal = input[cursor:j] + cursor = j + break keyValPair + } + default: + escaped = false + } + } + + case ' ', '\t', '\n', '\r': + // key with no value (flag) + key = input[cursor:i] + params[key] = []string{} + cursor = i + break keyValPair + } + } + + if rawVal == "" { + continue + } + + var sb strings.Builder + + var escape int // start of escape sequence (after \, so 0 is never a valid start) + for i := 0; i < len(rawVal); i++ { + ch := rawVal[i] + if escape > 0 { + // validate escape sequence + // (RFC 9460 Appendix A) + // escaped: "\" ( non-digit / dec-octet ) + // non-digit: "%x21-2F / %x3A-7E" + // dec-octet: "0-255 as a 3-digit decimal number" + if ch >= '0' && ch <= '9' { + // advance to end of decimal octet, which must be 3 digits + i += 2 + if i > len(rawVal) { + return nil, fmt.Errorf("value ends with incomplete escape sequence: %s", rawVal[escape:]) + } + decOctet, err := strconv.Atoi(rawVal[escape : i+1]) + if err != nil { + return nil, err + } + if decOctet < 0 || decOctet > 255 { + return nil, fmt.Errorf("invalid decimal octet in escape sequence: %s (%d)", rawVal[escape:i], decOctet) + } + sb.WriteRune(rune(decOctet)) + escape = 0 + continue + } else if (ch < 0x21 || ch > 0x2F) && (ch < 0x3A && ch > 0x7E) { + return nil, fmt.Errorf("illegal escape sequence %s", rawVal[escape:i]) + } + } + switch ch { + case ';', '(', ')': + // RFC 9460 Appendix A: + // > contiguous = 1*( non-special / escaped ) + // > non-special is VCHAR minus DQUOTE, ";", "(", ")", and "\". + return nil, fmt.Errorf("illegal character in value %q at position %d: %s", rawVal, i, string(ch)) + case '\\': + escape = i + 1 + default: + sb.WriteByte(ch) + escape = 0 + } + } + + params[key] = strings.Split(sb.String(), ",") + } + + return params, nil +} diff --git a/record_test.go b/record_test.go new file mode 100644 index 0000000..b8991f2 --- /dev/null +++ b/record_test.go @@ -0,0 +1,479 @@ +package libdns + +import ( + "net/netip" + "reflect" + "testing" + "time" +) + +func TestToAddress(t *testing.T) { + for i, test := range []struct { + input RR + expect Address + shouldErr bool + }{ + { + input: RR{ + Name: "sub", + TTL: 5 * time.Minute, + Type: "A", + Data: "1.2.3.4", + }, + expect: Address{ + Name: "sub", + TTL: 5 * time.Minute, + IP: netip.MustParseAddr("1.2.3.4"), + }, + }, + { + input: RR{ + Name: "@", + TTL: 5 * time.Minute, + Type: "AAAA", + Data: "2001:db8:3c4d:15:0:d234:3eee::", + }, + expect: Address{ + Name: "@", + TTL: 5 * time.Minute, + IP: netip.MustParseAddr("2001:db8:3c4d:15:0:d234:3eee::"), + }, + }, + } { + actual, err := test.input.toAddress() + if err == nil && test.shouldErr { + t.Errorf("Test %d: Expected error, got none", i) + } + if err != nil && !test.shouldErr { + t.Errorf("Test %d: Expected no error, but got: %v", i, err) + } + if !reflect.DeepEqual(actual, test.expect) { + t.Errorf("Test %d: INPUT=%#v\nEXPECTED: %#v\nACTUAL: %#v", i, test.input, test.expect, actual) + } + } +} + +func TestToCAA(t *testing.T) { + for i, test := range []struct { + input RR + expect CAA + shouldErr bool + }{ + { + input: RR{ + Name: "@", + TTL: 5 * time.Minute, + Type: "CAA", + Data: `128 issue "letsencrypt.org"`, + }, + expect: CAA{ + Name: "@", + TTL: 5 * time.Minute, + Flags: 128, + Tag: "issue", + Value: "letsencrypt.org", + }, + }, + } { + actual, err := test.input.toCAA() + if err == nil && test.shouldErr { + t.Errorf("Test %d: Expected error, got none", i) + } + if err != nil && !test.shouldErr { + t.Errorf("Test %d: Expected no error, but got: %v", i, err) + } + if !reflect.DeepEqual(actual, test.expect) { + t.Errorf("Test %d: INPUT=%#v\nEXPECTED: %#v\nACTUAL: %#v", i, test.input, test.expect, actual) + } + } +} + +func TestToCNAME(t *testing.T) { + for i, test := range []struct { + input RR + expect CNAME + shouldErr bool + }{ + { + input: RR{ + Name: "@", + TTL: 5 * time.Minute, + Type: "CNAME", + Data: "example.com.", + }, + expect: CNAME{ + Name: "@", + TTL: 5 * time.Minute, + Target: "example.com.", + }, + }, + } { + actual, err := test.input.toCNAME() + if err == nil && test.shouldErr { + t.Errorf("Test %d: Expected error, got none", i) + } + if err != nil && !test.shouldErr { + t.Errorf("Test %d: Expected no error, but got: %v", i, err) + } + if !reflect.DeepEqual(actual, test.expect) { + t.Errorf("Test %d: INPUT=%#v\nEXPECTED: %#v\nACTUAL: %#v", i, test.input, test.expect, actual) + } + } +} + +func TestToSVCB(t *testing.T) { + for i, test := range []struct { + input RR + expect ServiceBinding + shouldErr bool + }{ + { + input: RR{ + Name: "@", + TTL: 5 * time.Minute, + Type: "HTTPS", + Data: `1 . key=value1,value2 ech="foobar"`, + }, + expect: ServiceBinding{ + Name: "@", + TTL: 5 * time.Minute, + Scheme: "https", + Priority: 1, + Target: ".", + Params: SvcParams{ + "key": []string{"value1", "value2"}, + "ech": []string{"foobar"}, + }, + }, + }, + { + input: RR{ + Name: "_8443._https.test", + TTL: 1 * time.Hour, + Type: "HTTPS", + Data: "0 example.com.", + }, + expect: ServiceBinding{ + Name: "test", + Scheme: "https", + URLSchemePort: 8443, + TTL: 1 * time.Hour, + Priority: 0, + Target: "example.com.", + Params: SvcParams{}, + }, + }, + { + input: RR{ + Name: "_dns.example.com.", + TTL: 1 * time.Second, + Type: "SVCB", + Data: "2 example.org. alpn=dot", + }, + expect: ServiceBinding{ + Name: "example.com.", + Scheme: "dns", + TTL: 1 * time.Second, + Priority: 2, + Target: "example.org.", + Params: SvcParams{ + "alpn": []string{"dot"}, + }, + }, + }, + { + input: RR{ + Name: "_853._dns.example.com.", + TTL: 1 * time.Second, + Type: "SVCB", + Data: "1 . port=53", + }, + expect: ServiceBinding{ + Name: "example.com.", + Scheme: "dns", + URLSchemePort: 853, + TTL: 1 * time.Second, + Priority: 1, + Target: ".", + Params: SvcParams{ + "port": []string{"53"}, + }, + }, + }, + } { + actual, err := test.input.toSVCB() + if err == nil && test.shouldErr { + t.Errorf("Test %d: Expected error, got none", i) + } + if err != nil && !test.shouldErr { + t.Errorf("Test %d: Expected no error, but got: %v", i, err) + } + if !reflect.DeepEqual(actual, test.expect) { + t.Errorf("Test %d: INPUT=%#v\nEXPECTED: %+v\nACTUAL: %+v", i, test.input, test.expect, actual) + } + } +} + +func TestToMX(t *testing.T) { + for i, test := range []struct { + input RR + expect MX + shouldErr bool + }{ + { + input: RR{ + Name: "@", + TTL: 5 * time.Minute, + Type: "MX", + Data: "10 example.com.", + }, + expect: MX{ + Name: "@", + TTL: 5 * time.Minute, + Preference: 10, + Target: "example.com.", + }, + }, + } { + actual, err := test.input.toMX() + if err == nil && test.shouldErr { + t.Errorf("Test %d: Expected error, got none", i) + } + if err != nil && !test.shouldErr { + t.Errorf("Test %d: Expected no error, but got: %v", i, err) + } + if !reflect.DeepEqual(actual, test.expect) { + t.Errorf("Test %d: INPUT=%#v\nEXPECTED: %#v\nACTUAL: %#v", i, test.input, test.expect, actual) + } + } +} + +func TestToNS(t *testing.T) { + for i, test := range []struct { + input RR + expect NS + shouldErr bool + }{ + { + input: RR{ + Name: "@", + TTL: 5 * time.Minute, + Type: "NS", + Data: "example.com.", + }, + expect: NS{ + Name: "@", + TTL: 5 * time.Minute, + Target: "example.com.", + }, + }, + } { + actual, err := test.input.toNS() + if err == nil && test.shouldErr { + t.Errorf("Test %d: Expected error, got none", i) + } + if err != nil && !test.shouldErr { + t.Errorf("Test %d: Expected no error, but got: %v", i, err) + } + if !reflect.DeepEqual(actual, test.expect) { + t.Errorf("Test %d: INPUT=%#v\nEXPECTED: %#v\nACTUAL: %#v", i, test.input, test.expect, actual) + } + } +} + +func TestToSRV(t *testing.T) { + for i, test := range []struct { + input RR + expect SRV + shouldErr bool + }{ + { + input: RR{ + Name: "_service._proto.name", + TTL: 5 * time.Minute, + Type: "SRV", + Data: "1 2 1234 example.com", + }, + expect: SRV{ + Service: "service", + Transport: "proto", + Name: "name", + TTL: 5 * time.Minute, + Priority: 1, + Weight: 2, + Port: 1234, + Target: "example.com", + }, + }, + } { + actual, err := test.input.toSRV() + if err == nil && test.shouldErr { + t.Errorf("Test %d: Expected error, got none", i) + } + if err != nil && !test.shouldErr { + t.Errorf("Test %d: Expected no error, but got: %v", i, err) + } + if !reflect.DeepEqual(actual, test.expect) { + t.Errorf("Test %d: INPUT=%#v\nEXPECTED: %+v\nACTUAL: %+v", i, test.input, test.expect, actual) + } + } +} + +func TestToTXT(t *testing.T) { + for i, test := range []struct { + input RR + expect TXT + shouldErr bool + }{ + { + input: RR{ + Name: "_acme_challenge", + TTL: 5 * time.Minute, + Type: "TXT", + Data: "foobar", + }, + expect: TXT{ + Name: "_acme_challenge", + TTL: 5 * time.Minute, + Text: "foobar", + }, + }, + } { + actual, err := test.input.toTXT() + if err == nil && test.shouldErr { + t.Errorf("Test %d: Expected error, got none", i) + } + if err != nil && !test.shouldErr { + t.Errorf("Test %d: Expected no error, but got: %v", i, err) + } + if !reflect.DeepEqual(actual, test.expect) { + t.Errorf("Test %d: INPUT=%#v\nEXPECTED: %#v\nACTUAL: %#v", i, test.input, test.expect, actual) + } + } +} + +func TestParseSvcParams(t *testing.T) { + for i, test := range []struct { + input string + expect SvcParams + shouldErr bool + }{ + { + input: "", + expect: SvcParams{}, + }, + { + input: `alpn="h2,h3" no-default-alpn ipv6hint=2001:db8::1 port=443`, + expect: SvcParams{ + "alpn": {"h2", "h3"}, + "no-default-alpn": {}, + "ipv6hint": {"2001:db8::1"}, + "port": {"443"}, + }, + }, + { + input: `key=value quoted="some string" flag`, + expect: SvcParams{ + "key": {"value"}, + "quoted": {"some string"}, + "flag": {}, + }, + }, + { + input: `key="nested \"quoted\" value,foobar"`, + expect: SvcParams{ + "key": {`nested "quoted" value`, "foobar"}, + }, + }, + { + input: `alpn=h3,h2 tls-supported-groups=29,23 no-default-alpn ech="foobar"`, + expect: SvcParams{ + "alpn": {"h3", "h2"}, + "tls-supported-groups": {"29", "23"}, + "no-default-alpn": {}, + "ech": {"foobar"}, + }, + }, + { + input: `escape=\097`, + expect: SvcParams{ + "escape": {"a"}, + }, + }, + { + input: `escapes=\097\098c`, + expect: SvcParams{ + "escapes": {"abc"}, + }, + }, + } { + actual, err := ParseSvcParams(test.input) + if err != nil && !test.shouldErr { + t.Errorf("Test %d: Expected no error, but got: %v (input=%q)", i, err, test.input) + continue + } else if err == nil && test.shouldErr { + t.Errorf("Test %d: Expected an error, but got no error (input=%q)", i, test.input) + continue + } + if !reflect.DeepEqual(test.expect, actual) { + t.Errorf("Test %d: Expected %v, got %v (input=%q)", i, test.expect, actual, test.input) + continue + } + } +} + +func TestSvcParamsString(t *testing.T) { + // this test relies on the parser also working + // because we can't just compare string outputs + // since map iteration is unordered + for i, test := range []SvcParams{ + {}, + { + "alpn": {"h2", "h3"}, + "no-default-alpn": {}, + "ipv6hint": {"2001:db8::1"}, + "port": {"443"}, + }, + { + "key": {"value"}, + "quoted": {"some string"}, + "flag": {}, + }, + { + "key": {`nested "quoted" value`, "foobar"}, + }, + { + "alpn": {"h3", "h2"}, + "tls-supported-groups": {"29", "23"}, + "no-default-alpn": {}, + "ech": {"foobar"}, + }, + } { + combined := test.String() + parsed, err := ParseSvcParams(combined) + if err != nil { + t.Errorf("Test %d: Expected no error, but got: %v (input=%q)", i, err, test) + continue + } + if len(parsed) != len(test) { + t.Errorf("Test %d: Expected %d keys, but got %d", i, len(test), len(parsed)) + continue + } + for key, expectedVals := range test { + if expected, actual := len(expectedVals), len(parsed[key]); expected != actual { + t.Errorf("Test %d: Expected key %s to have %d values, but had %d", i, key, expected, actual) + continue + } + for j, expected := range expectedVals { + if actual := parsed[key][j]; actual != expected { + t.Errorf("Test %d key %q value %d: Expected '%s' but got '%s'", i, key, j, expected, actual) + continue + } + } + } + if !reflect.DeepEqual(parsed, test) { + t.Errorf("Test %d: Expected %#v, got %#v", i, test, combined) + continue + } + } +} diff --git a/rrtypes.go b/rrtypes.go new file mode 100644 index 0000000..23b3ab5 --- /dev/null +++ b/rrtypes.go @@ -0,0 +1,375 @@ +package libdns + +import ( + "fmt" + "net/netip" + "time" +) + +// Address represents a parsed A-type or AAAA-type record, +// which associates a name with an IPv4 or IPv6 address +// respectively. This is typically how to "point a domain +// to your server." +// +// Since A and AAAA are semantically identical, with the +// exception of the bit length of the IP address in the +// data field, these record types are combined for ease of +// use in Go programs, which supports both address sizes, +// to help simplify code. +type Address struct { + Name string + TTL time.Duration + IP netip.Addr +} + +func (a Address) RR() RR { + recType := "A" + if a.IP.Is6() { + recType = "AAAA" + } + return RR{ + Name: a.Name, + TTL: a.TTL, + Type: recType, + Data: a.IP.String(), + } +} + +// CAA represents a parsed CAA-type record, which is used to specify which PKIX +// certificate authorities are allowed to issue certificates for a domain. See +// also the [registry of flags and tags]. +// +// [registry of flags and tags]: https://www.iana.org/assignments/caa-parameters/caa-parameters.xhtml +type CAA struct { + Name string + TTL time.Duration + Flags uint8 // As of March 2025, the only valid values are 0 and 128. + Tag string + Value string +} + +func (c CAA) RR() RR { + return RR{ + Name: c.Name, + TTL: c.TTL, + Type: "CAA", + Data: fmt.Sprintf(`%d %s %q`, c.Flags, c.Tag, c.Value), + } +} + +// CNAME represents a CNAME-type record, which delegates +// authority to other names. +type CNAME struct { + Name string + TTL time.Duration + Target string +} + +func (c CNAME) RR() RR { + return RR{ + Name: c.Name, + TTL: c.TTL, + Type: "CNAME", + Data: c.Target, + } +} + +// MX represents a parsed MX-type record, which is used to specify the hostnames +// of the servers that accept mail for a domain. +type MX struct { + Name string + TTL time.Duration + Preference uint16 // Lower values indicate that clients should prefer this server. This field is similar to the “Priority” field in SRV records. + Target string // The hostname of the mail server +} + +func (m MX) RR() RR { + return RR{ + Name: m.Name, + TTL: m.TTL, + Type: "MX", + Data: fmt.Sprintf("%d %s", m.Preference, m.Target), + } +} + +// NS represents a parsed NS-type record, which is used to specify the +// authoritative nameservers for a zone. It is strongly recommended to have at +// least two NS records for redundancy. +// +// Note that the NS records present at the root level of a zone must match those +// delegated to by the parent zone. This means that changing the NS records for +// the root of a registered domain won't have any effect unless you also update +// the NS records with the domain registrar. +// +// Also note that the DNS standards forbid removing the last NS record for a +// zone, so if you want to replace all NS records, you should add the new ones +// before removing the old ones. +type NS struct { + Name string + TTL time.Duration + Target string +} + +func (n NS) RR() RR { + return RR{ + Name: n.Name, + TTL: n.TTL, + Type: "NS", + Data: n.Target, + } +} + +// SRV represents a parsed SRV-type record, which is used to +// manifest services or instances that provide services on a +// network. +// +// The serialization of this record type takes the form: +// +// _service._proto.name. ttl IN SRV priority weight port target. +// +// Note that all fields are mandatory. +type SRV struct { + // “Service” is the name of the service being offered, without the leading + // underscore. The correct value for this field is defined by the service + // that you are serving (and is typically registered with IANA). Some + // examples include "sip", "xmpp", "ldap", "minecraft", "stun", "turn", etc. + Service string + + // “Transport” is the name of the transport protocol used by the service, + // without the leading underscore. This is almost always "tcp" or "udp", but + // "sctp" and "dccp" are technically valid as well. + // + // Note that RFC 2782 defines this field as “Proto[col]”, but we're using + // the updated name “Transport” from RFC 6335 in order to avoid confusion + // with the similarly-named field in the SVCB record type. + Transport string + + Name string + TTL time.Duration + Priority uint16 // Lower values indicate that clients should prefer this server + Weight uint16 // Higher values indicate that clients should prefer this server when choosing between targets with the same priority + Port uint16 // The port on which the service is running. + Target string // The hostname of the server providing the service, which must not point to a CNAME. +} + +func (s SRV) RR() RR { + var name string + if s.Service == "" && s.Transport == "" { + // If both “Service” and “Transport” are empty, then we'll assume that + // “Name” is complete as-is. This is fairly dubious, but could happen + // if a properly-underscored CNAME points at a SRV without underscores. + name = s.Name + } else { + // Otherwise, we need to prepend the underscores to the name. + name = fmt.Sprintf("_%s._%s.%s", s.Service, s.Transport, s.Name) + } + + return RR{ + Name: name, + TTL: s.TTL, + Type: "SRV", + Data: fmt.Sprintf("%d %d %d %s", s.Priority, s.Weight, s.Port, s.Target), + } +} + +// ServiceBinding represents a parsed ServiceBinding-type record, which is used to provide the +// target and various key–value parameters for a service. HTTPS records are +// defined as a “ServiceBinding-Compatible RR Type”, which means that their data +// structures are identical to ServiceBinding records, albeit with a different type name +// and semantics. +// +// HTTPS-type records are used to provide clients with information for +// establishing HTTPS connections to servers. It may include data about ALPN, +// ECH, IP hints, and more. +// +// Unlike the other RR types that are hostname-focused or service-focused, ServiceBinding +// (“Service Binding”) records are URL-focused. This distinction is generally +// irrelevant, but is important when disusing the port fields. +type ServiceBinding struct { + // “Scheme” is the scheme of the URL used to access the service, or some + // other protocol identifier registered with IANA. This field should not + // contain a leading underscore. + // + // If the scheme is set to "https", then a HTTPS-type record will be + // generated; for all other schemes, a SVCB-type record will be generated. + // As defined in RFC 9460, the schemes "http", "wss", and "ws" also map to + // HTTPS records. + // + // Note that if a new SVCB-compatible RR type is defined and specified as + // mapping to a scheme, then [libdns] may automatically generate that type + // instead of SVCB at some point in the future. It is expected that any RFC + // that proposes such a new type will ensure that this does not cause any + // backwards compatibility issues. + Scheme string + + // Warning: This field almost certainly does not do what you expect, and + // should typically be unset (or set to 0). + // + // “URLSchemePort” is the port number that is explictly specified in a URL + // when accessing a service. This field does not affect the port number that + // is actually used to access the service, and unlike with SRV records, it + // must be unset if you are using the default port for the scheme. + // + // # Examples + // + // In the typical case, you would have the following URL: + // + // https://example.com/ + // + // and then the client would lookup the following records: + // + // example.com. 60 IN HTTPS 1 example.net. alpn=h2,h3 + // example.net. 60 IN A 192.0.2.1 + // + // and then the client would connect to 192.0.2.1:443. But if you had the + // same URL but the following records: + // + // example.com. 60 IN HTTPS 1 example.net. alpn=h2,h3 port=1111 + // example.net. 60 IN A 192.0.2.2 + // + // then the client would connect to 192.0.2.2:1111. But if you had the + // following URL: + // + // https://example.com:2222/ + // + // then the client would lookup the following records: + // + // _2222._https.example.com. 60 IN HTTPS 1 example.net. alpn=h2,h3 + // example.net. 60 IN A 192.0.2.3 + // + // and the client would connect to 192.0.2.3:2222. And if you had the same + // URL but the following records: + // + // _2222._https.example.com. 60 IN HTTPS 1 example.net. alpn=h2,h3 port=3333 + // example.net. 60 IN A 192.0.2.4 + // + // then the client would connect to 192.0.2.4:3333. + // + // So the key things to note here are that: + // + // - If you want to change the port that the client connects to, you need + // to set the “port=” value in the “Params” field, not the + // “URLSchemePort”. + // + // - The client will never lookup the HTTPS record prefixed with the + // underscored default port, so you should only set “URLSchemePort” if + // you are explicitly using a non-default port in the URL. + // + // - It is completely valid to set the “port=” value in the “Params” field + // to the default port for the scheme, but also completely unnecessary. + // + // - The “URLSchemePort” field and the “port=” value in the “Params” field + // are completely independent, with one exception: if you set the + // “URLSchemePort” field to a non-default port and leave the “port=” + // value in the “Params” field unset, then the client will default to the + // value of the “URLSchemePort” field, and not to the default port for + // the scheme. + URLSchemePort uint16 + + Name string + TTL time.Duration + + // “Priority” is the priority of the service, with lower values indicating + // that clients should prefer this service over others. + // + // Note that Priority==0 is a special case, and indicates that the record + // is an “Alias” record. Alias records behave like CNAME records, but are + // allowed at the root of a zone. When in Alias mode, the Params field + // should be unset. + Priority uint16 + + // “Target” is the target of the service, which is typically a hostname or + // an alias (CNAME or other SVCB record). If this field is set to a single + // dot ".", then the target is the same as the name of the record (without + // the underscore-prefixed components, of course). + Target string + + // “Params” is a map of key–value pairs that are used to specify various + // parameters for the service. The keys are typically registered with IANA, + // and which keys are valid is service-dependent. + // https://www.iana.org/assignments/dns-svcb/dns-svcb.xhtml + // + // Note that there is a key called “mandatory”, but this does not mean that + // it is mandatory for you to set the listed keys. Instead, this means that + // if a client does not understand all of the listed keys, then it must + // ignore the entire record. This is similar to the “critical” flag in CAA + // records. + Params SvcParams +} + +// RR converts the parsed record data to a generic [Record] struct. +// +// EXPERIMENTAL; subject to change or removal. +func (s ServiceBinding) RR() RR { + var name string + var recType string + if s.Scheme == "https" || s.Scheme == "http" || s.Scheme == "wss" || s.Scheme == "ws" { + recType = "HTTPS" + name = s.Name + if s.URLSchemePort == 443 || s.URLSchemePort == 80 { + // Ok, we'll correct your mistake for you. + s.URLSchemePort = 0 + } + } else { + recType = "SVCB" + name = fmt.Sprintf("_%s.%s", s.Scheme, s.Name) + } + + if s.URLSchemePort != 0 { + name = fmt.Sprintf("_%d.%s", s.URLSchemePort, name) + } + + var params string + if s.Priority == 0 && len(s.Params) != 0 { + // The SvcParams should be empty in AliasMode, so we'll fix that for + // you. + params = "" + } else { + params = s.Params.String() + } + + return RR{ + Name: name, + TTL: s.TTL, + Type: recType, + Data: fmt.Sprintf("%d %s %s", s.Priority, s.Target, params), + } +} + +// TXT represents a parsed TXT-type record, which is used to +// add arbitrary text data to a name in a DNS zone. It is often +// used for email integrity (DKIM/SPF), site verification, ACME +// challenges, and more. +type TXT struct { + Name string + TTL time.Duration + + // The “Text” field contains the arbitrary data associated with the TXT + // record. The contents of this field should *not* be wrapped in quotes as + // libdns implementations are expected to quote any fields as necessary. In + // addition, as discussed in the description of [libdns.RR.Data], you should + // not include any escaped characters in this field, as libdns will escape + // them for you. + // + // In the zone file format and the DNS wire format, a single TXT record is + // composed of one or more strings of no more than 255 bytes each ([RFC 1035 + // §3.3.14], [RFC 7208 §3.3]). We eschew those restrictions here, and + // instead treat the entire TXT as a single, arbitrary-length string. libdns + // implementations are therefore expected to handle this as required by + // their respective DNS provider APIs. See the [DNSControl explainer] on + // this for more information. + // + // [RFC 1035 §3.3.14]: https://datatracker.ietf.org/doc/html/rfc1035#section-3.3.14 + // [RFC 7208 §3.3]: https://datatracker.ietf.org/doc/html/rfc7208#section-3.3 + // [DNSControl explainer]: https://docs.dnscontrol.org/developer-info/opinions#opinion-8-txt-records-are-one-long-string + Text string +} + +func (t TXT) RR() RR { + return RR{ + Name: t.Name, + TTL: t.TTL, + Type: "TXT", + Data: t.Text, + } +} From 2f733196c2d9c5790e62e68948512c7ce024f52f Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 6 Apr 2025 21:25:43 -0600 Subject: [PATCH 04/13] Minor docs enhancements - Repeat advice to return specific types rather than generic RR struct, for visibility - Rename unexported method - Fix readme --- README.md | 12 +++++++----- libdns.go | 12 ++++++++++++ record.go | 11 +++++++++-- record_test.go | 2 +- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index defa89a..da10171 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ libdns - Universal DNS provider APIs for Go `libdns` is a collection of free-range DNS provider client implementations written in Go! With libdns packages, your Go program can manage DNS records across any supported providers. A "provider" is a service or program that manages a DNS zone. -This repository defines the core interfaces that provider packages should implement. They are small and idiomatic Go interfaces with well-defined semantics for managing DNS records. +This repository defines the core APIs that provider packages should implement. They are small and idiomatic Go interfaces with well-defined semantics for managing DNS records. The interfaces include: @@ -42,7 +42,7 @@ recs, err := provider.GetRecords(ctx, zone) // create records (AppendRecords is similar, with different semantics) newRecs, err := provider.SetRecords(ctx, zone, []libdns.Record{ - libdns.A{ + libdns.Address{ Name: "@", Value: netip.MustParseAddr("1.2.3.4"), }, @@ -78,17 +78,19 @@ This is incredibly useful when you are maintaining your own zone file, but risky **[StackExchange/dnscontrol](https://github.com/StackExchange/dnscontrol)** is written in Go, but is similar to OctoDNS in that it tends to obliterate your entire zone and replace it with your input. Again, this is very useful if you are maintaining your own master list of records, but doesn't do well for simply adding or removing records. -**[go-acme/lego](https://github.com/go-acme/lego)** has support for a huge number of DNS providers (75+!), but their APIs are only capable of setting and deleting TXT records for ACME challenges. +**[go-acme/lego](https://github.com/go-acme/lego)** supports many DNS providers, but their APIs are only capable of setting and deleting TXT records for ACME challenges. -**[miekg/dns](https://github.com/miekg/dns)** is a comprehensive, low-level DNS library for Go programs. It is well-maintained and extremely thorough, but also a bit lower-level than our needs require. +**[miekg/dns](https://github.com/miekg/dns)** is a comprehensive, low-level DNS library for Go programs. It is well-maintained and extremely thorough, but also too low-level to be productive for our use cases. **`libdns`** takes inspiration from the above projects but aims for a more generally-useful set of high-level APIs that homogenize pretty well across providers. In contrast to the above projects, libdns can add, set, delete, and get arbitrary records from a zone without obliterating it (although syncing up an entire zone is also possible!). Its APIs also include context so long-running calls can be cancelled early, for example to accommodate on-line config changes downstream. libdns interfaces are also smaller and more composable. Additionally, libdns can grow to support a nearly infinite number of DNS providers without added bloat, because each provider implementation is a separate Go module, which keeps your builds lean and fast. In summary, the goal is that libdns providers can do what the above libraries/tools can do, but with more flexibility: they can create and delete TXT records for ACME challenges, they can replace entire zones, but they can also do incremental changes or simply read records. +**Whatever libdns is used for with your DNS zone, it is presumed that only your libdns code is manipulating that (part of your) zone.** This package does not provide synchronization primitives, but your own code can do that if necessary. + ## Record abstraction How records are represented across providers varies widely, and each kind of record has different fields and semantics. -Realistically, libdns should enable most common record manipulations, but may not be able to fit absolutely 100% of all possibilities with DNS in a provider-agnostic way. That is probably OK; and given the wide varieties in DNS record types and provider APIs, it would be unreasonable to expect otherwise. Our goal is 100% fulfillment of ~90% of use cases / user requirements, not 100% fulfillment of 100% of use cases. +Realistically, libdns should enable most common record manipulations, but may not be able to fit absolutely 100% of all possibilities with DNS in a provider-agnostic way. That is probably OK; and given the wide varieties in DNS record types and provider APIs, it would be unreasonable to expect otherwise. Our goal is 100% fulfillment of ~99% of use cases / user requirements, not 100% fulfillment of 100% of use cases. diff --git a/libdns.go b/libdns.go index 00938f9..e031c39 100644 --- a/libdns.go +++ b/libdns.go @@ -95,6 +95,9 @@ type RecordAppender interface { // existing records with the same name, it will either fail or leave the // zone in an invalid state. // + // Implementations should return struct types defined by this package which + // correspond with the specific RR-type, rather than the [RR] struct, if possible. + // // Implementations must honor context cancellation and be safe for concurrent // use. AppendRecords(ctx context.Context, zone string, recs []Record) ([]Record, error) @@ -138,6 +141,9 @@ type RecordSetter interface { // Therefore, users should proceed with caution when using SetRecords with // CNAME records. // + // Implementations should return struct types defined by this package which + // correspond with the specific RR-type, rather than the [RR] struct, if possible. + // // Implementations must honor context cancellation and be safe for concurrent // use. // @@ -201,6 +207,9 @@ type RecordDeleter interface { // Note that it is semantically invalid to remove the last “NS” record from a // zone, so attempting to do is undefined behavior. // + // Implementations should return struct types defined by this package which + // correspond with the specific RR-type, rather than the [RR] struct, if possible. + // // Implementations must honor context cancellation and be safe for concurrent // use. DeleteRecords(ctx context.Context, zone string, recs []Record) ([]Record, error) @@ -213,6 +222,9 @@ type ZoneLister interface { // available zones, and very few [libdns]-dependent packages use this // method, so this method is optional. // + // Implementations should return struct types defined by this package which + // correspond with the specific RR-type, rather than the [RR] struct, if possible. + // // Implementations must honor context cancellation and be safe for // concurrent use. ListZones(ctx context.Context) ([]Zone, error) diff --git a/record.go b/record.go index 65656bd..c2c15ac 100644 --- a/record.go +++ b/record.go @@ -23,6 +23,13 @@ type Record interface { // The fields in this struct are common to all RRs, with the data field // being opaque; it has no particular meaning until it is parsed. // +// This type should NOT be returned by implementations of the libdns interfaces; +// in other words, methods such as GetRecords, AppendRecords, etc., should +// not return RR values. Instead, they should return the structs corresponding +// to the specific RR types (such as [Address], [TXT], etc). This provides +// consistency for callers who can then reliably type-switch or type-assert the +// output without the possibility for errors. +// // [DNS Resource Record]: https://en.wikipedia.org/wiki/Domain_Name_System#Resource_records type RR struct { // The name of the record. It is partially qualified, relative to the zone. @@ -111,7 +118,7 @@ func (r RR) Parse() (Record, error) { case "CNAME": return r.toCNAME() case "HTTPS", "SVCB": - return r.toSVCB() + return r.toServiceBinding() case "MX": return r.toMX() case "NS": @@ -255,7 +262,7 @@ func (r RR) toSRV() (SRV, error) { }, nil } -func (r RR) toSVCB() (ServiceBinding, error) { +func (r RR) toServiceBinding() (ServiceBinding, error) { recType := r.Type if recType != "HTTPS" && recType != "SVCB" { return ServiceBinding{}, fmt.Errorf("record type not SVCB or HTTPS: %s", r.Type) diff --git a/record_test.go b/record_test.go index b8991f2..2a6212f 100644 --- a/record_test.go +++ b/record_test.go @@ -201,7 +201,7 @@ func TestToSVCB(t *testing.T) { }, }, } { - actual, err := test.input.toSVCB() + actual, err := test.input.toServiceBinding() if err == nil && test.shouldErr { t.Errorf("Test %d: Expected error, got none", i) } From f18b99b480992ef8a567840519bb1251e1d2cc73 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 6 Apr 2025 22:36:19 -0600 Subject: [PATCH 05/13] Add links to 1.0 beta godocs --- README.md | 12 +++++++++--- libdns.go | 6 +++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index da10171..05d70b1 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ libdns - Universal DNS provider APIs for Go =========================================== - +[![Go Reference](https://pkg.go.dev/badge/github.com/libdns/libdns)](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1) -**⚠️ Work-in-progress. Exported APIs are subject to change.** +**⚠️ v1.0 is now in beta. Any packages that implemented prior versions should upgrade. The betas are subject to change, but big changes are unlikely at this point. We encourage all maintainers to upgrade their packages. Thank you!** `libdns` is a collection of free-range DNS provider client implementations written in Go! With libdns packages, your Go program can manage DNS records across any supported providers. A "provider" is a service or program that manages a DNS zone. @@ -17,7 +17,13 @@ The interfaces include: - [`RecordDeleter`](https://pkg.go.dev/github.com/libdns/libdns#RecordDeleter) to delete records. - [`ZoneLister`](https://pkg.go.dev/github.com/libdns/libdns#ZoneLister) to list zones. -[See full godoc for detailed documentation.](https://pkg.go.dev/github.com/libdns/libdns) +[See full godoc for detailed information.](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1) + +## Implementations + +This package only defines standardized APIs described above. To actually manipulate DNS records/zones, you will need a package specific to your provider that implements these interfaces. + +You can choose from over 75 packages at [https://github.com/libdns](https://github.com/orgs/libdns/repositories?type=all). ## Example diff --git a/libdns.go b/libdns.go index e031c39..a5ecd46 100644 --- a/libdns.go +++ b/libdns.go @@ -9,9 +9,9 @@ // This package represents DNS records in two primary ways: as opaque [RR] // structs, where the data is serialized as a single string as in a zone file; // and as individual type structures, where the data is parsed into its separate -// fields for easier manipulation by Go programs (for example: [SRV] and [HTTPS] -// types). This hybrid design offers great flexibility for both DNS provider -// packages and consumer Go programs. +// fields for easier manipulation by Go programs (for example: [SRV] and +// [ServiceBinding] types). This hybrid design offers great flexibility for both +// DNS provider packages and consumer Go programs. // // This package represents records flexibly with the [Record] interface, which // is any type that can transform itself into the [RR] struct, which is a From 7fe2177d834c6ce3de17d14e3836da0fa4a6cc47 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 15 Apr 2025 15:02:12 -0600 Subject: [PATCH 06/13] readme: Update more links to beta docs --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 05d70b1..07d7108 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ This repository defines the core APIs that provider packages should implement. T The interfaces include: -- [`RecordGetter`](https://pkg.go.dev/github.com/libdns/libdns#RecordGetter) to list records. -- [`RecordAppender`](https://pkg.go.dev/github.com/libdns/libdns#RecordAppender) to create new records. -- [`RecordSetter`](https://pkg.go.dev/github.com/libdns/libdns#RecordSetter) to set (create or update) records. -- [`RecordDeleter`](https://pkg.go.dev/github.com/libdns/libdns#RecordDeleter) to delete records. -- [`ZoneLister`](https://pkg.go.dev/github.com/libdns/libdns#ZoneLister) to list zones. +- [`RecordGetter`](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1#RecordGetter) to list records. +- [`RecordAppender`](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1#RecordAppender) to create new records. +- [`RecordSetter`](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1#RecordSetter) to set (create or update) records. +- [`RecordDeleter`](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1#RecordDeleter) to delete records. +- [`ZoneLister`](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1#ZoneLister) to list zones. [See full godoc for detailed information.](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1) From 5ab1d4de259f1eb914085c61784ad9176ea8e803 Mon Sep 17 00:00:00 2001 From: Max Chernoff Date: Wed, 16 Apr 2025 16:16:26 -0600 Subject: [PATCH 07/13] Document that provider-specific types are allowed (#162) Closes #160. --- record.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/record.go b/record.go index c2c15ac..f2cbb02 100644 --- a/record.go +++ b/record.go @@ -30,6 +30,11 @@ type Record interface { // consistency for callers who can then reliably type-switch or type-assert the // output without the possibility for errors. // +// Implementations are permitted to define their own types that implement the +// [RR] interface, but this should only be done for provider-specific types. If +// you're instead wanting to use a general-purpose DNS RR type that is not yet +// supported by this package, please open an issue or PR to add it. +// // [DNS Resource Record]: https://en.wikipedia.org/wiki/Domain_Name_System#Resource_records type RR struct { // The name of the record. It is partially qualified, relative to the zone. From 8fedb4f57bfacc5abb64a8eff0b331dfea191181 Mon Sep 17 00:00:00 2001 From: Matt Holt Date: Sat, 19 Apr 2025 07:29:27 -0600 Subject: [PATCH 08/13] Support 2-label SRV names (fix #163) (#164) * Support 2-label SRV names (fix #163) * Support 1 and 2-label ServiceBinding names --------- Co-authored-by: Max Chernoff --- record.go | 18 +++++++++-- record_test.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/record.go b/record.go index f2cbb02..bfe8523 100644 --- a/record.go +++ b/record.go @@ -251,14 +251,18 @@ func (r RR) toSRV() (SRV, error) { target := fields[3] 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) + if len(parts) < 2 { + return SRV{}, fmt.Errorf("name %v does not contain enough fields; expected format: '_service._proto.name' or '_service._proto'", r.Name) + } + name := "@" + if len(parts) == 3 { + name = parts[2] } return SRV{ Service: strings.TrimPrefix(parts[0], "_"), Transport: strings.TrimPrefix(parts[1], "_"), - Name: parts[2], + Name: name, TTL: r.TTL, Priority: uint16(priority), Weight: uint16(weight), @@ -295,6 +299,14 @@ func (r RR) toServiceBinding() (ServiceBinding, error) { scheme := "" var port uint64 = 0 nameParts := strings.SplitN(r.Name, ".", 3) + // Handle the case where the name is only underscore-prefixed labels + if len(nameParts) <= 1 && strings.HasPrefix(nameParts[0], "_") { + nameParts = append(nameParts, "@") + } else if len(nameParts) == 2 && strings.HasPrefix(nameParts[1], "_") { + nameParts = append(nameParts, "@") + } + + // Parse the first two parts of the name if strings.HasPrefix(nameParts[0], "_") && strings.HasPrefix(nameParts[1], "_") { portStr := strings.TrimPrefix(nameParts[0], "_") scheme = strings.TrimPrefix(nameParts[1], "_") diff --git a/record_test.go b/record_test.go index 2a6212f..77b7e89 100644 --- a/record_test.go +++ b/record_test.go @@ -200,6 +200,72 @@ func TestToSVCB(t *testing.T) { }, }, }, + { + input: RR{ + Name: "_1234._examplescheme", + TTL: 1 * time.Hour, + Type: "SVCB", + Data: "0 example.com.", + }, + expect: ServiceBinding{ + Name: "@", + Scheme: "examplescheme", + URLSchemePort: 1234, + TTL: 1 * time.Hour, + Priority: 0, + Target: "example.com.", + Params: SvcParams{}, + }, + }, + { + input: RR{ + Name: "_examplescheme", + TTL: 1 * time.Hour, + Type: "SVCB", + Data: "0 example.com.", + }, + expect: ServiceBinding{ + Name: "@", + Scheme: "examplescheme", + TTL: 1 * time.Hour, + Priority: 0, + Target: "example.com.", + Params: SvcParams{}, + }, + }, + { + input: RR{ + Name: "_examplescheme.@", + TTL: 1 * time.Hour, + Type: "SVCB", + Data: "0 example.com.", + }, + expect: ServiceBinding{ + Name: "@", + Scheme: "examplescheme", + TTL: 1 * time.Hour, + Priority: 0, + Target: "example.com.", + Params: SvcParams{}, + }, + }, + { + input: RR{ + Name: "_1234._examplescheme.@", + TTL: 1 * time.Hour, + Type: "SVCB", + Data: "0 example.com.", + }, + expect: ServiceBinding{ + Name: "@", + Scheme: "examplescheme", + URLSchemePort: 1234, + TTL: 1 * time.Hour, + Priority: 0, + Target: "example.com.", + Params: SvcParams{}, + }, + }, } { actual, err := test.input.toServiceBinding() if err == nil && test.shouldErr { @@ -305,6 +371,24 @@ func TestToSRV(t *testing.T) { Target: "example.com", }, }, + { + input: RR{ + Name: "_service._proto", + TTL: 5 * time.Minute, + Type: "SRV", + Data: "1 2 1234 example.com", + }, + expect: SRV{ + Service: "service", + Transport: "proto", + Name: "@", + TTL: 5 * time.Minute, + Priority: 1, + Weight: 2, + Port: 1234, + Target: "example.com", + }, + }, } { actual, err := test.input.toSRV() if err == nil && test.shouldErr { From db2a2dd0747a96049f1beaae42a8052c0214a186 Mon Sep 17 00:00:00 2001 From: Max Chernoff Date: Sat, 19 Apr 2025 07:30:11 -0600 Subject: [PATCH 09/13] Don't use `.@` for relative names in [SRV|ServiceBinding].RR() (#165) --- record_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++ rrtypes.go | 5 +++++ 2 files changed, 66 insertions(+) diff --git a/record_test.go b/record_test.go index 77b7e89..92735d5 100644 --- a/record_test.go +++ b/record_test.go @@ -561,3 +561,64 @@ func TestSvcParamsString(t *testing.T) { } } } + +func TestRelativeRRNames(t *testing.T) { + for _, test := range []struct { + input Record + expect string + }{ + { + input: ServiceBinding{ + Name: "@", + Scheme: "examplescheme", + URLSchemePort: 1234, + TTL: 1 * time.Hour, + Priority: 1, + Target: ".", + Params: SvcParams{}, + }, + expect: "_1234._examplescheme", + }, + { + input: SRV{ + Name: "@", + Service: "exampleservice", + Transport: "tcp", + TTL: 1 * time.Hour, + Priority: 1, + Weight: 2, + Target: ".", + }, + expect: "_exampleservice._tcp", + }, + { + input: ServiceBinding{ + Name: "test", + Scheme: "examplescheme", + URLSchemePort: 1234, + TTL: 1 * time.Hour, + Priority: 1, + Target: ".", + Params: SvcParams{}, + }, + expect: "_1234._examplescheme.test", + }, + { + input: SRV{ + Name: "test", + Service: "exampleservice", + Transport: "tcp", + TTL: 1 * time.Hour, + Priority: 1, + Weight: 2, + Target: ".", + }, + expect: "_exampleservice._tcp.test", + }, + } { + rr := test.input.RR() + if rr.Name != test.expect { + t.Errorf("Expected %q, got %q", test.expect, rr.Name) + } + } +} diff --git a/rrtypes.go b/rrtypes.go index 23b3ab5..7496cfc 100644 --- a/rrtypes.go +++ b/rrtypes.go @@ -3,6 +3,7 @@ package libdns import ( "fmt" "net/netip" + "strings" "time" ) @@ -164,6 +165,8 @@ func (s SRV) RR() RR { name = fmt.Sprintf("_%s._%s.%s", s.Service, s.Transport, s.Name) } + name = strings.TrimSuffix(name, ".@") + return RR{ Name: name, TTL: s.TTL, @@ -328,6 +331,8 @@ func (s ServiceBinding) RR() RR { params = s.Params.String() } + name = strings.TrimSuffix(name, ".@") + return RR{ Name: name, TTL: s.TTL, From 10a2b0b2c222c51900827339aaf1e22f5cfd2528 Mon Sep 17 00:00:00 2001 From: Max Chernoff Date: Sun, 20 Apr 2025 07:39:40 -0600 Subject: [PATCH 10/13] Make all zero values have `.RR().Data == ""` (#166) --- record_test.go | 37 +++++++++++++++++++++++++++++++++++++ rrtypes.go | 40 +++++++++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/record_test.go b/record_test.go index 92735d5..b5b77d9 100644 --- a/record_test.go +++ b/record_test.go @@ -622,3 +622,40 @@ func TestRelativeRRNames(t *testing.T) { } } } + +func TestRRDataZeroValues(t *testing.T) { + for _, test := range []Record{ + Address{ + Name: "example.com", + }, + CAA{ + Name: "example.com", + }, + CNAME{ + Name: "example.com", + }, + MX{ + Name: "example.com", + }, + NS{ + Name: "example.com", + }, + SRV{ + Name: "example.com", + Transport: "tcp", + Service: "exampleservice", + }, + ServiceBinding{ + Name: "example.com", + Scheme: "https", + }, + TXT{ + Name: "example.com", + }, + } { + rr := test.RR() + if rr.Data != "" { + t.Errorf("%s: Expected empty Data, got '%s'", rr.Type, rr.Data) + } + } +} diff --git a/rrtypes.go b/rrtypes.go index 7496cfc..fafafe7 100644 --- a/rrtypes.go +++ b/rrtypes.go @@ -28,11 +28,19 @@ func (a Address) RR() RR { if a.IP.Is6() { recType = "AAAA" } + data := a.IP.String() + if a.IP == (netip.Addr{}) { + // If the IP address is null, then we get the string "invalid IP". We'll + // convert this to the empty string to make + // [libdns.RecordDeleter.DeleteRecords] easier to use when missing IP + // addresses are passed. + data = "" + } return RR{ Name: a.Name, TTL: a.TTL, Type: recType, - Data: a.IP.String(), + Data: data, } } @@ -50,11 +58,16 @@ type CAA struct { } func (c CAA) RR() RR { + data := fmt.Sprintf(`%d %s %q`, c.Flags, c.Tag, c.Value) + // Make sure that the zero value is an empty string + if c.Flags == 0 && c.Tag == "" && c.Value == "" { + data = "" + } return RR{ Name: c.Name, TTL: c.TTL, Type: "CAA", - Data: fmt.Sprintf(`%d %s %q`, c.Flags, c.Tag, c.Value), + Data: data, } } @@ -85,11 +98,16 @@ type MX struct { } func (m MX) RR() RR { + data := fmt.Sprintf("%d %s", m.Preference, m.Target) + // Make sure that the zero value is an empty string + if m.Preference == 0 && m.Target == "" { + data = "" + } return RR{ Name: m.Name, TTL: m.TTL, Type: "MX", - Data: fmt.Sprintf("%d %s", m.Preference, m.Target), + Data: data, } } @@ -167,11 +185,17 @@ func (s SRV) RR() RR { name = strings.TrimSuffix(name, ".@") + data := fmt.Sprintf("%d %d %d %s", s.Priority, s.Weight, s.Port, s.Target) + // Make sure that the zero value is an empty string + if s.Priority == 0 && s.Weight == 0 && s.Port == 0 && s.Target == "" { + data = "" + } + return RR{ Name: name, TTL: s.TTL, Type: "SRV", - Data: fmt.Sprintf("%d %d %d %s", s.Priority, s.Weight, s.Port, s.Target), + Data: data, } } @@ -333,11 +357,17 @@ func (s ServiceBinding) RR() RR { name = strings.TrimSuffix(name, ".@") + data := fmt.Sprintf("%d %s %s", s.Priority, s.Target, params) + // Make sure that the zero value is an empty string + if s.Priority == 0 && s.Target == "" && params == "" { + data = "" + } + return RR{ Name: name, TTL: s.TTL, Type: recType, - Data: fmt.Sprintf("%d %s %s", s.Priority, s.Target, params), + Data: data, } } From e0df105aed0e13a68e893367f93eabb73a558638 Mon Sep 17 00:00:00 2001 From: Matt Holt Date: Wed, 23 Apr 2025 07:27:30 -0600 Subject: [PATCH 11/13] Create RR.ProviderData (close #119) (#169) * Create RR.ProviderData (close #119) * Add omitempty to the optional field * Move ProviderData to specific struct types, not RR * Update libdns.go Co-authored-by: Max Chernoff --------- Co-authored-by: Max Chernoff --- libdns.go | 9 +++++++++ record.go | 8 ++++---- rrtypes.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/libdns.go b/libdns.go index a5ecd46..6e264e3 100644 --- a/libdns.go +++ b/libdns.go @@ -19,6 +19,15 @@ // Specific record types such as [Address], [SRV], [TXT], and others implement // the [Record] interface. // +// Record values should not be primitvely compared (==) unless it is [RR], +// because some struct types contain maps, for which equality is not defined; +// additionally, some packages may attach custom data to each RR struct-type's +// `ProviderData` field, whose values might not be comparable either. The +// `ProviderData` fields are not portable across providers, or possibly even +// zones. Because it is not portable, and we want to ensure that [RR] structs +// remain both portable and comparable, the `RR()` methods do not preserve +// `ProviderData` in their return values. +// // Implementations of the libdns interfaces should accept as input any [Record] // value, and should return as output the concrete struct types that implement // the [Record] interface (i.e. [Address], [TXT], [ServiceBinding], etc). This diff --git a/record.go b/record.go index bfe8523..e21b3d9 100644 --- a/record.go +++ b/record.go @@ -57,7 +57,7 @@ type RR struct { // // Valid, but probably doesn't do what you want: // - “www.example.net” (refers to “www.example.net.example.com.”) - Name string + Name string `json:"name"` // The time-to-live of the record. This is represented in the DNS zone file as // an unsigned integral number of seconds, but is provided here as a @@ -69,7 +69,7 @@ type RR struct { // Note that some providers may reject or silently increase TTLs that are below // a certain threshold, and that DNS resolvers may choose to ignore your TTL // settings, so it is recommended to not rely on the exact TTL value. - TTL time.Duration + TTL time.Duration `json:"ttl"` // The type of the record as an uppercase string. DNS provider packages are // encouraged to support as many of the most common record types as possible, @@ -77,7 +77,7 @@ type RR struct { // // Other custom record types may be supported with implementation-defined // behavior. - Type string + Type string `json:"type"` // The data (or "value") of the record. This field should be formatted in // the *unescaped* standard zone file syntax (technically, the "RDATA" field @@ -102,7 +102,7 @@ type RR struct { // // Implementations are not expected to support RFC 3597 “\#” escape // sequences, but may choose to do so if they wish. - Data string + Data string `json:"data"` } // RR returns itself. This may be the case when trying to parse an RR type diff --git a/rrtypes.go b/rrtypes.go index fafafe7..2cc6814 100644 --- a/rrtypes.go +++ b/rrtypes.go @@ -21,6 +21,10 @@ type Address struct { Name string TTL time.Duration IP netip.Addr + + // Optional custom data associated with the provider serving this record. + // See the package godoc for important details on this field. + ProviderData any } func (a Address) RR() RR { @@ -55,6 +59,10 @@ type CAA struct { Flags uint8 // As of March 2025, the only valid values are 0 and 128. Tag string Value string + + // Optional custom data associated with the provider serving this record. + // See the package godoc for important details on this field. + ProviderData any } func (c CAA) RR() RR { @@ -77,6 +85,10 @@ type CNAME struct { Name string TTL time.Duration Target string + + // Optional custom data associated with the provider serving this record. + // See the package godoc for important details on this field. + ProviderData any } func (c CNAME) RR() RR { @@ -95,6 +107,10 @@ type MX struct { TTL time.Duration Preference uint16 // Lower values indicate that clients should prefer this server. This field is similar to the “Priority” field in SRV records. Target string // The hostname of the mail server + + // Optional custom data associated with the provider serving this record. + // See the package godoc for important details on this field. + ProviderData any } func (m MX) RR() RR { @@ -127,6 +143,10 @@ type NS struct { Name string TTL time.Duration Target string + + // Optional custom data associated with the provider serving this record. + // See the package godoc for important details on this field. + ProviderData any } func (n NS) RR() RR { @@ -169,6 +189,10 @@ type SRV struct { Weight uint16 // Higher values indicate that clients should prefer this server when choosing between targets with the same priority Port uint16 // The port on which the service is running. Target string // The hostname of the server providing the service, which must not point to a CNAME. + + // Optional custom data associated with the provider serving this record. + // See the package godoc for important details on this field. + ProviderData any } func (s SRV) RR() RR { @@ -322,6 +346,10 @@ type ServiceBinding struct { // ignore the entire record. This is similar to the “critical” flag in CAA // records. Params SvcParams + + // Optional custom data associated with the provider serving this record. + // See the package godoc for important details on this field. + ProviderData any } // RR converts the parsed record data to a generic [Record] struct. @@ -398,6 +426,10 @@ type TXT struct { // [RFC 7208 §3.3]: https://datatracker.ietf.org/doc/html/rfc7208#section-3.3 // [DNSControl explainer]: https://docs.dnscontrol.org/developer-info/opinions#opinion-8-txt-records-are-one-long-string Text string + + // Optional custom data associated with the provider serving this record. + // See the package godoc for important details on this field. + ProviderData any } func (t TXT) RR() RR { From 505849161d82942aee7f711e5582ebab8a4f9dd6 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 28 Apr 2025 10:10:33 -0600 Subject: [PATCH 12/13] Minor docs enhancements --- README.md | 33 +++++++++++++-------------------- libdns.go | 35 +++++++++++++++-------------------- 2 files changed, 28 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 07d7108..463e66b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@ libdns - Universal DNS provider APIs for Go =========================================== -[![Go Reference](https://pkg.go.dev/badge/github.com/libdns/libdns)](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1) - -**⚠️ v1.0 is now in beta. Any packages that implemented prior versions should upgrade. The betas are subject to change, but big changes are unlikely at this point. We encourage all maintainers to upgrade their packages. Thank you!** +[![Go Reference](https://pkg.go.dev/badge/github.com/libdns/libdns)](https://pkg.go.dev/github.com/libdns/libdns) `libdns` is a collection of free-range DNS provider client implementations written in Go! With libdns packages, your Go program can manage DNS records across any supported providers. A "provider" is a service or program that manages a DNS zone. @@ -11,19 +9,19 @@ This repository defines the core APIs that provider packages should implement. T The interfaces include: -- [`RecordGetter`](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1#RecordGetter) to list records. -- [`RecordAppender`](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1#RecordAppender) to create new records. -- [`RecordSetter`](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1#RecordSetter) to set (create or update) records. -- [`RecordDeleter`](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1#RecordDeleter) to delete records. -- [`ZoneLister`](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1#ZoneLister) to list zones. +- [`RecordGetter`](https://pkg.go.dev/github.com/libdns/libdns#RecordGetter) to list records. +- [`RecordAppender`](https://pkg.go.dev/github.com/libdns/libdns#RecordAppender) to create new records. +- [`RecordSetter`](https://pkg.go.dev/github.com/libdns/libdns#RecordSetter) to set (create or update) records. +- [`RecordDeleter`](https://pkg.go.dev/github.com/libdns/libdns#RecordDeleter) to delete records. +- [`ZoneLister`](https://pkg.go.dev/github.com/libdns/libdns#ZoneLister) to list zones. -[See full godoc for detailed information.](https://pkg.go.dev/github.com/libdns/libdns@v1.0.0-beta.1) +**[See full godoc for detailed information.](https://pkg.go.dev/github.com/libdns/libdns)** ## Implementations -This package only defines standardized APIs described above. To actually manipulate DNS records/zones, you will need a package specific to your provider that implements these interfaces. +This package only defines standardized APIs described above. To actually manipulate DNS records/zones, you will need [a package specific to your provider](https://github.com/orgs/libdns/repositories?type=all) that implements these interfaces. -You can choose from over 75 packages at [https://github.com/libdns](https://github.com/orgs/libdns/repositories?type=all). +You can choose from over 80 packages at [https://github.com/libdns](https://github.com/orgs/libdns/repositories?type=all). ## Example @@ -71,7 +69,7 @@ deletedRecs, err := provider.DeleteRecords(ctx, zone, []libdns.Record{ Provider packages are 100% written and maintained by the community! Collectively, we as members of the community each maintain the packages for providers we personally use. -**[Instructions for adding new libdns packages](https://github.com/libdns/libdns/wiki/Implementing-a-libdns-package)** are on this repo's wiki. Please feel free to contribute yours! +**[Instructions for adding new libdns packages](https://github.com/libdns/libdns/wiki/Implementing-a-libdns-package)** are on this repo's wiki. Please feel free to contribute a package for your provider! ## Similar projects @@ -86,17 +84,12 @@ This is incredibly useful when you are maintaining your own zone file, but risky **[go-acme/lego](https://github.com/go-acme/lego)** supports many DNS providers, but their APIs are only capable of setting and deleting TXT records for ACME challenges. -**[miekg/dns](https://github.com/miekg/dns)** is a comprehensive, low-level DNS library for Go programs. It is well-maintained and extremely thorough, but also too low-level to be productive for our use cases. +**[miekg/dns](https://github.com/miekg/dns)** is a comprehensive, low-level DNS library for Go programs. It is well-maintained and extremely thorough, but also too low-level to be effective for our use cases. -**`libdns`** takes inspiration from the above projects but aims for a more generally-useful set of high-level APIs that homogenize pretty well across providers. In contrast to the above projects, libdns can add, set, delete, and get arbitrary records from a zone without obliterating it (although syncing up an entire zone is also possible!). Its APIs also include context so long-running calls can be cancelled early, for example to accommodate on-line config changes downstream. libdns interfaces are also smaller and more composable. Additionally, libdns can grow to support a nearly infinite number of DNS providers without added bloat, because each provider implementation is a separate Go module, which keeps your builds lean and fast. +**`libdns`** takes inspiration from the above projects but aims for a more generally-useful set of high-level APIs that homogenize pretty well across providers. In contrast to most of the above projects, libdns can add, set, delete, and get arbitrary records from a zone without obliterating it (although syncing up an entire zone is also possible!). Its APIs also include context so long-running calls can be cancelled early, for example to accommodate on-line config changes downstream. libdns interfaces are also smaller and more composable. Additionally, libdns can grow to support a nearly infinite number of DNS providers without added bloat, because each provider implementation is a separate Go module, which keeps your builds lean and fast. In summary, the goal is that libdns providers can do what the above libraries/tools can do, but with more flexibility: they can create and delete TXT records for ACME challenges, they can replace entire zones, but they can also do incremental changes or simply read records. **Whatever libdns is used for with your DNS zone, it is presumed that only your libdns code is manipulating that (part of your) zone.** This package does not provide synchronization primitives, but your own code can do that if necessary. - -## Record abstraction - -How records are represented across providers varies widely, and each kind of record has different fields and semantics. - -Realistically, libdns should enable most common record manipulations, but may not be able to fit absolutely 100% of all possibilities with DNS in a provider-agnostic way. That is probably OK; and given the wide varieties in DNS record types and provider APIs, it would be unreasonable to expect otherwise. Our goal is 100% fulfillment of ~99% of use cases / user requirements, not 100% fulfillment of 100% of use cases. +Realistically, libdns should enable most common record manipulations, but may not be able to fit absolutely 100% of all possibilities with DNS in a provider-agnostic way. That is probably OK; and given the wide varieties in DNS record types and provider APIs, it would be unreasonable to expect otherwise. **Our goal is 100% fulfillment of ~99% of use cases / user requirements, not 100% fulfillment of 100% of use cases.** diff --git a/libdns.go b/libdns.go index 6e264e3..399920f 100644 --- a/libdns.go +++ b/libdns.go @@ -6,21 +6,19 @@ // This documentation uses the definitions for terms from RFC 9499: // https://datatracker.ietf.org/doc/html/rfc9499 // -// This package represents DNS records in two primary ways: as opaque [RR] -// structs, where the data is serialized as a single string as in a zone file; -// and as individual type structures, where the data is parsed into its separate -// fields for easier manipulation by Go programs (for example: [SRV] and -// [ServiceBinding] types). This hybrid design offers great flexibility for both -// DNS provider packages and consumer Go programs. +// This package represents records with the [Record] interface, which is any +// type that can transform itself into the [RR] struct. This interface is +// implemented by the various record abstractions this package offers: [RR] +// structs, where the data is serialized as a single opaque string as if in +// a zone file, being a type-agnostic [Resource Record] (that is, a name, +// type, class, TTL, and data); and individual RR-type structures, where the +// data is parsed into its separate fields for easier manipulation by Go +// programs (for example: [SRV], [TXT], and [ServiceBinding] types). This +// hybrid design grants great flexibility for both DNS provider packages and +// consumer Go programs. // -// This package represents records flexibly with the [Record] interface, which -// is any type that can transform itself into the [RR] struct, which is a -// type-agnostic [Resource Record] (that is, a name, type, class, TTL, and data). -// Specific record types such as [Address], [SRV], [TXT], and others implement -// the [Record] interface. -// -// Record values should not be primitvely compared (==) unless it is [RR], -// because some struct types contain maps, for which equality is not defined; +// [Record] values should not be primitvely compared (==) unless they are [RR], +// because other struct types contain maps, for which equality is not defined; // additionally, some packages may attach custom data to each RR struct-type's // `ProviderData` field, whose values might not be comparable either. The // `ProviderData` fields are not portable across providers, or possibly even @@ -105,7 +103,7 @@ type RecordAppender interface { // zone in an invalid state. // // Implementations should return struct types defined by this package which - // correspond with the specific RR-type, rather than the [RR] struct, if possible. + // correspond with the specific RR-type (instead of the opaque [RR] struct). // // Implementations must honor context cancellation and be safe for concurrent // use. @@ -151,7 +149,7 @@ type RecordSetter interface { // CNAME records. // // Implementations should return struct types defined by this package which - // correspond with the specific RR-type, rather than the [RR] struct, if possible. + // correspond with the specific RR-type (instead of the opaque [RR] struct). // // Implementations must honor context cancellation and be safe for concurrent // use. @@ -217,7 +215,7 @@ type RecordDeleter interface { // zone, so attempting to do is undefined behavior. // // Implementations should return struct types defined by this package which - // correspond with the specific RR-type, rather than the [RR] struct, if possible. + // correspond with the specific RR-type (instead of the opaque [RR] struct). // // Implementations must honor context cancellation and be safe for concurrent // use. @@ -231,9 +229,6 @@ type ZoneLister interface { // available zones, and very few [libdns]-dependent packages use this // method, so this method is optional. // - // Implementations should return struct types defined by this package which - // correspond with the specific RR-type, rather than the [RR] struct, if possible. - // // Implementations must honor context cancellation and be safe for // concurrent use. ListZones(ctx context.Context) ([]Zone, error) From 9b97177ca13a3854fa43a4ff6471b2f485ce31b9 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 28 Apr 2025 10:28:31 -0600 Subject: [PATCH 13/13] Clarify ProviderData field --- libdns.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/libdns.go b/libdns.go index 399920f..ce50977 100644 --- a/libdns.go +++ b/libdns.go @@ -21,10 +21,14 @@ // because other struct types contain maps, for which equality is not defined; // additionally, some packages may attach custom data to each RR struct-type's // `ProviderData` field, whose values might not be comparable either. The -// `ProviderData` fields are not portable across providers, or possibly even +// `ProviderData` field is not portable across providers, or possibly even // zones. Because it is not portable, and we want to ensure that [RR] structs -// remain both portable and comparable, the `RR()` methods do not preserve -// `ProviderData` in their return values. +// remain both portable and comparable, the `RR()` method does not preserve +// `ProviderData` in its return value. Users of libdns packages should check +// the documentation of provider packages, as some may use the `ProviderData` +// field to reduce API calls / increase effiency. But implementations must +// never rely on `ProviderData` for correctness if possible (and should +// document clearly otherwise). // // Implementations of the libdns interfaces should accept as input any [Record] // value, and should return as output the concrete struct types that implement