Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ The exporter can be configured using env variables or command flags.
| `ZONE_<NAME>` | `DEPRECATED since 0.0.5` (optional) Zone ID. Add zones you want to scrape by adding env vars in this format. You can find the zone ids in Cloudflare dashboards. |
| `LOG_LEVEL` | Set loglevel. Options are error, warn, info, debug. default `error` |

### Host whitelist

Set the chart value `hostWhitelist.configMapName` to an externally managed ConfigMap in the release namespace. The chart mounts only its `hosts.yaml` key read-only at `/etc/cloudflare-exporter/hosts.yaml`; it never creates the ConfigMap. See `examples/host-whitelist-configmap.yaml`.

The file is reread before each scrape. It must be exactly one mapping with one `hosts` list of strings. YAML and equivalent JSON are accepted; duplicate keys, merge keys, scalar/null or multi-document input, extra keys, and invalid entries are rejected. Matching is exact and unnormalized. Before any valid whitelist is loaded, a missing or unmounted ConfigMap/file scrapes all hosts. A valid `hosts: []` also explicitly scrapes all hosts. Invalid updates retain the last valid list; after a valid non-empty whitelist has loaded, an unreadable or missing file retains that last valid whitelist. Covered paths are request analytics, firewall host analytics, colocation analytics, and edge-errors-by-path; non-host metrics are unchanged.

Corresponding flags:

```
Expand Down
3 changes: 3 additions & 0 deletions charts/cloudflare-exporter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The following table lists the configurable parameters of the Cloudflare-exporter
| `image.tag` | | `"0.0.2"` |
| `env` | | `[]` |
| `secretRef` | The name of a secret with environment variables | `""` |
| `hostWhitelist.configMapName` | External ConfigMap containing the `hosts.yaml` whitelist key | `""` |
| `imagePullSecrets` | | `[]` |
| `nameOverride` | | `""` |
| `fullnameOverride` | | `""` |
Expand Down Expand Up @@ -50,6 +51,8 @@ The following table lists the configurable parameters of the Cloudflare-exporter
| `tolerations` | | `[]` |
| `affinity` | | `{}` |

When configured, the operator must create `hostWhitelist.configMapName` in the release namespace. The chart projects only `hosts.yaml` read-only into `/etc/cloudflare-exporter`; it does not create or own a ConfigMap. The runtime default path is `/etc/cloudflare-exporter/hosts.yaml`. Before any valid whitelist is loaded, a missing or unmounted ConfigMap means scrape all hosts. A valid `hosts: []` also means scrape all hosts. After a valid non-empty whitelist is loaded, an unreadable or missing file retains the last valid whitelist. See `examples/host-whitelist-configmap.yaml`, `ci/host-whitelist-values.yaml`, and `ci/assert-host-whitelist-render.sh`.



## Contributing and reporting issues
Expand Down
32 changes: 32 additions & 0 deletions charts/cloudflare-exporter/ci/assert-host-whitelist-render.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/bin/sh
set -eu

chart_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT

helm template test "$chart_dir" >"$tmp_dir/default.yaml"
helm template test "$chart_dir" -f "$chart_dir/ci/host-whitelist-values.yaml" >"$tmp_dir/enabled.yaml"

count() { grep -cF -- "$1" "$2" || true; }
assert_count() {
[ "$(count "$1" "$2")" -eq "$3" ] || {
echo "expected $3 occurrences of $1 in $2" >&2
exit 1
}
}

assert_count 'name: host-whitelist' "$tmp_dir/default.yaml" 0
assert_count '/etc/cloudflare-exporter/hosts.yaml' "$tmp_dir/default.yaml" 0
assert_count 'configMap:' "$tmp_dir/default.yaml" 0
assert_count 'name: host-whitelist' "$tmp_dir/enabled.yaml" 2
assert_count 'volumes:' "$tmp_dir/enabled.yaml" 1
assert_count 'volumeMounts:' "$tmp_dir/enabled.yaml" 1
assert_count 'mountPath: /etc/cloudflare-exporter' "$tmp_dir/enabled.yaml" 1
assert_count 'readOnly: true' "$tmp_dir/enabled.yaml" 1
assert_count 'configMap:' "$tmp_dir/enabled.yaml" 1
assert_count 'name: cloudflare-exporter-hosts' "$tmp_dir/enabled.yaml" 1
assert_count 'key: hosts.yaml' "$tmp_dir/enabled.yaml" 1
assert_count 'path: hosts.yaml' "$tmp_dir/enabled.yaml" 1
assert_count 'mode: 0444' "$tmp_dir/enabled.yaml" 1
assert_count 'kind: ConfigMap' "$tmp_dir/enabled.yaml" 0
2 changes: 2 additions & 0 deletions charts/cloudflare-exporter/ci/host-whitelist-values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
hostWhitelist:
configMapName: cloudflare-exporter-hosts
18 changes: 18 additions & 0 deletions charts/cloudflare-exporter/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ spec:
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
serviceAccountName: {{ include "cloudflare-exporter.serviceAccountName" . }}
{{- if .Values.hostWhitelist.configMapName }}
volumes:
- name: host-whitelist
projected:
sources:
- configMap:
name: {{ .Values.hostWhitelist.configMapName }}
items:
- key: hosts.yaml
path: hosts.yaml
mode: 0444
{{- end }}
containers:
- name: {{ .Chart.Name }}
securityContext:
Expand All @@ -45,6 +57,12 @@ spec:
{{- toYaml .Values.resources | nindent 12 }}
env:
{{- toYaml .Values.env | nindent 12 }}
{{- if .Values.hostWhitelist.configMapName }}
volumeMounts:
- name: host-whitelist
mountPath: /etc/cloudflare-exporter
readOnly: true
{{- end }}
{{- if .Values.secretRef }}
envFrom:
- secretRef:
Expand Down
15 changes: 15 additions & 0 deletions charts/cloudflare-exporter/values.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"hostWhitelist": {
"type": "object",
"properties": {
"configMapName": { "type": "string" }
},
"required": ["configMapName"],
"additionalProperties": false
}
},
"required": ["hostWhitelist"]
}
2 changes: 2 additions & 0 deletions charts/cloudflare-exporter/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ image:
# tag: latest
env: []
secretRef: ""
hostWhitelist:
configMapName: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
Expand Down
9 changes: 9 additions & 0 deletions examples/host-whitelist-configmap.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: cloudflare-exporter-hosts
data:
hosts.yaml: |
hosts:
- www.example.com
- api.example.com
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand Down Expand Up @@ -46,5 +48,4 @@ require (
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
202 changes: 202 additions & 0 deletions host_whitelist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package main

import (
"bytes"
"encoding/json"
"errors"
"io"
"os"
"sync"

"github.com/prometheus/client_golang/prometheus"
"gopkg.in/yaml.v3"
)

const hostWhitelistPath = "/etc/cloudflare-exporter/hosts.yaml"

type hostWhitelist struct {
enabled bool
allowed map[string]struct{}
}

func emptyHostWhitelist() hostWhitelist {
return hostWhitelist{allowed: map[string]struct{}{}}
}

func (w hostWhitelist) Allows(host string) bool {
if !w.enabled || len(w.allowed) == 0 {
return true
}
_, ok := w.allowed[host]
return ok
}

func readHostWhitelist(path string) (hostWhitelist, string, error) {
b, err := os.ReadFile(path)
if err != nil {
return emptyHostWhitelist(), "missing/unreadable", err
}
if len(b) == 0 {
return emptyHostWhitelist(), "empty", errors.New("empty whitelist")
}

decoder := yaml.NewDecoder(bytes.NewReader(b))
var document yaml.Node
if err := decoder.Decode(&document); err != nil {
return emptyHostWhitelist(), "malformed", errors.New("malformed whitelist")
}
var extra yaml.Node
if err := decoder.Decode(&extra); err != io.EOF {
return emptyHostWhitelist(), "malformed", errors.New("multiple whitelist documents")
}

if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
return emptyHostWhitelist(), "invalid schema", errors.New("whitelist must be one mapping")
}
root := document.Content[0]
if len(root.Content) != 2 {
return emptyHostWhitelist(), "invalid schema", errors.New("whitelist must contain only hosts")
}
var hosts *yaml.Node
for i := 0; i < len(root.Content); i += 2 {
key, value := root.Content[i], root.Content[i+1]
if key.Value == "<<" {
return emptyHostWhitelist(), "invalid schema", errors.New("merge keys are not allowed")
}
if key.Value != "hosts" || hosts != nil {
return emptyHostWhitelist(), "invalid schema", errors.New("invalid whitelist key")
}
hosts = value
}
if hosts == nil || hosts.Kind != yaml.SequenceNode {
return emptyHostWhitelist(), "invalid schema", errors.New("hosts must be a list")
}

allowed := make(map[string]struct{}, len(hosts.Content))
for _, host := range hosts.Content {
if host.Kind != yaml.ScalarNode || host.Tag != "!!str" {
return emptyHostWhitelist(), "invalid schema", errors.New("hosts must contain strings")
}
allowed[host.Value] = struct{}{}
}
return hostWhitelist{enabled: true, allowed: allowed}, "", nil
}

func loadHostWhitelist(path string, previous hostWhitelist) hostWhitelist {
if path == "" {
return emptyHostWhitelist()
}
next, category, err := readHostWhitelist(path)
if err != nil {
log.WithField("path", path).WithField("category", category).Warn("host whitelist reload failed")
if previous.enabled {
return previous
}
return emptyHostWhitelist()
}
if !sameHostWhitelist(previous, next) {
log.WithField("path", path).WithField("hosts", len(next.allowed)).Info("host whitelist replaced")
}
return next
}

func sameHostWhitelist(a, b hostWhitelist) bool {
if a.enabled != b.enabled || len(a.allowed) != len(b.allowed) {
return false
}
for host := range a.allowed {
if _, ok := b.allowed[host]; !ok {
return false
}
}
return true
}

type hostSeriesVector interface {
DeleteLabelValues(labelValues ...string) bool
}

type hostSeriesFamily struct {
vector hostSeriesVector
labels []string
emitted map[string]map[string]struct{}
observed map[string]map[string]struct{}
}

type hostSeriesRegistry struct {
mu sync.Mutex
families []hostSeriesFamily
}

func newHostSeriesRegistry() *hostSeriesRegistry { return &hostSeriesRegistry{} }

var hostSeriesRegistryState = newHostSeriesRegistry()

func (r *hostSeriesRegistry) Register(vector hostSeriesVector, labels ...string) {
r.mu.Lock()
defer r.mu.Unlock()
r.families = append(r.families, hostSeriesFamily{
vector: vector,
labels: labels,
emitted: map[string]map[string]struct{}{},
observed: map[string]map[string]struct{}{},
})
}

func (r *hostSeriesRegistry) Observe(vector hostSeriesVector, labels prometheus.Labels) {
r.mu.Lock()
defer r.mu.Unlock()
for i := range r.families {
if r.families[i].vector != vector {
continue
}
values := make([]string, len(r.families[i].labels))
for j, label := range r.families[i].labels {
values[j] = labels[label]
}
host := labels["host"]
key, _ := json.Marshal(values)
if r.families[i].observed[host] == nil {
r.families[i].observed[host] = map[string]struct{}{}
}
r.families[i].observed[host][string(key)] = struct{}{}
return
}
}

func deleteHostSeriesTuples(family *hostSeriesFamily, tuples map[string]struct{}) {
for key := range tuples {
var values []string
if err := json.Unmarshal([]byte(key), &values); err == nil {
family.vector.DeleteLabelValues(values...)
}
}
}

func (r *hostSeriesRegistry) ResetScrape() {
r.mu.Lock()
defer r.mu.Unlock()
for i := range r.families {
r.families[i].observed = map[string]map[string]struct{}{}
}
}

func (r *hostSeriesRegistry) Reconcile() {
r.mu.Lock()
defer r.mu.Unlock()
for i := range r.families {
family := &r.families[i]
for host, emitted := range family.emitted {
observed := family.observed[host]
stale := make(map[string]struct{})
for key := range emitted {
if _, ok := observed[key]; !ok {
stale[key] = struct{}{}
}
}
deleteHostSeriesTuples(family, stale)
}
family.emitted = family.observed
family.observed = map[string]map[string]struct{}{}
}
}
Loading