commit 00718515f833f36f70d9a38acba1ab15f0004bd0 Author: Steven Polley Date: Tue Nov 5 17:09:54 2024 -0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8495e50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +env.sh +cfcleaner +*.exe \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..110e3e8 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Clownflare Cleaner + +I personally use this tool to identify stale DNS records for stevenpolley.net. All it does is grab all your zones, then loop through each zone. For each zone, it grabs each record and loops through each record. For each record, it checks DNS query analytics and then provides a report showing records and their query metrics. This can then be used to manually identify stale records and then perhaps take manual action afterwards. It does not make any changes to Cloudflare directly and only requires read access. + +The entire project makes use of only the Go standard library, no third party dependencies at all. Also CF's own golang SDK is missing features to retrieve analytics for individual DNS records. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e22644f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.stevenpolley.net/steven/cfcleaner + +go 1.23.2 diff --git a/main.go b/main.go new file mode 100644 index 0000000..a17358d --- /dev/null +++ b/main.go @@ -0,0 +1,230 @@ +package main + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "sort" + "strconv" + "time" +) + +const apiURL = "https://api.cloudflare.com/client/v4" + +var apiKey string + +type Zone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Record struct { + ID string `json:"id"` + ZoneID string `json:"zone_id"` + ZoneName string `json:"zone_name"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + Proxiable bool `json:"proxiable"` + Proxied bool `json:"proxied"` + TTL int `json:"ttl"` + Comment string `json:"comment"` + Tags []string `json:"tags"` + CreatedOn time.Time `json:"created_on"` + ModifiedOn time.Time `json:"modified_on"` + NumberQueries int +} + +type ZoneResult struct { + Result []Zone `json:"result"` +} + +type RecordResult struct { + Result []Record `json:"result"` +} + +type Analytic struct { + Result struct { + Rows int `json:"rows"` + Data []struct { + Metrics []int `json:"metrics"` + } `json:"data"` + DataLag int `json:"data_lag"` + Min struct { + } `json:"min"` + Max struct { + } `json:"max"` + Totals struct { + QueryCount int `json:"queryCount"` + } `json:"totals"` + Query struct { + Dimensions []any `json:"dimensions"` + Metrics []string `json:"metrics"` + Since time.Time `json:"since"` + Until time.Time `json:"until"` + Limit int `json:"limit"` + } `json:"query"` + } `json:"result"` +} + +func main() { + apiKey = os.Getenv("cfapikey") + + zones, err := getZones() + if err != nil { + log.Fatalf("failed to get zones: %v", err) + } + + outRows := make([]Record, 0) + + for _, zone := range zones { + log.Printf("processing zone '%s' with ID '%s'", zone.Name, zone.ID) + + records, err := getRecords(zone) + if err != nil { + log.Printf("failed to get records for zone '%s' with ID '%s': %v", zone.Name, zone.ID, err) + } + + for _, record := range records { + analytic, err := getRecordAnalytic(zone, record) + if err != nil { + log.Printf("failed to get record report for record '%s' in zone '%s' with ID '%s': %v", record.Name, zone.Name, zone.ID, err) + continue + } + record.NumberQueries = analytic.Result.Totals.QueryCount + outRows = append(outRows, record) + } + } + + sort.Slice(outRows, func(i, j int) bool { + return outRows[i].NumberQueries < outRows[j].NumberQueries + }) + + buf := bytes.NewBuffer(nil) + writer := csv.NewWriter(buf) + writer.Write([]string{"Name", "Type", "NumberQueries", "CreatedOn", "ModifiedOn", "Comment", "Content"}) + + for _, row := range outRows { + writer.Write([]string{row.Name, row.Type, strconv.Itoa(row.NumberQueries), row.CreatedOn.Format(time.RFC3339), row.ModifiedOn.Format(time.RFC3339), row.Comment, row.Content}) + } + writer.Flush() + + b, err := io.ReadAll(buf) + if err != nil { + log.Fatalf("failed to read bytes from output buffer: %v", err) + } + + fmt.Println(string(b)) + +} + +func getZones() ([]Zone, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/zones", apiURL), nil) + if err != nil { + return nil, fmt.Errorf("failed to create new http request: %v", err) + } + req.Header.Add("Content-Type", "application/json") + //req.Header.Add("X-Auth-Email", apiKey) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", apiKey)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute http request: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http response has status '%d' but expected '%d'", resp.StatusCode, http.StatusOK) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read http response body: %v", err) + } + zoneResult := &ZoneResult{} + + err = json.Unmarshal(b, zoneResult) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response body to struct: %v", err) + } + + return zoneResult.Result, nil +} + +func getRecords(zone Zone) ([]Record, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/zones/%s/dns_records", apiURL, zone.ID), nil) + if err != nil { + return nil, fmt.Errorf("failed to create new http request: %v", err) + } + req.Header.Add("Content-Type", "application/json") + //req.Header.Add("X-Auth-Email", apiKey) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", apiKey)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute http request: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http response has status '%d' but expected '%d'", resp.StatusCode, http.StatusOK) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read http response body: %v", err) + } + recordResult := &RecordResult{} + + err = json.Unmarshal(b, recordResult) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response body to struct: %v", err) + } + + return recordResult.Result, nil +} + +func getRecordAnalytic(zone Zone, record Record) (*Analytic, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/zones/%s/dns_analytics/report", apiURL, zone.ID), nil) + if err != nil { + return nil, fmt.Errorf("failed to create new http request: %v", err) + } + req.Header.Add("Content-Type", "application/json") + //req.Header.Add("X-Auth-Email", apiKey) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", apiKey)) + + q := req.URL.Query() + q.Add("filters", fmt.Sprintf("queryName==%s", record.Name)) + //q.Add("filters", fmt.Sprintf("queryName==%s,queryType==%s", record.Name, record.Type)) + req.URL.RawQuery = q.Encode() + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute http request: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http response has status '%d' but expected '%d'", resp.StatusCode, http.StatusOK) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read http response body: %v", err) + } + + analytic := &Analytic{} + + err = json.Unmarshal(b, analytic) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response body to struct: %v", err) + } + + return analytic, nil + +}