diff --git a/go.mod b/go.mod index 6c5894540..614dce032 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/alecthomas/chroma/v2 v2.15.0 github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b - github.com/caddyserver/certmagic v0.22.2 + github.com/caddyserver/certmagic v0.22.3-0.20250407182622-b9399eadfbe7 github.com/caddyserver/zerossl v0.1.3 github.com/cloudflare/circl v1.6.0 github.com/dustin/go-humanize v1.0.1 @@ -116,7 +116,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgtype v1.14.0 // indirect github.com/jackc/pgx/v4 v4.18.3 // indirect - github.com/libdns/libdns v0.2.3 + github.com/libdns/libdns v1.0.0-beta.1 github.com/manifoldco/promptui v0.9.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index e2c85355b..676849128 100644 --- a/go.sum +++ b/go.sum @@ -93,8 +93,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= -github.com/caddyserver/certmagic v0.22.2 h1:qzZURXlrxwR5m25/jpvVeEyJHeJJMvAwe5zlMufOTQk= -github.com/caddyserver/certmagic v0.22.2/go.mod h1:hbqE7BnkjhX5IJiFslPmrSeobSeZvI6ux8tyxhsd6qs= +github.com/caddyserver/certmagic v0.22.3-0.20250407182622-b9399eadfbe7 h1:dZCbkHTYh2ulXpmUK4ZrYWMCTa7x4/F3w52RGQfYoIY= +github.com/caddyserver/certmagic v0.22.3-0.20250407182622-b9399eadfbe7/go.mod h1:r8ZK7Y8zaUU3j5g+oTiluLtQX8fwWI6MamcRINlSsJY= github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -327,8 +327,8 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/libdns/libdns v0.2.3 h1:ba30K4ObwMGB/QTmqUxf3H4/GmUrCAIkMWejeGl12v8= -github.com/libdns/libdns v0.2.3/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libdns/libdns v1.0.0-beta.1 h1:KIf4wLfsrEpXpZ3vmc/poM8zCATXT2klbdPe6hyOBjQ= +github.com/libdns/libdns v1.0.0-beta.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= diff --git a/modules/caddytls/ech.go b/modules/caddytls/ech.go index f7b8db995..fe0ba93b7 100644 --- a/modules/caddytls/ech.go +++ b/modules/caddytls/ech.go @@ -641,10 +641,6 @@ nextName: } relName := libdns.RelativeName(domain+".", zone) - // TODO: libdns.RelativeName should probably return "@" instead of "". (The latest commits of libdns do this, so remove this logic once upgraded.) - if relName == "" { - relName = "@" - } // get existing records for this domain; we need to make sure another // record exists for it so we don't accidentally trample a wildcard; we @@ -657,23 +653,25 @@ nextName: zap.Error(err)) continue } - var httpsRec libdns.Record + var httpsRec libdns.ServiceBinding var nameHasExistingRecord bool for _, rec := range recs { - // TODO: providers SHOULD normalize root-level records to be named "@"; remove the extra conditions when the transition to the new semantics is done - if rec.Name == relName || (rec.Name == "" && relName == "@") { + rr := rec.RR() + if rr.Name == relName { // CNAME records are exclusive of all other records, so we cannot publish an HTTPS // record for a domain that is CNAME'd. See #6922. - if rec.Type == "CNAME" { + if rr.Type == "CNAME" { dnsPub.logger.Warn("domain has CNAME record, so unable to publish ECH data to HTTPS record", zap.String("domain", domain), - zap.String("cname_value", rec.Value)) + zap.String("cname_value", rr.Data)) continue nextName } nameHasExistingRecord = true - if rec.Type == "HTTPS" && (rec.Target == "" || rec.Target == ".") { - httpsRec = rec - break + if svcb, ok := rec.(libdns.ServiceBinding); ok && svcb.Scheme == "https" { + if svcb.Target == "" || svcb.Target == "." { + httpsRec = svcb + break + } } } } @@ -689,31 +687,24 @@ nextName: zap.String("zone", zone)) continue } - params := make(svcParams) - if httpsRec.Value != "" { - params, err = parseSvcParams(httpsRec.Value) - if err != nil { - dnsPub.logger.Error("unable to parse existing DNS record to publish ECH data to HTTPS DNS record", - zap.String("domain", domain), - zap.String("https_rec_value", httpsRec.Value), - zap.Error(err)) - continue - } + params := httpsRec.Params + if params == nil { + params = make(libdns.SvcParams) } - // overwrite only the ech SvcParamKey + // overwrite only the "ech" SvcParamKey params["ech"] = []string{base64.StdEncoding.EncodeToString(configListBin)} // publish record _, err = dnsPub.provider.SetRecords(ctx, zone, []libdns.Record{ - { + libdns.ServiceBinding{ // HTTPS and SVCB RRs: RFC 9460 (https://www.rfc-editor.org/rfc/rfc9460) - Type: "HTTPS", + Scheme: "https", Name: relName, - Priority: 2, // allows a manual override with priority 1 - Target: ".", - Value: params.String(), TTL: 1 * time.Minute, // TODO: for testing only + Priority: 2, // allows a manual override with priority 1 + Target: ".", + Params: params, }, }) if err != nil { @@ -952,172 +943,6 @@ func newECHConfigID(ctx caddy.Context) (uint8, error) { return 0, fmt.Errorf("depleted attempts to find an available config_id") } -// svcParams represents SvcParamKey and SvcParamValue pairs as -// described in https://www.rfc-editor.org/rfc/rfc9460 (section 2.1). -type svcParams map[string][]string - -// parseSvcParams parses service parameters into a structured type -// for safer manipulation. -func parseSvcParams(input string) (svcParams, error) { - if len(input) > 4096 { - return nil, fmt.Errorf("input too long: %d", len(input)) - } - - params := make(svcParams) - input = strings.TrimSpace(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 -} - -// String serializes svcParams into zone presentation format. -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() -} - // ECHPublisher is an interface for publishing ECHConfigList values // so that they can be used by clients. type ECHPublisher interface { diff --git a/modules/caddytls/ech_test.go b/modules/caddytls/ech_test.go deleted file mode 100644 index b722d2fbf..000000000 --- a/modules/caddytls/ech_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package caddytls - -import ( - "reflect" - "testing" -) - -func TestParseSvcParams(t *testing.T) { - for i, test := range []struct { - input string - expect svcParams - shouldErr bool - }{ - { - 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 - } - } -}