package joker import ( "fmt" "net/url" "strconv" "strings" "github.com/StackExchange/dnscontrol/v4/models" "github.com/StackExchange/dnscontrol/v4/pkg/diff2" ) // GetZoneRecords gets the records of a zone and returns them in RecordConfig format. func (api *jokerProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) { params := url.Values{} params.Set("domain", domain) _, body, err := api.makeRequest("dns-zone-get", params) if err != nil { return nil, err } records, err := api.parseZoneRecords(domain, body) if err != nil { return nil, err } return records, nil } // parseZoneLine parses a zone file line while preserving quoted strings. func parseZoneLine(line string) []string { var parts []string var current strings.Builder inQuotes := false escaped := false for _, r := range line { if escaped { current.WriteRune(r) escaped = false continue } if r == '\\' { escaped = true current.WriteRune(r) continue } if r == '"' { inQuotes = !inQuotes current.WriteRune(r) continue } if !inQuotes && (r == ' ' || r == '\t') { // Skip multiple consecutive spaces if current.Len() > 0 { parts = append(parts, current.String()) current.Reset() } continue } current.WriteRune(r) } // Add the final part if any if current.Len() > 0 { parts = append(parts, current.String()) } return parts } // parseZoneRecords parses Joker zone format into RecordConfig format. func (api *jokerProvider) parseZoneRecords(domain, zoneData string) (models.Records, error) { var records models.Records lines := strings.Split(strings.TrimSpace(zoneData), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "$") { continue } // Parse the line while preserving quoted strings parts := parseZoneLine(line) if len(parts) < 4 { continue } label := parts[0] recordType := parts[1] priority := parts[2] // For TXT records, we need to handle quoted content specially var target string var ttl uint32 = 300 if recordType == "TXT" { // Use parseZoneLine to handle proper quoting if len(parts) >= 4 { // For TXT, the content is in parts[3] and may be quoted target = parts[3] // Remove surrounding quotes if present if strings.HasPrefix(target, "\"") && strings.HasSuffix(target, "\"") && len(target) > 1 { target = target[1 : len(target)-1] } // Properly handle escaped characters - reverse the generation order target = strings.ReplaceAll(target, "\\\"", "\"") target = strings.ReplaceAll(target, "\\\\", "\\") // Parse TTL if present if len(parts) >= 5 { if ttlParsed, err := strconv.ParseUint(parts[4], 10, 32); err == nil { ttl = uint32(ttlParsed) } } } } else { target = parts[3] // Default TTL if not specified in zone record if len(parts) >= 5 { if ttlParsed, err := strconv.ParseUint(parts[4], 10, 32); err == nil { ttl = uint32(ttlParsed) } } } // Convert @ to empty string for root domain if label == "@" { label = "" } rc := &models.RecordConfig{ TTL: ttl, } // Set the label and domain correctly rc.SetLabel(label, domain) // Handle different record types switch recordType { case "A", "AAAA": rc.Type = recordType if err := rc.SetTarget(target); err != nil { continue } case "CNAME": rc.Type = recordType // Ensure CNAME targets are fully qualified if !strings.HasSuffix(target, ".") { target = target + "." } if err := rc.SetTarget(target); err != nil { continue } case "NS": rc.Type = recordType // Ensure NS targets are fully qualified if !strings.HasSuffix(target, ".") { target = target + "." } if err := rc.SetTarget(target); err != nil { continue } case "TXT": rc.Type = recordType // TXT target is already extracted without quotes in the parsing above if err := rc.SetTarget(target); err != nil { continue } case "MX": rc.Type = recordType if prio, err := strconv.ParseUint(priority, 10, 16); err == nil { rc.MxPreference = uint16(prio) } // Ensure MX targets are fully qualified if !strings.HasSuffix(target, ".") { target = target + "." } if err := rc.SetTarget(target); err != nil { continue } case "SRV": rc.Type = recordType // SRV format: priority/weight target:port if strings.Contains(priority, "/") { priorityParts := strings.Split(priority, "/") if len(priorityParts) == 2 { if prio, err := strconv.ParseUint(priorityParts[0], 10, 16); err == nil { rc.SrvPriority = uint16(prio) } if weight, err := strconv.ParseUint(priorityParts[1], 10, 16); err == nil { rc.SrvWeight = uint16(weight) } } } if strings.Contains(target, ":") { targetParts := strings.Split(target, ":") if len(targetParts) == 2 { if port, err := strconv.ParseUint(targetParts[1], 10, 16); err == nil { rc.SrvPort = uint16(port) } srvTarget := targetParts[0] // Ensure SRV targets are fully qualified if !strings.HasSuffix(srvTarget, ".") { srvTarget = srvTarget + "." } if err := rc.SetTarget(srvTarget); err != nil { continue } } } case "CAA": rc.Type = recordType // CAA format: flags tag value [ttl] if len(parts) >= 4 { flags := priority // priority field contains flags for CAA tag := parts[3] // tag field // Value might be quoted value := "" if len(parts) >= 5 { value = parts[4] // Remove surrounding quotes if present if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") && len(value) > 1 { value = value[1 : len(value)-1] } // Parse TTL from the end if present (position 5) if len(parts) >= 6 { if ttlParsed, err := strconv.ParseUint(parts[5], 10, 32); err == nil { ttl = uint32(ttlParsed) } } } if flagsInt, err := strconv.ParseUint(flags, 10, 8); err == nil { rc.CaaFlag = uint8(flagsInt) } rc.CaaTag = tag if err := rc.SetTarget(value); err != nil { continue } } case "NAPTR": rc.Type = recordType // NAPTR format for Joker: order/preference replacement ttl 0 0 "flags" "service" "regex" if len(parts) >= 9 { // Parse order/preference from priority field (parts[2]) if strings.Contains(priority, "/") { priorityParts := strings.Split(priority, "/") if len(priorityParts) == 2 { if order, err := strconv.ParseUint(priorityParts[0], 10, 16); err == nil { rc.NaptrOrder = uint16(order) } if pref, err := strconv.ParseUint(priorityParts[1], 10, 16); err == nil { rc.NaptrPreference = uint16(pref) } } } // Replacement is in position 3 if len(parts) > 3 { target = parts[3] } // Parse TTL from position 4 if len(parts) >= 5 { if ttlParsed, err := strconv.ParseUint(parts[4], 10, 32); err == nil { ttl = uint32(ttlParsed) } } // Parse flags, service, and regex from positions 7, 8, 9 if len(parts) > 7 { rc.NaptrFlags = strings.Trim(parts[7], "\"") } if len(parts) > 8 { rc.NaptrService = strings.Trim(parts[8], "\"") } if len(parts) > 9 { rc.NaptrRegexp = strings.Trim(parts[9], "\"") } // Ensure NAPTR targets are fully qualified if they're not empty or "." if target != "" && target != "." && !strings.HasSuffix(target, ".") { target = target + "." } if err := rc.SetTarget(target); err != nil { continue } } default: // Skip unsupported record types continue } records = append(records, rc) } return records, nil } // fixTTLs enforces minimum TTL requirements for Joker provider. // NAPTR and SVC records can have TTL=0, others need >= 300. func fixTTLs(records models.Records) { for _, rc := range records { if rc.TTL != 0 && rc.TTL < 300 { if rc.Type != "NAPTR" && rc.Type != "SVC" { rc.TTL = 300 } } } } // GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records. func (api *jokerProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) { // Apply TTL fixes to desired records before comparison fixTTLs(dc.Records) result, err := diff2.ByZone(existingRecords, dc, nil) if err != nil { return nil, 0, err } if !result.HasChanges { return []*models.Correction{}, result.ActualChangeCount, nil } msg := fmt.Sprintf("Zone update for %s\n%s", dc.Name, strings.Join(result.Msgs, "\n")) correction := &models.Correction{ Msg: msg, F: func() error { return api.updateZoneRecords(dc.Name, result.DesiredPlus) }, } return []*models.Correction{correction}, result.ActualChangeCount, nil } // updateZoneRecords replaces the entire zone with new records. func (api *jokerProvider) updateZoneRecords(domain string, records models.Records) error { zoneData := api.recordsToZoneFormat(domain, records) // Joker API doesn't support empty zones (returns Status-Code: 2400) // If the zone would be empty, we need to skip the update if zoneData == "" { // Return success for empty zone updates since Joker maintains minimal DNS infrastructure // (SOA, NS records) automatically and doesn't allow completely empty zones return nil } params := url.Values{} params.Set("domain", domain) params.Set("zone", zoneData) _, _, err := api.makeRequest("dns-zone-put", params) return err } // recordsToZoneFormat converts RecordConfig records to Joker zone format. func (api *jokerProvider) recordsToZoneFormat(domain string, records models.Records) string { var lines []string for _, rc := range records { label := rc.Name if label == "" { label = "@" } // Joker format: