pax_global_header00006660000000000000000000000064151544067720014525gustar00rootroot0000000000000052 comment=09e9a58959b1ebca9f3a28986baa13a369359020 golang-github-libdns-libdns-1.1.1/000077500000000000000000000000001515440677200167765ustar00rootroot00000000000000golang-github-libdns-libdns-1.1.1/.github/000077500000000000000000000000001515440677200203365ustar00rootroot00000000000000golang-github-libdns-libdns-1.1.1/.github/FUNDING.yml000066400000000000000000000013171515440677200221550ustar00rootroot00000000000000# These are supported funding model platforms github: [mholt] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] golang-github-libdns-libdns-1.1.1/.gitignore000066400000000000000000000000141515440677200207610ustar00rootroot00000000000000_gitignore/ golang-github-libdns-libdns-1.1.1/LICENSE000066400000000000000000000020551515440677200200050ustar00rootroot00000000000000MIT License Copyright (c) 2020 Matthew Holt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. golang-github-libdns-libdns-1.1.1/README.md000066400000000000000000000136271515440677200202660ustar00rootroot00000000000000libdns - 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) `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 APIs 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 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)** ## Implementations 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 80 packages at [https://github.com/libdns](https://github.com/orgs/libdns/repositories?type=all). ## Example To work with DNS records managed by Cloudflare, for example, we can use [libdns/cloudflare](https://pkg.go.dev/github.com/libdns/cloudflare): ```go import ( "github.com/libdns/cloudflare" "github.com/libdns/libdns" ) ctx := context.TODO() zone := "example.com." // configure the DNS provider (choose any from github.com/libdns) provider := cloudflare.Provider{APIToken: "topsecret"} // list records recs, err := provider.GetRecords(ctx, zone) // create records (AppendRecords is similar, with different semantics) newRecs, err := provider.SetRecords(ctx, zone, []libdns.Record{ libdns.Address{ Name: "@", Value: netip.MustParseAddr("1.2.3.4"), }, }) // delete records deletedRecs, err := provider.DeleteRecords(ctx, zone, []libdns.Record{ libdns.TXT{ Name: "subdomain", Text: "txt value I want to delete" }, }) // no matter which provider you use, the code stays the same! // (some providers have caveats; see their package documentation) ``` ## Implementing new provider packages 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 a package for your provider! ## Similar projects **[OctoDNS](https://github.com/github/octodns)** is a suite of tools written in Python for managing DNS. However, its approach is a bit heavy-handed when all you need are small, incremental changes to a zone: > WARNING: OctoDNS assumes ownership of any domain you point it to. When you tell it to act it will do whatever is necessary to try and match up states including deleting any unexpected records. Be careful when playing around with OctoDNS. This is incredibly useful when you are maintaining your own zone file, but risky when you just need incremental changes. **[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)** 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 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 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. 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.** golang-github-libdns-libdns-1.1.1/go.mod000066400000000000000000000000511515440677200201000ustar00rootroot00000000000000module github.com/libdns/libdns go 1.18 golang-github-libdns-libdns-1.1.1/libdns.go000066400000000000000000000360341515440677200206060ustar00rootroot00000000000000// 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 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. // // [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` 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()` 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 // 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 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 // 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 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" "strings" ) // [RecordGetter] can get records from a DNS zone. 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. 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 should return struct types defined by this package which // correspond with the specific RR-type (instead of the opaque [RR] struct). // // Implementations must honor context cancellation and be safe for concurrent // use. AppendRecords(ctx context.Context, zone string, recs []Record) ([]Record, error) } // [RecordSetter] can set new or update existing records in a DNS zone. type RecordSetter interface { // SetRecords updates the zone so that the records described in the input are // reflected in the output. It may create or 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. // // SetRecords is distinct from [libdns.RecordAppender.AppendRecords] in that // AppendRecords *only* adds records to the zone, while SetRecords may also // delete records if necessary. Therefore, SetRecords behaves similarly to // the following code: // // func SetRecords(ctx context.Context, zone string, recs []Record) ([]Record, error) { // prevs, _ := p.GetRecords(ctx, zone) // toDelete := []Record{} // for _, prev := range prevs { // for _, new := range recs { // if prev.RR().Name == new.RR().Name && prev.RR().Type == new.RR().Type { // toDelete = append(toDelete, prev) // } // } // } // DeleteRecords(ctx, zone, toDelete) // return AppendRecords(ctx, zone, recs) // } // // 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]. // // If possible, implementations should make SetRecords atomic, such that if // err == nil, then all of the requested changes were made, and if err != nil, // then the zone remains as if the method was never called. However, as very // few providers offer batch/atomic operations, the actual result of a call // where err != nil is undefined. Implementations may implement synthetic // atomicity that rolls back partial changes on failure ONLY if it can be // done reliably. For calls that error atomically, implementations should // return [AtomicErr] as the error so callers may know that their zone remains // in a consistent state. Implementations should document their atomicity // guarantees (or lack thereof). // // 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 should return struct types defined by this package which // correspond with the specific RR-type (instead of the opaque [RR] struct). // // 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 // alpha.example.com. 3600 IN AAAA 2001:db8::1 // alpha.example.com. 3600 IN AAAA 2001:db8::2 // beta.example.com. 3600 IN AAAA 2001:db8::3 // beta.example.com. 3600 IN AAAA 2001:db8::4 // // ;; Input // alpha.example.com. 3600 IN AAAA 2001:db8::1 // alpha.example.com. 3600 IN AAAA 2001:db8::2 // alpha.example.com. 3600 IN AAAA 2001:db8::5 // // ;; Resultant zone // alpha.example.com. 3600 IN AAAA 2001:db8::1 // alpha.example.com. 3600 IN AAAA 2001:db8::2 // alpha.example.com. 3600 IN AAAA 2001:db8::5 // beta.example.com. 3600 IN AAAA 2001:db8::3 // beta.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. 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 should return struct types defined by this package which // correspond with the specific RR-type (instead of the opaque [RR] struct). // // Implementations must honor context cancellation and be safe for concurrent // use. DeleteRecords(ctx context.Context, zone string, recs []Record) ([]Record, error) } // [ZoneLister] can list available DNS zones. type ZoneLister interface { // ListZones returns the list of available DNS zones for use by other // [libdns] methods. 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) } // [Zone] is a generalized representation of a DNS zone. type Zone struct { Name string } // [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 cannot be expressed relative to zone, the input fqdn is // returned. func RelativeName(fqdn, zone string) string { // liberally ignore trailing dots on both fqdn and zone, because // the relative name won't have a trailing dot anyway; I assume // this won't be problematic...? // (initially implemented because Cloudflare returns "fully- // qualified" domains in their records without a trailing dot, // but the input zone typically has a trailing dot) 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 // 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 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, ".") } if name == "" || name == "@" { return zone } if strings.HasSuffix(name, ".") { // Already a FQDN, so just return it return name } return name + "." + zone } // AtomicErr should be returned as the error when a method errors // atomically. When this error type is returned, the caller can // know that their zone remains in a consistent state despite an // error. type AtomicErr error golang-github-libdns-libdns-1.1.1/libdns_test.go000066400000000000000000000053541515440677200216460ustar00rootroot00000000000000package libdns import ( "fmt" "testing" ) func ExampleRelativeName() { fmt.Println(RelativeName("sub.example.com.", "example.com.")) // Output: sub } func ExampleAbsoluteName() { fmt.Println(AbsoluteName("sub", "example.com.")) // Output: sub.example.com. } func TestRelativeName(t *testing.T) { for i, test := range []struct { fqdn, zone string expect string }{ { fqdn: "", zone: "", expect: "", }, { fqdn: "", zone: "example.com", expect: "", }, { fqdn: "example.com.", zone: "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", expect: "sub", }, { fqdn: "foo.bar.example.com", zone: "bar.example.com", expect: "foo", }, { fqdn: "foo.bar.example.com", zone: "example.com", expect: "foo.bar", }, { fqdn: "foo.bar.example.com.", zone: "example.com.", expect: "foo.bar", }, { fqdn: "foo.bar.example.com", zone: "example.com.", expect: "foo.bar", }, { fqdn: "foo.bar.example.com.", zone: "example.com", expect: "foo.bar", }, { fqdn: "example.com", zone: "example.net", expect: "example.com", }, } { actual := RelativeName(test.fqdn, test.zone) if actual != test.expect { t.Errorf("Test %d: FQDN=%s ZONE=%s - expected '%s' but got '%s'", i, test.fqdn, test.zone, test.expect, actual) } } } func TestAbsoluteName(t *testing.T) { for i, test := range []struct { name, zone string expect string }{ { name: "", zone: "example.com", expect: "example.com", }, { name: "@", zone: "example.com.", expect: "example.com.", }, { name: "www", zone: "example.com.", expect: "www.example.com.", }, { name: "www", zone: "example.com.", expect: "www.example.com.", }, // see discussion at https://github.com/libdns/libdns/pull/153#discussion_r2013372378 about these next two { name: "www.", zone: "example.com.", expect: "www.", }, { name: "foo.bar.", zone: "example.com.", expect: "foo.bar.", }, { name: "foo.bar", zone: "example.com.", expect: "foo.bar.example.com.", }, { name: "foo", zone: "", expect: "foo", }, } { actual := AbsoluteName(test.name, test.zone) if actual != test.expect { t.Errorf("Test %d: NAME=%s ZONE=%s - expected '%s' but got '%s'", i, test.name, test.zone, test.expect, actual) } } } golang-github-libdns-libdns-1.1.1/record.go000066400000000000000000000364111515440677200206100ustar00rootroot00000000000000package 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. // // 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. // // 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. // 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 `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 // [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 `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, // especially: A, AAAA, CNAME, TXT, HTTPS, and SRV. // // Other custom record types may be supported with implementation-defined // behavior. 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 // 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 `json:"data"` } // 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.toServiceBinding() 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.SplitN(r.Data, " ", 3) 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] // If only https://tip.golang.org/src/cmd/internal/quoted/quoted.go were // public... value, err := strconv.Unquote(fields[2]) if err != nil { value = 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) < 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: name, TTL: r.TTL, Priority: uint16(priority), Weight: uint16(weight), Port: uint16(port), Target: target, }, nil } 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) } 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) // 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], "_") 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 } golang-github-libdns-libdns-1.1.1/record_test.go000066400000000000000000000351021515440677200216430ustar00rootroot00000000000000package 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", }, }, { input: RR{ Name: "@", TTL: 1 * time.Hour, Type: "CAA", Data: `0 issuewild "letsencrypt.org; validationmethods=dns-01; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567890"`, }, expect: CAA{ Name: "@", TTL: 1 * time.Hour, Flags: 0, Tag: "issuewild", Value: "letsencrypt.org; validationmethods=dns-01; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567890", }, }, } { 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"}, }, }, }, { 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 { 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", }, }, { 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 { 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 } } } 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) } } } 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) } } } golang-github-libdns-libdns-1.1.1/rrtypes.go000066400000000000000000000361141515440677200210420ustar00rootroot00000000000000package libdns import ( "fmt" "net/netip" "strings" "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 // 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 { recType := "A" 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: data, } } // 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 // 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 { 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: data, } } // CNAME represents a CNAME-type record, which delegates // authority to other names. 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 { 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 // 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 { 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: data, } } // 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 // 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 { 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. // 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 { 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) } 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: data, } } // 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 // 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. // // 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() } 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: data, } } // 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 // 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 { return RR{ Name: t.Name, TTL: t.TTL, Type: "TXT", Data: t.Text, } }