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.
 
-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" }
     { "flag": 0, "tag": "issuewild", "value": "ca.example.net" }
     { "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
 - `issuewild`: Authorizes the specified CA to issue wildcard certificates for this domain  
 - `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 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)
 
-			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])
@@ -474,33 +474,87 @@ func setupZoneData(data map[string]interface{}, zone *Zone) {
 					}
 
 				case dns.TypeCAA:
-					rec := records[rType][i].(map[string]interface{})
+					rec := records[rType][i]
 					
 					var flag uint8 = 0
 					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
 					}
 					
-					if rec["weight"] != nil {
-						record.Weight = typeutil.ToInt(rec["weight"])
-					}
-					
 					record.RR = &dns.CAA{
 						Hdr:   h,
 						Flag:  flag,
@@ -518,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]})
+				}
 			}
 		}
 	}
@@ -595,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:[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, 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))
+	}
+}