Browse Source

Add CAA record text format support per feedback

Co-authored-by: abh <[email protected]>
copilot-swe-agent[bot] 4 months ago
parent
commit
e4de2c5df3
3 changed files with 264 additions and 28 deletions
  1. 18 4
      README.md
  2. 118 24
      zones/reader.go
  3. 128 0
      zones/reader_test.go

+ 18 - 4
README.md

@@ -260,19 +260,33 @@ The target will have the current zone name appended if it's not a FQDN (since v2
 
 
 CAA (Certificate Authority Authorization) records allow domain owners to specify which Certificate Authorities are authorized to issue certificates for their domain.
 CAA (Certificate Authority Authorization) records allow domain owners to specify which Certificate Authorities are authorized to issue certificates for their domain.
 
 
-Each CAA record has a flag, tag, and value:
+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:[email protected]"
+
+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:[email protected]", 100]
+
+The older JSON object format is still supported for backward compatibility:
 
 
     { "tag": "issue", "value": "ca.example.net" }
     { "tag": "issue", "value": "ca.example.net" }
     { "flag": 0, "tag": "issuewild", "value": "ca.example.net" }
     { "flag": 0, "tag": "issuewild", "value": "ca.example.net" }
     { "flag": 128, "tag": "iodef", "value": "mailto:[email protected]", "weight": 100 }
     { "flag": 128, "tag": "iodef", "value": "mailto:[email protected]", "weight": 100 }
 
 
-The `flag` field is optional and defaults to 0. Common tags include:
+Common tags include:
 - `issue`: Authorizes the specified CA to issue certificates for this domain
 - `issue`: Authorizes the specified CA to issue certificates for this domain
 - `issuewild`: Authorizes the specified CA to issue wildcard 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
 - `iodef`: Specifies a URL or email address for reporting certificate issue violations
 
 
-The `weight` field is optional and works the same as other record types.
-
 ### MX
 ### MX
 
 
 MX records support a `weight` similar to A records to indicate how often the particular
 MX records support a `weight` similar to A records to indicate how often the particular

+ 118 - 24
zones/reader.go

@@ -228,7 +228,7 @@ func setupZoneData(data map[string]interface{}, zone *Zone) {
 
 
 			//log.Printf("RECORDS %s TYPE-REC %T\n", Records, Records)
 			//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++ {
 			for i := 0; i < len(records[rType]); i++ {
 				//log.Printf("RT %T %#v\n", records[rType][i], records[rType][i])
 				//log.Printf("RT %T %#v\n", records[rType][i], records[rType][i])
@@ -474,33 +474,87 @@ func setupZoneData(data map[string]interface{}, zone *Zone) {
 					}
 					}
 
 
 				case dns.TypeCAA:
 				case dns.TypeCAA:
-					rec := records[rType][i].(map[string]interface{})
+					rec := records[rType][i]
 					
 					
 					var flag uint8 = 0
 					var flag uint8 = 0
 					var tag, value string
 					var tag, value string
 					
 					
-					if rec["flag"] != nil {
-						flag = uint8(typeutil.ToInt(rec["flag"]))
-					}
-					
-					if rec["tag"] != nil {
-						tag = rec["tag"].(string)
-					} else {
-						log.Printf("CAA record missing required 'tag' field for '%s' in '%s'\n", label.Label, zone.Origin)
-						continue
-					}
-					
-					if rec["value"] != nil {
-						value = rec["value"].(string)
-					} else {
-						log.Printf("CAA record missing required 'value' field for '%s' in '%s'\n", label.Label, zone.Origin)
+					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
 						continue
 					}
 					}
 					
 					
-					if rec["weight"] != nil {
-						record.Weight = typeutil.ToInt(rec["weight"])
-					}
-					
 					record.RR = &dns.CAA{
 					record.RR = &dns.CAA{
 						Hdr:   h,
 						Hdr:   h,
 						Flag:  flag,
 						Flag:  flag,
@@ -518,10 +572,17 @@ func setupZoneData(data map[string]interface{}, zone *Zone) {
 				}
 				}
 
 
 				label.Weight[dnsType] += record.Weight
 				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]})
+				}
 			}
 			}
 		}
 		}
 	}
 	}
@@ -595,3 +656,36 @@ func getStringWeight(rec []interface{}) (string, int) {
 
 
 	return str, weight
 	return str, weight
 }
 }
+
+// parseCAAText parses CAA record text format: "flag tag value"
+// Example: "0 issue ca.example.net" or "128 iodef mailto:[email protected]"
+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
+}

+ 128 - 0
zones/reader_test.go

@@ -258,3 +258,131 @@ func TestCAARecords(t *testing.T) {
 	assert.Equal(t, "mailto:[email protected]", iodef.Value, "Iodef CAA record value should be 'mailto:[email protected]'")
 	assert.Equal(t, "mailto:[email protected]", iodef.Value, "Iodef CAA record value should be 'mailto:[email protected]'")
 	assert.Equal(t, 100, iodefWeight, "Iodef CAA record weight should be 100")
 	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:[email protected]\"", 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:[email protected]", iodef.Value, "Iodef CAA record value should be 'mailto:[email protected]'")
+	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))
+	}
+}