diff --git a/README.md b/README.md index 293ceac..80d584a 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,37 @@ Internally resolved cname, of sorts. Only works internally in a zone. The target will have the current zone name appended if it's not a FQDN (since v2.2.0). +### CAA + +CAA (Certificate Authority Authorization) records allow domain owners to specify which Certificate Authorities are authorized to issue certificates for their domain. + +CAA records support the standard DNS text format (similar to bind zone files): + + "0 issue ca.example.net" + "0 issuewild ca.example.net" + "128 iodef mailto:security@example.com" + +The format is `"flag tag value"` where: +- `flag`: A number (0-255) for processing flags +- `tag`: The property type (issue, issuewild, iodef, etc.) +- `value`: The property value (CA domain, email, URL, etc.) + +For weight-based load balancing, use array format: + + ["0 issue ca.example.net", 10] + ["128 iodef mailto:security@example.com", 100] + +The older JSON object format is still supported for backward compatibility: + + { "tag": "issue", "value": "ca.example.net" } + { "flag": 0, "tag": "issuewild", "value": "ca.example.net" } + { "flag": 128, "tag": "iodef", "value": "mailto:security@example.com", "weight": 100 } + +Common tags include: +- `issue`: Authorizes the specified CA to issue certificates for this domain +- `issuewild`: Authorizes the specified CA to issue wildcard certificates for this domain +- `iodef`: Specifies a URL or email address for reporting certificate issue violations + ### MX MX records support a `weight` similar to A records to indicate how often the particular diff --git a/go.sum b/go.sum index 63eb672..a6f22f4 100644 --- a/go.sum +++ b/go.sum @@ -24,12 +24,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hamba/avro/v2 v2.14.1 h1:mRkiRKjRTTs+yx0nVuM6z/q5zg3VBZfOe/01ngAnU6A= -github.com/hamba/avro/v2 v2.14.1/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK8fJ7Jo= github.com/hamba/avro/v2 v2.16.0 h1:0XhyP65Hs8iMLtdSR0v7ZrwRjsbIZdvr7KzYgmx1Mbo= github.com/hamba/avro/v2 v2.16.0/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK8fJ7Jo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -42,8 +38,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= -github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/miekg/dns v1.1.56 h1:5imZaSeoRNvpM9SzWNhEcP9QliKiz20/dA2QabIGVnE= github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -61,20 +55,12 @@ github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= -github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= -github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= -github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= -github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= -github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= -github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= -github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= @@ -88,42 +74,20 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -go.ntppool.org/common v0.2.0 h1:ufVBJoflAwq1HzT1/kezUBPTP2lkYEBPRmg1wkuqDbo= -go.ntppool.org/common v0.2.0/go.mod h1:2vW9Wsc+N45GkBoo+i8gn4a2dPeGP9gLQntzw6aKH6E= go.ntppool.org/common v0.2.1 h1:UZFFn/39Rn6esx+gzVceY4v5oznyNORJ7JugixdmKzM= go.ntppool.org/common v0.2.1/go.mod h1:rTTb+LHJRogQ8rdmu3lZsa7zwWA9vg33fNaM6u/EKtI= -golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ= -golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= -golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/tools v0.12.1-0.20230815132531-74c255bcf846 h1:Vve/L0v7CXXuxUmaMGIEK/dEeq7uiqb5qBgQrZzIE7E= -golang.org/x/tools v0.12.1-0.20230815132531-74c255bcf846/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/zones/reader.go b/zones/reader.go index 5a3f128..2f77b24 100644 --- a/zones/reader.go +++ b/zones/reader.go @@ -155,6 +155,7 @@ func setupZoneData(data map[string]interface{}, zone *Zone) { "a": dns.TypeA, "aaaa": dns.TypeAAAA, "alias": dns.TypeMF, + "caa": dns.TypeCAA, "cname": dns.TypeCNAME, "mx": dns.TypeMX, "ns": dns.TypeNS, @@ -227,7 +228,7 @@ func setupZoneData(data map[string]interface{}, zone *Zone) { //log.Printf("RECORDS %s TYPE-REC %T\n", Records, Records) - label.Records[dnsType] = make(Records, len(records[rType])) + validRecords := make([]*Record, 0, len(records[rType])) for i := 0; i < len(records[rType]); i++ { //log.Printf("RT %T %#v\n", records[rType][i], records[rType][i]) @@ -472,6 +473,95 @@ func setupZoneData(data map[string]interface{}, zone *Zone) { continue } + case dns.TypeCAA: + rec := records[rType][i] + + var flag uint8 = 0 + var tag, value string + + switch rec.(type) { + case string: + // Text format: "flag tag value" + var err error + flag, tag, value, err = parseCAAText(rec.(string)) + if err != nil { + log.Printf("Error parsing CAA record '%s' for '%s' in '%s': %v\n", rec.(string), label.Label, zone.Origin, err) + continue + } + case []interface{}: + // Array format: ["flag tag value", weight] + arr := rec.([]interface{}) + if len(arr) == 0 { + log.Printf("Empty CAA record array for '%s' in '%s'\n", label.Label, zone.Origin) + continue + } + + caaText, ok := arr[0].(string) + if !ok { + log.Printf("First element of CAA record array must be string for '%s' in '%s'\n", label.Label, zone.Origin) + continue + } + + var err error + flag, tag, value, err = parseCAAText(caaText) + if err != nil { + log.Printf("Error parsing CAA record '%s' for '%s' in '%s': %v\n", caaText, label.Label, zone.Origin, err) + continue + } + + if len(arr) > 1 { + switch weight := arr[1].(type) { + case int: + record.Weight = weight + case float64: + record.Weight = int(weight) + case string: + var err error + record.Weight, err = strconv.Atoi(weight) + if err != nil { + log.Printf("Error converting CAA weight '%s' to integer for '%s' in '%s': %v\n", weight, label.Label, zone.Origin, err) + } + default: + record.Weight = typeutil.ToInt(arr[1]) + } + } + case map[string]interface{}: + // JSON format for backward compatibility + recmap := rec.(map[string]interface{}) + + if recmap["flag"] != nil { + flag = uint8(typeutil.ToInt(recmap["flag"])) + } + + if recmap["tag"] != nil { + tag = recmap["tag"].(string) + } else { + log.Printf("CAA record missing required 'tag' field for '%s' in '%s'\n", label.Label, zone.Origin) + continue + } + + if recmap["value"] != nil { + value = recmap["value"].(string) + } else { + log.Printf("CAA record missing required 'value' field for '%s' in '%s'\n", label.Label, zone.Origin) + continue + } + + if recmap["weight"] != nil { + record.Weight = typeutil.ToInt(recmap["weight"]) + } + default: + log.Printf("CAA record must be string, array, or map format for '%s' in '%s'\n", label.Label, zone.Origin) + continue + } + + record.RR = &dns.CAA{ + Hdr: h, + Flag: flag, + Tag: tag, + Value: value, + } + default: log.Println("type:", rType) panic("Don't know how to handle this type") @@ -482,10 +572,17 @@ func setupZoneData(data map[string]interface{}, zone *Zone) { } label.Weight[dnsType] += record.Weight - label.Records[dnsType][i] = record + validRecords = append(validRecords, record) } - if label.Weight[dnsType] > 0 { - sort.Sort(RecordsByWeight{label.Records[dnsType]}) + + // Only create the Records array if we have valid records + if len(validRecords) > 0 { + label.Records[dnsType] = make(Records, len(validRecords)) + copy(label.Records[dnsType], validRecords) + + if label.Weight[dnsType] > 0 { + sort.Sort(RecordsByWeight{label.Records[dnsType]}) + } } } } @@ -559,3 +656,36 @@ func getStringWeight(rec []interface{}) (string, int) { return str, weight } + +// parseCAAText parses CAA record text format: "flag tag value" +// Example: "0 issue ca.example.net" or "128 iodef mailto:security@example.com" +func parseCAAText(text string) (flag uint8, tag, value string, err error) { + parts := strings.Fields(text) + if len(parts) < 3 { + return 0, "", "", fmt.Errorf("CAA record must have at least 3 parts: flag tag value") + } + + // Parse flag + flagInt, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, "", "", fmt.Errorf("invalid CAA flag '%s': %v", parts[0], err) + } + if flagInt < 0 || flagInt > 255 { + return 0, "", "", fmt.Errorf("CAA flag must be between 0 and 255, got %d", flagInt) + } + flag = uint8(flagInt) + + // Tag is the second part + tag = parts[1] + + // Value is everything from the third part onwards, joined with spaces + // This handles values that contain spaces (like quoted strings) + value = strings.Join(parts[2:], " ") + + // Remove quotes if present (common in bind format) + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + value = value[1 : len(value)-1] + } + + return flag, tag, value, nil +} diff --git a/zones/reader_test.go b/zones/reader_test.go index 7ea7d65..5c83b97 100644 --- a/zones/reader_test.go +++ b/zones/reader_test.go @@ -8,6 +8,7 @@ import ( "github.com/abh/geodns/v3/targeting" "github.com/abh/geodns/v3/targeting/geoip2" + "github.com/miekg/dns" "github.com/stretchr/testify/assert" ) @@ -161,3 +162,227 @@ func CopyFile(src, dst string) (int64, error) { defer df.Close() return io.Copy(df, sf) } + +func TestCAARecords(t *testing.T) { + // Create test data inline + jsonData := map[string]interface{}{ + "": map[string]interface{}{ + "ns": map[string]interface{}{ + "ns1.example.com.": nil, + "ns2.example.com.": nil, + }, + "caa": []interface{}{ + map[string]interface{}{ + "flag": float64(0), + "tag": "issue", + "value": "ca.example.net", + }, + map[string]interface{}{ + "tag": "issuewild", + "value": "ca.example.net", + }, + map[string]interface{}{ + "flag": float64(128), + "tag": "iodef", + "value": "mailto:security@example.com", + "weight": float64(100), + }, + }, + }, + } + + // Create a test zone + zone := &Zone{ + Origin: "test.com", + Labels: make(map[string]*Label), + Options: ZoneOptions{Ttl: 600}, + } + + // Set up zone data + setupZoneData(jsonData, zone) + + // Verify the apex label was created + apexLabel, exists := zone.Labels[""] + if !exists { + t.Fatal("Apex label not found") + } + + // Verify CAA records exist + caaRecords, exists := apexLabel.Records[dns.TypeCAA] + if !exists { + t.Fatal("CAA records not found") + } + + // Should have 3 CAA records + assert.Equal(t, 3, len(caaRecords), "Expected 3 CAA records") + + // Debug: print the actual records + for i, record := range caaRecords { + caa := record.RR.(*dns.CAA) + t.Logf("CAA record %d: flag=%d, tag=%s, value=%s, weight=%d", i, caa.Flag, caa.Tag, caa.Value, record.Weight) + } + + // Test records - order may vary based on JSON parsing + var issue, issuewild, iodef *dns.CAA + var iodefWeight int + + for _, record := range caaRecords { + caa := record.RR.(*dns.CAA) + switch caa.Tag { + case "issue": + issue = caa + case "issuewild": + issuewild = caa + case "iodef": + iodef = caa + iodefWeight = record.Weight + } + } + + // Test issue CAA record + assert.NotNil(t, issue, "Issue CAA record should exist") + assert.Equal(t, uint8(0), issue.Flag, "Issue CAA record flag should be 0") + assert.Equal(t, "issue", issue.Tag, "Issue CAA record tag should be 'issue'") + assert.Equal(t, "ca.example.net", issue.Value, "Issue CAA record value should be 'ca.example.net'") + + // Test issuewild CAA record + assert.NotNil(t, issuewild, "Issuewild CAA record should exist") + assert.Equal(t, uint8(0), issuewild.Flag, "Issuewild CAA record flag should default to 0") + assert.Equal(t, "issuewild", issuewild.Tag, "Issuewild CAA record tag should be 'issuewild'") + assert.Equal(t, "ca.example.net", issuewild.Value, "Issuewild CAA record value should be 'ca.example.net'") + + // Test iodef CAA record + assert.NotNil(t, iodef, "Iodef CAA record should exist") + assert.Equal(t, uint8(128), iodef.Flag, "Iodef CAA record flag should be 128") + assert.Equal(t, "iodef", iodef.Tag, "Iodef CAA record tag should be 'iodef'") + assert.Equal(t, "mailto:security@example.com", iodef.Value, "Iodef CAA record value should be 'mailto:security@example.com'") + assert.Equal(t, 100, iodefWeight, "Iodef CAA record weight should be 100") +} + +func TestCAARecordsTextFormat(t *testing.T) { + // Test CAA records using text format (bind-style) + jsonData := map[string]interface{}{ + "": map[string]interface{}{ + "ns": map[string]interface{}{ + "ns1.example.com.": nil, + "ns2.example.com.": nil, + }, + "caa": []interface{}{ + "0 issue ca.example.net", + "0 issuewild \"ca.example.net\"", + []interface{}{"128 iodef \"mailto:security@example.com\"", 100}, + "255 issue letsencrypt.org", + }, + }, + } + + // Create a test zone + zone := &Zone{ + Origin: "texttest.com", + Labels: make(map[string]*Label), + Options: ZoneOptions{Ttl: 600}, + } + + // Set up zone data + setupZoneData(jsonData, zone) + + // Verify the apex label was created + apexLabel, exists := zone.Labels[""] + if !exists { + t.Fatal("Apex label not found") + } + + // Verify CAA records exist + caaRecords, exists := apexLabel.Records[dns.TypeCAA] + if !exists { + t.Fatal("CAA records not found") + } + + // Should have 4 CAA records + assert.Equal(t, 4, len(caaRecords), "Expected 4 CAA records") + + // Debug: print the actual records + for i, record := range caaRecords { + caa := record.RR.(*dns.CAA) + t.Logf("CAA text record %d: flag=%d, tag=%s, value=%s, weight=%d", i, caa.Flag, caa.Tag, caa.Value, record.Weight) + } + + // Find specific records by content + var issue1, issue2, issuewild, iodef *dns.CAA + var iodefWeight int + + for _, record := range caaRecords { + caa := record.RR.(*dns.CAA) + switch { + case caa.Tag == "issue" && caa.Value == "ca.example.net": + issue1 = caa + case caa.Tag == "issue" && caa.Value == "letsencrypt.org": + issue2 = caa + case caa.Tag == "issuewild": + issuewild = caa + case caa.Tag == "iodef": + iodef = caa + iodefWeight = record.Weight + } + } + + // Test first issue CAA record + assert.NotNil(t, issue1, "First issue CAA record should exist") + assert.Equal(t, uint8(0), issue1.Flag, "First issue CAA record flag should be 0") + assert.Equal(t, "issue", issue1.Tag, "First issue CAA record tag should be 'issue'") + assert.Equal(t, "ca.example.net", issue1.Value, "First issue CAA record value should be 'ca.example.net'") + + // Test second issue CAA record + assert.NotNil(t, issue2, "Second issue CAA record should exist") + assert.Equal(t, uint8(255), issue2.Flag, "Second issue CAA record flag should be 255") + assert.Equal(t, "issue", issue2.Tag, "Second issue CAA record tag should be 'issue'") + assert.Equal(t, "letsencrypt.org", issue2.Value, "Second issue CAA record value should be 'letsencrypt.org'") + + // Test issuewild CAA record (with quotes stripped) + assert.NotNil(t, issuewild, "Issuewild CAA record should exist") + assert.Equal(t, uint8(0), issuewild.Flag, "Issuewild CAA record flag should be 0") + assert.Equal(t, "issuewild", issuewild.Tag, "Issuewild CAA record tag should be 'issuewild'") + assert.Equal(t, "ca.example.net", issuewild.Value, "Issuewild CAA record value should be 'ca.example.net' (quotes stripped)") + + // Test iodef CAA record with weight + assert.NotNil(t, iodef, "Iodef CAA record should exist") + assert.Equal(t, uint8(128), iodef.Flag, "Iodef CAA record flag should be 128") + assert.Equal(t, "iodef", iodef.Tag, "Iodef CAA record tag should be 'iodef'") + assert.Equal(t, "mailto:security@example.com", iodef.Value, "Iodef CAA record value should be 'mailto:security@example.com'") + assert.Equal(t, 100, iodefWeight, "Iodef CAA record weight should be 100") +} + +func TestCAARecordsTextFormatErrors(t *testing.T) { + // Test error handling for malformed CAA text records + jsonData := map[string]interface{}{ + "test": map[string]interface{}{ + "caa": []interface{}{ + "invalid", // Too few parts + "notanumber issue ca.net", // Invalid flag + "256 issue ca.net", // Flag out of range + }, + }, + } + + // Create a test zone + zone := &Zone{ + Origin: "errortest.com", + Labels: make(map[string]*Label), + Options: ZoneOptions{Ttl: 600}, + } + + // This should not panic but should log errors and skip malformed records + setupZoneData(jsonData, zone) + + // Verify the label was created + testLabel, exists := zone.Labels["test"] + if !exists { + t.Fatal("Test label not found") + } + + // Should have no CAA records due to all being malformed + caaRecords, exists := testLabel.Records[dns.TypeCAA] + if exists && len(caaRecords) > 0 { + t.Errorf("Expected no CAA records due to malformed input, but got %d", len(caaRecords)) + } +}