Skip to content

Commit a2ea119

Browse files
authored
Stop gateway controller managing per-hostname DNS records (#10)
* refactor: stop gateway controller managing DNS records * test: remove brittle DNS policy source guard
1 parent a4f0089 commit a2ea119

2 files changed

Lines changed: 5 additions & 173 deletions

File tree

README.md

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
A Kubernetes [Gateway API](https://gateway-api.sigs.k8s.io/) controller that routes cluster traffic through [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) — no public IPs or open firewall ports required.
88

9-
**How it works:** The controller watches GatewayClass, Gateway, and HTTPRoute resources. For each Gateway it creates a Cloudflare Tunnel and deploys a [cloudflared](https://github.com/cloudflare/cloudflared) client. HTTPRoutes are translated into tunnel ingress rules and DNS CNAME records pointing to the tunnel.
9+
**How it works:** The controller watches GatewayClass, Gateway, and HTTPRoute resources. For each Gateway it creates a Cloudflare Tunnel and deploys a [cloudflared](https://github.com/cloudflare/cloudflared) client. HTTPRoutes are translated into tunnel ingress rules. Hostname DNS, custom domains, and public route publication are owned by the platform routing layer.
1010

1111
**Conformance:** This controller passes the official [Gateway API conformance tests](https://gateway-api.sigs.k8s.io/concepts/conformance/) for the **GatewayHTTP** profile against Gateway API **v1.4.1**. Conformance tests run on every push in CI.
1212

@@ -32,7 +32,7 @@ kubectl apply -k github.com/kubernetes-sigs/gateway-api//config/crd?ref=v1.4.1
3232
kubectl apply -k github.com/cnap-tech/cloudflare-kubernetes-gateway//config/default?ref=main
3333
```
3434

35-
3. [Find your Cloudflare account ID](https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/) and [create an API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) with **Cloudflare Tunnel Edit** and **Zone DNS Edit** permissions.
35+
3. [Find your Cloudflare account ID](https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/) and [create an API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) with **Cloudflare Tunnel Edit** permissions. The controller does not require Zone DNS permissions.
3636

3737
4. Create the credentials Secret:
3838

@@ -102,7 +102,7 @@ kubectl apply -k github.com/cnap-tech/cloudflare-kubernetes-gateway//config/prom
102102
- **HTTPRoute** Service backendRefs (without filtering or weighting)
103103
- **Gateway** lifecycle management — tunnel creation, cloudflared deployment, cleanup on deletion
104104
- **GatewayClass** validation with Cloudflare API token verification
105-
- **DNS management**automatic CNAME record creation and cleanup with metadata tags
105+
- **Tunnel endpoint management**tunnel creation and ingress-rule reconciliation without per-hostname DNS writes
106106
- **Two config modes** — remote (Cloudflare dashboard) or local (ConfigMap-based)
107107
- **API proxy support** — route API calls through a custom base URL
108108

@@ -123,14 +123,9 @@ The controller reads configuration from the Secret referenced by the GatewayClas
123123

124124
**Local**: Ingress rules are written to a ConfigMap on the cluster. cloudflared reads from a mounted config file. The controller triggers a rolling restart when the config changes. Faster updates with no polling delay — useful for automated or high-frequency deployments.
125125

126-
### DNS Record Metadata
126+
### DNS and Public Routing Ownership
127127

128-
DNS records created by the controller include metadata for identification and filtering:
129-
130-
- **Tags**: `managed-by:cnap-gateway`, `tunnel-id:<id>`, `gateway:<namespace>/<name>`
131-
- **Comment**: Human-readable description with Kubernetes context
132-
133-
Stale DNS records are automatically cleaned up using tag-based filtering when hostnames are removed from routes.
128+
The controller does not create or clean up per-hostname DNS records. It owns the cluster-local tunnel endpoint and Cloudflare Tunnel ingress configuration only. Public hostnames, custom-domain validation, dispatcher KV entries, and stale-route tombstones are reconciled by the platform routing service.
134129

135130
## Standalone cloudflared
136131

internal/controller/httproute_controller.go

Lines changed: 0 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ import (
1010
"time"
1111

1212
"github.com/cloudflare/cloudflare-go/v2"
13-
"github.com/cloudflare/cloudflare-go/v2/dns"
1413
"github.com/cloudflare/cloudflare-go/v2/zero_trust"
15-
"github.com/cloudflare/cloudflare-go/v2/zones"
1614
appsv1 "k8s.io/api/apps/v1"
1715
corev1 "k8s.io/api/core/v1"
1816
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -227,14 +225,6 @@ func (r *HTTPRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
227225
}
228226
tunnel := tunnels.Result[0]
229227

230-
// Collect all desired hostnames from the current ingress rules
231-
desiredHostnames := map[string]bool{}
232-
for _, rule := range ingress {
233-
if rule.Hostname.Value != "" {
234-
desiredHostnames[rule.Hostname.Value] = true
235-
}
236-
}
237-
238228
// Update tunnel configuration based on config mode
239229
if cfg.ConfigMode == ConfigModeLocal {
240230
// Local mode: write ingress rules to a ConfigMap
@@ -259,81 +249,6 @@ func (r *HTTPRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
259249
}
260250

261251
log.Info("Updated Tunnel configuration", "mode", cfg.ConfigMode, "ingress", ingress)
262-
263-
tunnelContent := fmt.Sprintf("%s.cfargotunnel.com", tunnel.ID)
264-
265-
// Tags for filtering and organizational metadata.
266-
// Tags are key:value strings visible in the Cloudflare dashboard.
267-
managedByTag := "managed-by:cnap-gateway"
268-
tunnelTag := fmt.Sprintf("tunnel-id:%s", tunnel.ID)
269-
gatewayTag := fmt.Sprintf("gateway:%s/%s", gateway.Namespace, gateway.Name)
270-
271-
// Create or update DNS records for all desired hostnames
272-
for hostname := range desiredHostnames {
273-
zoneID, err := FindZoneID(hostname, ctx, api, account)
274-
if err != nil {
275-
return ctrl.Result{}, err
276-
}
277-
278-
// Descriptive comment for the CF dashboard — shows K8s origin
279-
comment := fmt.Sprintf("Routed via Cloudflare Tunnel to Gateway %s/%s [%s]",
280-
gateway.Namespace, gateway.Name, controllerName)
281-
282-
tags := []dns.RecordTagsParam{
283-
managedByTag,
284-
tunnelTag,
285-
gatewayTag,
286-
}
287-
288-
records, _ := api.DNS.Records.List(ctx, dns.RecordListParams{
289-
ZoneID: cloudflare.String(zoneID),
290-
Proxied: cloudflare.Bool(true),
291-
Type: cloudflare.F[dns.RecordListParamsType]("CNAME"),
292-
Name: cloudflare.String(hostname),
293-
})
294-
if len(records.Result) == 0 {
295-
_, err := api.DNS.Records.New(ctx, dns.RecordNewParams{
296-
ZoneID: cloudflare.String(zoneID),
297-
Record: dns.CNAMERecordParam{
298-
Proxied: cloudflare.Bool(true),
299-
Type: cloudflare.F[dns.CNAMERecordType]("CNAME"),
300-
Name: cloudflare.String(hostname),
301-
Content: cloudflare.F[interface{}](tunnelContent),
302-
Comment: cloudflare.String(comment),
303-
Tags: cloudflare.F(tags),
304-
},
305-
})
306-
if err != nil {
307-
log.Error(err, "Failed to create DNS record", "hostname", hostname)
308-
return ctrl.Result{}, err
309-
}
310-
} else {
311-
_, err := api.DNS.Records.Update(ctx, records.Result[0].ID, dns.RecordUpdateParams{
312-
ZoneID: cloudflare.String(zoneID),
313-
Record: dns.CNAMERecordParam{
314-
Proxied: cloudflare.Bool(true),
315-
Type: cloudflare.F[dns.CNAMERecordType]("CNAME"),
316-
Name: cloudflare.String(hostname),
317-
Content: cloudflare.F[interface{}](tunnelContent),
318-
Comment: cloudflare.String(comment),
319-
Tags: cloudflare.F(tags),
320-
},
321-
})
322-
if err != nil {
323-
log.Error(err, "Failed to update DNS record", "hostname", hostname)
324-
return ctrl.Result{}, err
325-
}
326-
}
327-
}
328-
329-
// Clean up stale DNS records using tag-based filtering.
330-
// Only scans zones that have records tagged with our tunnel ID.
331-
if err := r.cleanupStaleDnsRecords(ctx, api, account, tunnelTag, desiredHostnames); err != nil {
332-
log.Error(err, "Failed to cleanup stale DNS records")
333-
// Non-fatal: tunnel config is already updated, DNS cleanup is best-effort
334-
}
335-
336-
log.Info("Updated DNS records", "desired", desiredHostnames)
337252
}
338253

339254
return ctrl.Result{}, nil
@@ -437,81 +352,3 @@ func sortIngressByPathSpecificity(ingress []zero_trust.TunnelConfigurationUpdate
437352
return pathI < pathJ
438353
})
439354
}
440-
441-
// cleanupStaleDnsRecords removes DNS records that are managed by this controller
442-
// and belong to this tunnel, but are no longer in the desired hostname set.
443-
// Uses tag-based filtering for efficient lookup instead of scanning all records.
444-
func (r *HTTPRouteReconciler) cleanupStaleDnsRecords(
445-
ctx context.Context,
446-
api *cloudflare.Client,
447-
accountID string,
448-
tunnelTag string,
449-
desiredHostnames map[string]bool,
450-
) error {
451-
log := log.FromContext(ctx)
452-
453-
// List all zones in the account
454-
zoneList, err := api.Zones.List(ctx, zones.ZoneListParams{
455-
Account: cloudflare.F(zones.ZoneListParamsAccount{ID: cloudflare.String(accountID)}),
456-
Status: cloudflare.F(zones.ZoneListParamsStatusActive),
457-
})
458-
if err != nil {
459-
return fmt.Errorf("failed to list zones: %w", err)
460-
}
461-
462-
for _, zone := range zoneList.Result {
463-
// Use tag-based filtering to find only records managed by us for this tunnel.
464-
// This is much more efficient than scanning all CNAME records.
465-
records, err := api.DNS.Records.List(ctx, dns.RecordListParams{
466-
ZoneID: cloudflare.String(zone.ID),
467-
Type: cloudflare.F[dns.RecordListParamsType]("CNAME"),
468-
Tag: cloudflare.F(dns.RecordListParamsTag{
469-
Exact: cloudflare.String(tunnelTag),
470-
}),
471-
TagMatch: cloudflare.F[dns.RecordListParamsTagMatch]("all"),
472-
})
473-
if err != nil {
474-
log.Error(err, "Failed to list DNS records for zone", "zone", zone.Name)
475-
continue
476-
}
477-
478-
for _, record := range records.Result {
479-
// Only delete records not in the desired set
480-
if desiredHostnames[record.Name] {
481-
continue
482-
}
483-
484-
log.Info("Deleting stale DNS record", "hostname", record.Name, "zone", zone.Name)
485-
_, err := api.DNS.Records.Delete(ctx, record.ID, dns.RecordDeleteParams{
486-
ZoneID: cloudflare.String(zone.ID),
487-
})
488-
if err != nil {
489-
log.Error(err, "Failed to delete stale DNS record", "hostname", record.Name)
490-
}
491-
}
492-
}
493-
494-
return nil
495-
}
496-
497-
func FindZoneID(hostname string, ctx context.Context, api *cloudflare.Client, accountID string) (string, error) {
498-
log := log.FromContext(ctx)
499-
for parts := range len(strings.Split(hostname, ".")) {
500-
zoneName := strings.Join(strings.Split(hostname, ".")[parts:], ".")
501-
zones, err := api.Zones.List(ctx, zones.ZoneListParams{
502-
Account: cloudflare.F(zones.ZoneListParamsAccount{ID: cloudflare.String(accountID)}),
503-
Name: cloudflare.String(zoneName),
504-
Status: cloudflare.F(zones.ZoneListParamsStatusActive),
505-
})
506-
if err != nil {
507-
log.Error(err, "Failed to list DNS zones")
508-
return "", err
509-
}
510-
if len(zones.Result) != 0 {
511-
return zones.Result[0].ID, nil
512-
}
513-
}
514-
err := errors.New("failed to discover DNS zone")
515-
log.Error(err, "Failed to discover parent DNS zone. Ensure Zone.DNS permission is configured", "hostname", hostname)
516-
return "", err
517-
}

0 commit comments

Comments
 (0)