diff --git a/cloudflare.go b/cloudflare.go index cfb45d5..f3e4e22 100644 --- a/cloudflare.go +++ b/cloudflare.go @@ -2,11 +2,17 @@ package main import ( "context" + "encoding/json" + "fmt" + "net/http" "slices" "strings" + "github.com/spf13/viper" + cf "github.com/cloudflare/cloudflare-go/v4" cfaccounts "github.com/cloudflare/cloudflare-go/v4/accounts" + cfcustomhostnames "github.com/cloudflare/cloudflare-go/v4/custom_hostnames" cfload_balancers "github.com/cloudflare/cloudflare-go/v4/load_balancers" cfpagination "github.com/cloudflare/cloudflare-go/v4/packages/pagination" cfrulesets "github.com/cloudflare/cloudflare-go/v4/rulesets" @@ -982,3 +988,79 @@ func filterNonFreePlanZones(zones []cfzones.Zone) (filteredZones []cfzones.Zone) } return } + +type customHostnameQuota struct { + Allocated int `json:"allocated"` + Used int `json:"used"` +} + +func fetchCustomHostnamesCount(zoneID string) (int, error) { + ctx, cancel := context.WithTimeout(context.Background(), cftimeout) + defer cancel() + + // Use the SDK to fetch custom hostnames count + // We only need the count, so we request 1 item per page + rep, err := cfclient.CustomHostnames.List(ctx, cfcustomhostnames.CustomHostnameListParams{ + ZoneID: cf.F(zoneID), + PerPage: cf.F(1.0), + }) + if err != nil { + return 0, fmt.Errorf("failed to fetch custom hostnames: %w", err) + } + + // Extract total_count from the result_info extra fields + totalCountRaw := rep.ResultInfo.JSON.ExtraFields["total_count"].Raw() + + var totalCount int + if err := json.Unmarshal([]byte(totalCountRaw), &totalCount); err != nil { + return 0, fmt.Errorf("failed to parse total_count: %w", err) + } + + return totalCount, nil +} + +func fetchCustomHostnamesQuota(zoneID string) (*customHostnameQuota, error) { + ctx, cancel := context.WithTimeout(context.Background(), cftimeout) + defer cancel() + + // Use direct HTTP call since this is an undocumented endpoint not in the SDK + url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/custom_hostnames/quota", zoneID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add authentication header + apiToken := viper.GetString("cf_api_token") + if apiToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiToken)) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + var response struct { + Result customHostnameQuota `json:"result"` + Success bool `json:"success"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !response.Success { + if len(response.Errors) > 0 { + return nil, fmt.Errorf("API error: %s", response.Errors[0].Message) + } + return nil, fmt.Errorf("API returned success=false") + } + + return &response.Result, nil +} diff --git a/main.go b/main.go index 28c7375..9fff1e0 100644 --- a/main.go +++ b/main.go @@ -126,6 +126,7 @@ func fetchMetrics(accounts []cfaccounts.Account, zones []cfzones.Zone, metrics M wg.Go(func() { fetchZoneColocationAnalytics(metrics, zonesChunk) }) wg.Go(func() { fetchLoadBalancerAnalytics(metrics, zonesChunk) }) wg.Go(func() { fetchLogpushAnalyticsForZone(metrics, zonesChunk) }) + wg.Go(func() { fetchCustomHostnamesMetrics(metrics, zonesChunk) }) } wg.Wait() diff --git a/prometheus.go b/prometheus.go index 559bf9d..2fc11fa 100644 --- a/prometheus.go +++ b/prometheus.go @@ -58,6 +58,9 @@ const ( r2StorageTotalMetricName MetricName = "cloudflare_r2_storage_total_bytes" r2StorageMetricName MetricName = "cloudflare_r2_storage_bytes" r2OperationMetricName MetricName = "cloudflare_r2_operation_count" + zoneCustomHostnamesTotalMetricName MetricName = "cloudflare_zone_custom_hostnames_total" + zoneCustomHostnamesQuotaAllocatedMetricName MetricName = "cloudflare_zone_custom_hostnames_quota_allocated" + zoneCustomHostnamesQuotaUsedMetricName MetricName = "cloudflare_zone_custom_hostnames_quota_used" ) type MetricsMap map[MetricName]prometheus.Collector @@ -289,6 +292,21 @@ var ( Help: "Number of operations performed by R2", }, []string{"account", "bucket", "operation"}) + zoneCustomHostnamesTotal = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: zoneCustomHostnamesTotalMetricName.String(), + Help: "Total number of custom hostnames configured for the zone", + }, []string{"zone", "account"}) + + zoneCustomHostnamesQuotaAllocated = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: zoneCustomHostnamesQuotaAllocatedMetricName.String(), + Help: "Allocated quota for custom hostnames for the zone", + }, []string{"zone", "account"}) + + zoneCustomHostnamesQuotaUsed = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: zoneCustomHostnamesQuotaUsedMetricName.String(), + Help: "Used custom hostnames quota for the zone", + }, []string{"zone", "account"}) + metricsMap = MetricsMap{} ) @@ -330,6 +348,9 @@ func init() { metricsMap[r2StorageTotalMetricName] = r2StorageTotal metricsMap[r2StorageMetricName] = r2Storage metricsMap[r2OperationMetricName] = r2Operation + metricsMap[zoneCustomHostnamesTotalMetricName] = zoneCustomHostnamesTotal + metricsMap[zoneCustomHostnamesQuotaAllocatedMetricName] = zoneCustomHostnamesQuotaAllocated + metricsMap[zoneCustomHostnamesQuotaUsedMetricName] = zoneCustomHostnamesQuotaUsed } func buildDeniedMetricsSet(metricsDenylist []string) (MetricsMap, error) { @@ -840,3 +861,40 @@ func addLoadBalancingRequestsAdaptive(z *lbResp, name string, account string) { } } } + +func fetchCustomHostnamesMetrics(metrics MetricsMap, zones []cfzones.Zone) { + skipTotal := shouldSkip(metrics, zoneCustomHostnamesTotalMetricName) + skipQuota := shouldSkip(metrics, zoneCustomHostnamesQuotaAllocatedMetricName, zoneCustomHostnamesQuotaUsedMetricName) + + if skipTotal && skipQuota { + return + } + + for _, zone := range zones { + labels := prometheus.Labels{ + "zone": zone.Name, + "account": zone.Account.Name, + } + + // Fetch count if needed + if !skipTotal { + count, err := fetchCustomHostnamesCount(zone.ID) + if err != nil { + log.Errorf("failed to fetch custom hostnames count for zone %s: %v", zone.Name, err) + } else { + zoneCustomHostnamesTotal.With(labels).Set(float64(count)) + } + } + + // Fetch quota if needed + if !skipQuota { + quota, err := fetchCustomHostnamesQuota(zone.ID) + if err != nil { + log.Errorf("failed to fetch custom hostnames quota for zone %s: %v", zone.Name, err) + } else { + zoneCustomHostnamesQuotaAllocated.With(labels).Set(float64(quota.Allocated)) + zoneCustomHostnamesQuotaUsed.With(labels).Set(float64(quota.Used)) + } + } + } +}