Bindy Scout¶
Two Deployment Modes
Same-cluster mode (default): Scout and the Bindy operator run in the same cluster. No extra configuration needed.
Remote cluster mode: Scout runs on a workload cluster and writes to a dedicated Bindy cluster elsewhere. Set BINDY_SCOUT_REMOTE_SECRET to a Secret containing a kubeconfig for the Bindy cluster. Scout will create ARecords and validate zones there while watching local Ingresses and Services.
The Scout Bee¶
In a honeybee colony, scout bees are the advance team. While the main workers tend the hive, a small number of scouts venture out into the surrounding terrain, find promising flower patches, return to the hive, and communicate their discoveries through the waggle dance — a precise, information-rich signal that tells their sisters exactly where the nectar is.
Bindy Scout plays the same role in your Kubernetes infrastructure.
The main bindy run process is the hive — it manages BIND9 DNS infrastructure and reconciles DNS zones and records from their canonical CRD definitions. But it doesn't know about every application that flies around your cluster. That's where Scout comes in.
Scout is a lightweight, event-driven controller that ventures into your workload namespaces, watches Ingress and LoadBalancer Service resources, and — when it finds one annotated with the right signal — carries that DNS information back to the bindy cluster and registers an ARecord on the application's behalf.
The application team annotates their Ingress or Service. Scout finds it, validates it, derives the correct record name from the DNS zone, and server-side applies the ARecord CR in the bindy namespace. From there, the normal ARecord reconciler picks it up and programs BIND9. The application never needs to know how DNS works — it just raises a flag and Scout handles the rest.
What Scout Solves¶
In a multi-team platform, the DNS operator and the application teams live in different worlds:
- The platform team owns the
bindy-systemnamespace, theDNSZoneCRs, and the BIND9 instances. - Application teams own their own namespaces,
Deploymentobjects, andIngressresources.
Bridging this gap traditionally requires either:
- Manual DNS record management (error-prone, doesn't scale), or
- Granting application teams write access to the
bindy-systemnamespace (violates least-privilege).
Scout eliminates both compromises. Application teams annotate their own Ingresses — resources they already own — and Scout does the translation work. No cross-namespace write access required on the application side.
How It Works¶
- Scout watches all
IngressandLoadBalancer Serviceresources cluster-wide (excluding its own namespace and any configured exclusions). - When a watch event fires, Scout checks for the
bindy.firestoned.io/scout-enabled: "true"annotation. - If enabled, Scout reads the zone annotation, derives the DNS record name, and resolves the IP — from the LoadBalancer status or an explicit annotation override.
- Ingress: record name derived by stripping the zone suffix from each rule host (e.g.
app.example.comin zoneexample.com→app). OneARecordper rule host. - Service: record name is the Service name. Exactly one
ARecordper Service. - Scout server-side applies an
ARecordCR in the configured target namespace (default:bindy-system), stamped with labels identifying the source cluster, namespace, and resource. - The main bindy operator's
ARecordreconciler picks up the new CR and programs BIND9.
Quick Start¶
1. Annotate your Ingress or Service¶
Minimal — when Scout is configured with --default-zone and --default-ips:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app
namespace: my-app-ns
annotations:
bindy.firestoned.io/scout-enabled: "true"
spec:
rules:
- host: my-app.example.com
...
With overrides — per-Ingress zone and IP take precedence over Scout defaults:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app
namespace: my-app-ns
annotations:
bindy.firestoned.io/scout-enabled: "true"
# Override the default zone for this Ingress (optional when --default-zone is set)
bindy.firestoned.io/zone: "example.com"
# Optional: explicit IP override (overrides --default-ips and LB status)
# bindy.firestoned.io/ip: "10.0.1.42"
# Optional: TTL override in seconds (defaults to zone TTL)
# bindy.firestoned.io/ttl: "300"
spec:
rules:
- host: my-app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app
port:
number: 80
Scout will create an ARecord named scout-<cluster>-my-app-ns-my-app-0 in the bindy-system namespace, with:
spec.name:my-app(derived by stripping the zone suffix from the host)spec.address: IP from the Ingress LoadBalancer status- Labels:
bindy.firestoned.io/source-cluster,source-namespace,source-name,zone
For a LoadBalancer Service:
apiVersion: v1
kind: Service
metadata:
name: my-grpc-api
namespace: my-app-ns
annotations:
bindy.firestoned.io/scout-enabled: "true"
bindy.firestoned.io/zone: "example.com"
spec:
type: LoadBalancer
selector:
app: my-grpc-api
ports:
- port: 9090
targetPort: 9090
Scout will create an ARecord named scout-<cluster>-my-app-ns-my-grpc-api in the bindy-system namespace, resolving to my-grpc-api.example.com.
2. Deploy Scout¶
apiVersion: apps/v1
kind: Deployment
metadata:
name: bindy-scout
namespace: bindy-system
spec:
replicas: 1
selector:
matchLabels:
app: bindy-scout
template:
metadata:
labels:
app: bindy-scout
spec:
serviceAccountName: bindy-scout
containers:
- name: scout
image: ghcr.io/firestoned/bindy:latest
args: ["scout", "--cluster-name", "prod"]
env:
- name: BINDY_SCOUT_NAMESPACE
value: "bindy-system"
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: RUST_LOG
value: "info"
- name: RUST_LOG_FORMAT
value: "json"
Annotations Reference¶
These annotations apply to both Ingress and LoadBalancer Service resources.
| Annotation | Required | Description |
|---|---|---|
bindy.firestoned.io/scout-enabled |
Yes (preferred) | Set to "true" to opt this Ingress or Service into Scout management. The record kind always defaults to ARecord. |
bindy.firestoned.io/recordKind |
(legacy, Ingress only) | Accepted for backward compatibility. "ARecord" opts in. Prefer scout-enabled: "true" for new deployments. |
bindy.firestoned.io/zone |
Yes (unless --default-zone is set) |
The DNS zone for this resource (e.g. example.com). Overrides the operator's --default-zone. For Ingress: hosts outside the zone are skipped with a warning. |
bindy.firestoned.io/ip |
No | Explicit IP address(es) for the A record. Accepts a single IP ("10.0.0.1") or a comma-separated list ("10.0.0.1,10.0.0.2,10.0.0.3") — every entry becomes one address on the resulting ARecord, in the order given. Whitespace around each entry is trimmed and empty entries are skipped. When set, overrides both --default-ips and the LoadBalancer status IP. |
bindy.firestoned.io/ttl |
No | TTL override in seconds. When absent, the created ARecord inherits the TTL from the DNSZone spec. |
bindy.firestoned.io/record-name |
No | Override the spec.name of the created ARecord. When set, replaces the name normally derived by stripping the zone suffix from the host. Use "@" for the zone apex. On multi-host resources (Ingress, HTTPRoute, TLSRoute) the override is applied to every record produced — intended for single-host use cases. |
Zone authorization (cross-namespace)¶
Scout only publishes into a zone that authorizes the source object's namespace. This prevents a tenant in one namespace from creating records in another tenant's zone (a cross-tenant DNS-hijack / confused-deputy risk, since Scout writes with a cluster-privileged client).
A source object (Ingress/Service/HTTPRoute/TLSRoute/TCPRoute) may publish into a
zone when either:
- the matching
DNSZonelives in the same namespace as the source object, or - the
DNSZonecarriesbindy.firestoned.io/allow-zone-namespaceslisting the source namespace (comma-separated), or the*wildcard.
apiVersion: bindy.firestoned.io/v1beta1
kind: DNSZone
metadata:
name: example-com
namespace: bindy-system
annotations:
bindy.firestoned.io/allow-zone-namespaces: "tenant-a,tenant-b" # or "*"
spec:
zoneName: example.com
If a source object names a zone that does not authorize its namespace, Scout
skips it and logs ... namespace not authorized for zone .... This mirrors the
same annotation gate the DNSZone reconciler enforces for cross-namespace
instance targeting. See the Migration Guide
for upgrade steps.
Watched Resources¶
Scout supports three categories of Kubernetes resources as DNS record sources.
1. Ingresses (Legacy)¶
Kubernetes Ingress is deprecated
The networking.k8s.io/v1 Ingress API is considered legacy in modern Kubernetes clusters. New deployments should prefer Gateway API (HTTPRoute / TLSRoute / TCPRoute) where available. Scout continues to support Ingress for backward compatibility.
Scout processes each rule in the Ingress spec independently. For a multi-host Ingress:
spec:
rules:
- host: api.example.com # → ARecord "scout-prod-ns-name-0" (name: "api")
- host: www.example.com # → ARecord "scout-prod-ns-name-1" (name: "www")
- host: other.io # → skipped (not in zone "example.com")
The record name is derived by stripping the zone suffix from the host:
| Host | Zone | Derived record name |
|---|---|---|
api.example.com |
example.com |
api |
deep.sub.example.com |
example.com |
deep.sub |
example.com |
example.com |
@ (apex record) |
other.io |
example.com |
(skipped — not in zone) |
Opt in with the bindy.firestoned.io/scout-enabled: "true" annotation. One ARecord CR is created per rule host, named scout-{cluster}-{namespace}-{ingress}-{idx}. The IP is resolved from the Ingress LoadBalancer status or the bindy.firestoned.io/ip annotation override.
2. Services (LoadBalancer)¶
Scout watches LoadBalancer Services and automatically creates ARecord CRs — no Ingress required. This covers gRPC, TCP, and other non-HTTP workloads exposed directly via a cloud load balancer.
Opt in with the same annotation:
apiVersion: v1
kind: Service
metadata:
name: my-grpc-api
namespace: my-app
annotations:
bindy.firestoned.io/scout-enabled: "true"
bindy.firestoned.io/zone: "example.com" # required if no --default-zone
# bindy.firestoned.io/ip: "1.2.3.4" # optional IP override
# bindy.firestoned.io/ttl: "300" # optional TTL override
spec:
type: LoadBalancer
selector:
app: my-grpc-api
ports:
- port: 443
targetPort: 8443
Scout creates an ARecord named scout-{cluster}-{namespace}-{service} with:
- DNS record name:
{service-name}(relative to the zone) → resolves tomy-grpc-api.example.com - IP: from
bindy.firestoned.io/ipannotation →--default-ips→status.loadBalancer.ingress[].ip
ClusterIP and NodePort services are silently skipped — they have no routable external IP.
If no external IP has been assigned yet, Scout logs a warning and re-queues after 30s:
Comparison with Ingress:
| Ingress | Service | |
|---|---|---|
| ARecord per rule | Yes (one per rules[].host) |
No — exactly one record |
| CR name suffix | ...-{idx} (rule index) |
No index |
| IP source | LB status or annotation | LB status or annotation |
| Source label | source-name |
source-name |
Query all Service-sourced ARecords:
3. Gateway API (HTTPRoute, TLSRoute, and TCPRoute)¶
Scout watches HTTPRoute, TLSRoute, and TCPRoute resources from the Gateway API. The same opt-in annotation (bindy.firestoned.io/scout-enabled: "true") and zone/IP/TTL annotations apply. For HTTPRoute and TLSRoute, Scout creates one ARecord per hostname in spec.hostnames[]. TCPRoute has no spec.hostnames[] field — Scout creates a single ARecord using the name supplied by the bindy.firestoned.io/record-name annotation.
IP resolution follows the gateway chain
Gateway API routes have no LoadBalancer status of their own. When no bindy.firestoned.io/ip annotation is set, Scout follows the route's parentRefs to the serving Gateway, and — for a Gateway whose gatewayClassName is in the operator-configured --gateway-service map — reads the external IP from the Gateway's status.addresses, or, when those are empty, from the mapped LoadBalancer Service. This lets operators avoid making every route repeat an IP that is already discoverable from the gateway (e.g. Traefik's proxy Service). The full precedence is: bindy.firestoned.io/ip annotation → discovered gateway IP → --default-ips → requeue.
See Gateway API Routes for the full reference, examples, record naming, labels, and RBAC requirements.
Labels on Created ARecords¶
Every ARecord created by Scout carries these labels:
| Label | Value | Source |
|---|---|---|
bindy.firestoned.io/managed-by |
scout |
All |
bindy.firestoned.io/source-cluster |
<BINDY_SCOUT_CLUSTER_NAME> |
All |
bindy.firestoned.io/source-namespace |
Resource namespace | All |
bindy.firestoned.io/source-name |
Resource name | All |
bindy.firestoned.io/zone |
Zone name | All |
The zone label is particularly important: it lets you configure a DNSZone to automatically pull in all ARecords created by Scout for that zone:
apiVersion: bindy.firestoned.io/v1beta1
kind: DNSZone
metadata:
name: example-com
spec:
zoneName: example.com
recordsFrom:
- labelSelector:
matchLabels:
bindy.firestoned.io/managed-by: scout
bindy.firestoned.io/zone: example.com
Configuration Reference¶
CLI Flags¶
| Flag | Description |
|---|---|
--cluster-name <NAME> |
Required (unless env var set). Logical name of this cluster stamped on all created ARecord labels. Used to distinguish records created by different clusters writing to the same bindy namespace. |
--namespace <NS> |
Namespace where ARecords are created. Defaults to bindy-system. |
--default-zone <ZONE> |
Default DNS zone applied to all Ingresses and Services when no bindy.firestoned.io/zone annotation is present (e.g. example.com). When combined with --default-ips, resources only need bindy.firestoned.io/scout-enabled: "true". |
--default-ips <IP[,IP]> |
Comma-separated default IP address(es) used when no per-resource bindy.firestoned.io/ip annotation or LoadBalancer status IP is available. Useful for shared-ingress topologies (e.g. Traefik). |
--gateway-service <class=target> |
Repeatable. Maps a gatewayClass to the LoadBalancer Service whose external IP backs it, used to resolve an IP for HTTPRoute/TLSRoute/TCPRoute via their parentRefs. target is either namespace/name (e.g. traefik=traefik/traefik) or namespace/<label-selector> (e.g. traefik=traefik/app.kubernetes.io/name=traefik). Multi-label selectors (with commas) must use this flag rather than the env var. The configured classes double as the allow-list of gateways Scout will follow. |
--namespace-selector <SELECTOR> |
A Kubernetes label selector (e.g. bindy.firestoned.io/scout-enabled=true) restricting which namespaces Scout will act in — same syntax as kubectl get ns -l <selector>. A namespace must match this selector and the individual Ingress/Service/route object must still carry its own opt-in annotation; both gates apply. Strongly recommended for every production deployment — see Namespace Whitelisting below. |
CLI flags take precedence over the corresponding environment variables.
Environment Variables¶
| Variable | Default | Description |
|---|---|---|
BINDY_SCOUT_CLUSTER_NAME |
— | Required when --cluster-name is not set. |
BINDY_SCOUT_NAMESPACE |
bindy-system |
Namespace where ARecords are created. |
POD_NAMESPACE |
default |
Scout's own namespace. Always excluded from Ingress watching to prevent Scout from watching resources in its own namespace. Injected automatically by the Kubernetes downward API. |
BINDY_SCOUT_EXCLUDE_NAMESPACES |
— | Comma-separated list of additional namespaces to skip. Useful to exclude system namespaces (kube-system, kube-public, etc.) that will never have Scout-annotated Ingresses. |
BINDY_SCOUT_DEFAULT_ZONE |
— | Default DNS zone for all Ingresses and Services when no bindy.firestoned.io/zone annotation is present. Overridden by --default-zone. When set alongside BINDY_SCOUT_DEFAULT_IPS, resources only need bindy.firestoned.io/scout-enabled: "true". |
BINDY_SCOUT_DEFAULT_IPS |
— | Comma-separated default IP address(es) used when no per-resource annotation override or LoadBalancer status IP is available. Useful for shared-ingress topologies (e.g. Traefik) where all resources resolve to the same VIP(s). Overridden by --default-ips. |
BINDY_SCOUT_GATEWAY_SERVICES |
— | Comma-separated gatewayClass=target map for gateway-chain IP resolution on HTTPRoute/TLSRoute/TCPRoute, e.g. traefik=traefik/traefik. target is namespace/name or namespace/<label-selector>. Because commas separate entries here, multi-label selectors must use the repeatable --gateway-service flag. Overridden by --gateway-service. |
BINDY_SCOUT_NAMESPACE_SELECTOR |
— (every namespace eligible) | Label selector restricting which namespaces Scout will act in. Overridden by --namespace-selector. Unset means every namespace in the cluster is eligible — running without this set is not recommended for production. See Namespace Whitelisting. |
BINDY_SCOUT_REMOTE_SECRET |
— | (Phase 2) Name of a Secret in the local cluster containing a kubeconfig key. When set, Scout targets the remote Bindy cluster for ARecord creation and zone validation. When unset, same-cluster mode is used. |
BINDY_SCOUT_REMOTE_SECRET_NAMESPACE |
Scout's own namespace | (Phase 2) Namespace of the BINDY_SCOUT_REMOTE_SECRET. Defaults to Scout's own namespace (POD_NAMESPACE). |
RUST_LOG |
info |
Log level: trace, debug, info, warn, error. |
RUST_LOG_FORMAT |
text |
Log format: text (compact, human-readable) or json (structured, for log aggregators). |
Full Deployment Example with All Variables¶
apiVersion: apps/v1
kind: Deployment
metadata:
name: bindy-scout
namespace: bindy-system
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: bindy
app.kubernetes.io/component: scout
template:
metadata:
labels:
app.kubernetes.io/name: bindy
app.kubernetes.io/component: scout
spec:
serviceAccountName: bindy-scout
containers:
- name: scout
image: ghcr.io/firestoned/bindy:latest
args:
- scout
# Alternatively: --cluster-name prod
env:
- name: BINDY_SCOUT_CLUSTER_NAME
value: "prod"
- name: BINDY_SCOUT_NAMESPACE
value: "bindy-system"
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: BINDY_SCOUT_EXCLUDE_NAMESPACES
value: "kube-system,kube-public,kube-node-lease"
- name: RUST_LOG
value: "info"
- name: RUST_LOG_FORMAT
value: "json"
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
RBAC Requirements¶
Scout requires cluster-wide watch access to Ingress and Service resources, read access to DNSZone and Namespace resources, and write access to ARecord resources in the target namespace. It requires no Secret access at all unless you use Phase 2 (multi-cluster) mode — see below.
Namespace access is low-sensitivity
The namespaces: get, list rule is namespace metadata only (names and labels, no
Secret data) and is required for
namespace whitelisting.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: bindy-scout
namespace: bindy-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: bindy-scout
rules:
# Watch and mutate Ingresses across all namespaces.
# patch+update required because kube-rs finalizer patches the main resource.
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "patch", "update"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses/finalizers"]
verbs: ["update"]
# Watch LoadBalancer Services for external IP → ARecord automation.
# patch+update required to add/remove the Scout finalizer on Service metadata.
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "patch", "update"]
- apiGroups: [""]
resources: ["services/finalizers"]
verbs: ["update"]
# Watch HTTPRoutes/TLSRoutes/TCPRoutes from the Gateway API and manage finalizers.
# patch+update required to add/remove the Scout finalizer on route metadata.
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["httproutes", "tlsroutes", "tcproutes"]
verbs: ["get", "list", "watch", "patch", "update"]
# route/finalizers subresource for forward-compatibility.
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["httproutes/finalizers", "tlsroutes/finalizers", "tcproutes/finalizers"]
verbs: ["update"]
# Gateways are read-only — Scout reads status.addresses to discover external IPs.
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["gateways"]
verbs: ["get", "list", "watch"]
# Read DNSZones for zone validation
- apiGroups: ["bindy.firestoned.io"]
resources: ["dnszones"]
verbs: ["get", "list", "watch"]
# Read Namespace labels for --namespace-selector / BINDY_SCOUT_NAMESPACE_SELECTOR.
# Low-sensitivity (name/labels only, no Secret data).
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get", "list"]
# NOTE: deliberately no Secret rule here. See "RBAC for Phase 2 (Multi-Cluster) Mode"
# below for the namespaced, resourceNames-restricted Role used instead.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: bindy-scout
subjects:
- kind: ServiceAccount
name: bindy-scout
namespace: bindy-system
roleRef:
kind: ClusterRole
name: bindy-scout
apiGroup: rbac.authorization.k8s.io
---
# Separate Role for writing ARecords in the target namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: bindy-scout-writer
namespace: bindy-system
rules:
- apiGroups: ["bindy.firestoned.io"]
resources: ["arecords"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: bindy-scout-writer
namespace: bindy-system
subjects:
- kind: ServiceAccount
name: bindy-scout
namespace: bindy-system
roleRef:
kind: Role
name: bindy-scout-writer
apiGroup: rbac.authorization.k8s.io
RBAC for Phase 2 (Multi-Cluster) Mode¶
If — and only if — you run Scout with BINDY_SCOUT_REMOTE_SECRET set, Scout additionally
needs to read that one kubeconfig Secret. This is granted by a separate, namespaced,
resourceNames-restricted Role/RoleBinding — not a cluster-wide grant on the main
bindy-scout ClusterRole. Apply this only if you use Phase 2 mode
(deploy/scout/secrets-reader-rbac.yaml, or automatically via bindy bootstrap scout
--remote-secret <name>):
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: bindy-scout-secrets-reader
namespace: bindy-system
rules:
# resourceNames restricts this to exactly the one kubeconfig Secret — not every
# Secret in this namespace, and not any Secret in any other namespace.
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["bindy-scout-remote-kubeconfig"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: bindy-scout-secrets-reader
namespace: bindy-system
subjects:
- kind: ServiceAccount
name: bindy-scout
namespace: bindy-system
roleRef:
kind: Role
name: bindy-scout-secrets-reader
apiGroup: rbac.authorization.k8s.io
bindy bootstrap scout --remote-secret <name> provisions this automatically, with
resourceNames set to the exact Secret name you passed. Same-cluster-only deployments
(the default — no --remote-secret) get no Secret access whatsoever.
Gateway API Routes (HTTPRoute, TLSRoute, and TCPRoute)¶
In addition to watching Ingress resources, Scout also supports Gateway API routes: HTTPRoute, TLSRoute, and TCPRoute from the Gateway API. These resources provide a more modern, flexible alternative to Ingress with better separation of concerns.
Scout treats HTTPRoute, TLSRoute, and TCPRoute similarly to Ingress:
- Watches all HTTPRoute/TLSRoute/TCPRoute resources cluster-wide (excluding its own namespace)
- Requires the same
bindy.firestoned.io/scout-enabled: "true"opt-in annotation - Uses the same annotation scheme for zone, IP, and TTL configuration
HTTPRoute/TLSRoute: creates oneARecordper hostname inspec.hostnames[]with an index suffixTCPRoute: has nospec.hostnames[]field — creates oneARecordusing thebindy.firestoned.io/record-nameannotation (required)
Why Use Gateway API Routes?¶
- HTTPRoute: Provides advanced HTTP routing (path-based, method-based, header matching) without the Ingress resource limitations
- TLSRoute: For TLS-only traffic (non-HTTP protocols over TLS, gRPC, custom protocols), Scout ensures DNS records are created for all declared hostnames
- TCPRoute: For TCP-based services such as databases, proxies, or other L4 traffic. TCPRoute has no
spec.hostnames[]field, so the record name must be supplied viabindy.firestoned.io/record-name. Scout creates exactly oneARecord.
Quick Example: HTTPRoute¶
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api-gateway
namespace: my-app
annotations:
bindy.firestoned.io/scout-enabled: "true"
bindy.firestoned.io/zone: "api.example.com"
bindy.firestoned.io/ip: "192.168.1.100"
spec:
hostnames:
- api.example.com
- secure-api.example.com
parentRefs:
- name: my-gateway
namespace: my-app
rules:
- backendRefs:
- name: api-service
port: 8080
Scout will create two ARecords:
- scout-{cluster}-my-app-api-gateway-0 for api.example.com
- scout-{cluster}-my-app-api-gateway-1 for secure-api.example.com
Quick Example: TLSRoute¶
apiVersion: gateway.networking.k8s.io/v1
kind: TLSRoute
metadata:
name: grpc-gateway
namespace: my-app
annotations:
bindy.firestoned.io/scout-enabled: "true"
bindy.firestoned.io/zone: "secure.example.com"
bindy.firestoned.io/ip: "192.168.1.101"
spec:
hostnames:
- secure.example.com
- grpc.example.com
parentRefs:
- name: my-gateway
namespace: my-app
rules:
- backendRefs:
- name: grpc-service
port: 5051
Scout will create two ARecords:
- scout-{cluster}-my-app-grpc-gateway-0 for secure.example.com
- scout-{cluster}-my-app-grpc-gateway-1 for grpc.example.com
Quick Example: TCPRoute¶
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: TCPRoute
metadata:
name: db-gateway
namespace: my-app
annotations:
bindy.firestoned.io/scout-enabled: "true"
bindy.firestoned.io/zone: "example.com"
bindy.firestoned.io/ip: "192.168.1.102"
bindy.firestoned.io/record-name: "db"
spec:
parentRefs:
- name: my-gateway
namespace: my-app
rules:
- backendRefs:
- name: postgres
port: 5432
Scout will create one ARecord for db.example.com.
Record Naming for Gateway Routes¶
Gateway API routes use spec.hostnames[] instead of spec.rules[].host. Scout creates one ARecord per hostname with an index suffix (0, 1, 2, ...):
| Hostname | Record CR Name |
|---|---|
api.example.com (index 0) |
scout-prod-my-app-api-gateway-0 |
secure-api.example.com (index 1) |
scout-prod-my-app-api-gateway-1 |
grpc.example.com (index 0) |
scout-prod-my-app-grpc-gateway-0 |
The record name within the zone is derived identically to Ingress rules:
| Hostname | Zone | Derived record name |
|---|---|---|
api.example.com |
api.example.com |
@ (apex record) |
secure-api.example.com |
api.example.com |
secure-api |
deep.api.example.com |
api.example.com |
deep.api |
Labels on Gateway Route ARecords¶
ARecords created from Gateway API routes carry similar labels to Ingress-derived records, with source-specific labels:
| Label | Value | Purpose |
|---|---|---|
bindy.firestoned.io/managed-by |
scout |
Identifies Scout as the manager |
bindy.firestoned.io/source-cluster |
<BINDY_SCOUT_CLUSTER_NAME> |
Cluster where the route lives |
bindy.firestoned.io/source-namespace |
Route namespace | Source namespace for traceability |
bindy.firestoned.io/source-name |
Route name | Source route name |
bindy.firestoned.io/zone |
Zone name | Allows DNSZone.spec.recordsFrom label selectors to discover the record |
This allows you to configure a DNSZone to pull in all ARecords created by Scout from both Ingress and Gateway API routes:
apiVersion: bindy.firestoned.io/v1beta1
kind: DNSZone
metadata:
name: api-example-com
spec:
zoneName: api.example.com
recordsFrom:
- labelSelector:
matchLabels:
bindy.firestoned.io/managed-by: scout
bindy.firestoned.io/zone: api.example.com
RBAC for Gateway API Routes¶
Scout requires read access (get, list, watch) to HTTPRoute, TLSRoute, TCPRoute, and Gateway resources. Gateway read access lets Scout follow a route's parentRefs to the serving gateway and discover its external IP. Add this rule to the Scout ClusterRole:
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: bindy-scout
rules:
# ... existing Ingress and Service rules ...
# Watch HTTPRoutes/TLSRoutes/TCPRoutes and read Gateways (Gateway API)
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["httproutes", "tlsroutes", "tcproutes", "gateways"]
verbs: ["get", "list", "watch"]
When gateway-chain IP resolution reads the LoadBalancer Service behind a Gateway, it uses the existing services get/list permissions (a namespace/<label-selector> target lists Services in the namespace).
Note: Gateway API resources are read-only for Scout. Scout does NOT add finalizers to routes/gateways or mutate them — it only reads spec/metadata/status and creates corresponding ARecords in the target namespace.
Namespace Exclusions¶
Scout always excludes its own namespace (POD_NAMESPACE) from Ingress watching. This prevents:
- Scout from watching its own Ingresses (if any) and creating circular references.
- Unintended ARecords from platform Ingresses in the bindy namespace.
To exclude additional namespaces:
env:
- name: BINDY_SCOUT_EXCLUDE_NAMESPACES
value: "kube-system,kube-public,kube-node-lease,monitoring"
Namespace Whitelisting (--namespace-selector)¶
By default — with no --namespace-selector / BINDY_SCOUT_NAMESPACE_SELECTOR set — Scout
is eligible to act in every namespace in the cluster, gated only by each source
object's own opt-in annotation (bindy.firestoned.io/scout-enabled: "true", etc.) and
the namespace exclusion list above. In other words: any tenant
in any namespace can cause Scout to create an ARecord, simply by annotating their own
Ingress/Service/route object.
Running with no namespace selector configured is not recommended. Set one:
or via the CLI flag: --namespace-selector "bindy.firestoned.io/scout-enabled=true".
Once set, Scout checks this selector against the namespace's own labels (not the resource's annotations) before acting on anything inside it — a genuine two-factor opt-in: the namespace must carry the label and the individual resource must carry its annotation. Label the namespaces you want Scout active in:
Any selector expression accepted by kubectl get ns -l <selector> works — this is not
limited to the single-label example above (e.g. environment in (staging,prod) is
equally valid).
What changes when a namespace is unlabeled¶
If a namespace stops matching the selector (label removed, or the selector is newly
introduced on an existing deployment), Scout treats every source object in that
namespace exactly as if its opt-in annotation had been removed: any ARecords it
created are deleted and its finalizer is released, unblocking normal object deletion.
This means introducing --namespace-selector on an existing Scout deployment is a
behavior change — any namespace not labeled at the time will have its Scout-managed
ARecords cleaned up. Label every namespace that should keep working before rolling
out the selector, not after.
Deletion and remote-cluster failures¶
When an opted-in Ingress/Service/route is deleted, Scout first deletes the ARecords it
created (on the remote Bindy cluster in Phase 2 mode) and then releases its finalizer.
If that remote cleanup fails — a broken or expired Phase 2 kubeconfig, or a remote-cluster
outage — Scout retries for a grace period (5 minutes) and, past it, releases the
finalizer anyway rather than hold the source object in Terminating. This prevents a
single unreachable remote from blocking deletion of tenant objects cluster-wide. When the
grace period is exceeded, Scout logs an error noting that the remote ARecords may be
orphaned and must be reconciled separately (they are otherwise reaped by Scout's
label-based stale-record cleanup once the remote is reachable again).
What this does and does not reduce¶
Setting a namespace selector meaningfully shrinks Scout's day-to-day operating
footprint and requires a platform-level action (labeling a namespace) before any
tenant's opt-in annotation takes effect — a real, valuable second gate. It does not
by itself shrink the ClusterRole's RBAC ceiling: bindy-scout still holds cluster-wide
patch/update on Ingress/Service/route types, so a directly compromised ServiceAccount
token could still, in principle, act outside the labeled namespaces for those types. (As
of 2026-07-19, Scout's Secret access is no longer part of this ceiling at all — the
former cluster-wide secrets: get grant was replaced with a namespaced,
resourceNames-restricted Role scoped to the single Phase 2 kubeconfig Secret; see
RBAC for Phase 2 Mode above.) See
docs/src/security/threat-model.md for the full analysis.
Multi-Cluster Mode¶
In many platform engineering setups, the DNS operator lives on a dedicated infrastructure cluster — isolated from workload traffic, hardened, and controlled by the platform team. Application workloads run on separate clusters that have no direct RBAC access to the DNS cluster. Scout bridges this gap without requiring network tunnels or federated identity.
The Queen Bee and Her Scouts¶
Think of the architecture using the same honeybee metaphor:
- The Queen Bee is the central Bindy operator running on the dedicated Queen Bee cluster. She manages all DNS infrastructure —
DNSZoneCRs,ARecordCRs, and the BIND9 instances that serve DNS. - Scout Bees run on each workload cluster. They watch local
Ingressresources and fly their discoveries back to the Queen, depositingARecordCRs directly into her hive.
Application teams annotate their Ingresses as normal. They never need to know a separate DNS cluster exists.
Each Scout authenticates to the Queen Bee cluster using a dedicated service account kubeconfig stored as a Secret in its own cluster. A Scout on Cluster A cannot impersonate or interfere with the Scout on Cluster B — each has its own independently revocable credentials.
How the Multi-Cluster Connection Works¶
Setup Guide¶
1. Bootstrap credentials on the Queen Bee cluster¶
Run bindy bootstrap mc against the Queen Bee cluster (the cluster running the Bindy operator). It creates a dedicated service account, the minimal RBAC Role and RoleBinding, waits for the SA token, and outputs a bindy.firestoned.io/remote-kubeconfig Secret as YAML to stdout:
# Run against the Queen Bee cluster — outputs Secret YAML to stdout
bindy bootstrap mc \
--service-account bindy-scout-remote \
--namespace bindy-system
Expected output (stderr — progress only):
✓ Applied ServiceAccount bindy-scout-remote
✓ Applied Role bindy-scout-remote
✓ Applied RoleBinding bindy-scout-remote
✓ Applied SA token Secret bindy-scout-remote-token
⏳ Waiting for SA token to be populated...
✓ SA token ready
✓ Apply the above Secret to each child cluster:
bindy bootstrap mc | kubectl --context=<child-cluster> apply -f -
Then set BINDY_SCOUT_REMOTE_SECRET=bindy-scout-remote-kubeconfig on the scout Deployment.
The YAML written to stdout is a Secret of type bindy.firestoned.io/remote-kubeconfig containing a kubeconfig key with the Queen Bee cluster credentials.
Use the default service account name
The default --service-account is bindy-scout-remote, which matches the RBAC manifests in deploy/scout/remote-cluster-rbac.yaml. Use a unique SA name per child cluster if you want independent credential revocation.
2. Apply the kubeconfig Secret to each child cluster¶
Pipe directly, or save to a file first:
export KUBECONFIG=~/.kube/queen-bee.yaml
bindy bootstrap mc > /tmp/scout-kubeconfig.yaml
kubectl --context=child-cluster-a apply -f /tmp/scout-kubeconfig.yaml
kubectl --context=child-cluster-b apply -f /tmp/scout-kubeconfig.yaml
# Note: each cluster uses the same credentials (same SA on Queen Bee cluster)
# Use a unique --service-account per child cluster for independent revocation
export KUBECONFIG=~/.kube/queen-bee.yaml
# Cluster A gets its own SA on the Queen Bee cluster
bindy bootstrap mc --service-account bindy-scout-remote-cluster-a \
| kubectl --context=child-cluster-a apply -f -
# Cluster B gets its own SA
bindy bootstrap mc --service-account bindy-scout-remote-cluster-b \
| kubectl --context=child-cluster-b apply -f -
3. Configure Scout on the child cluster¶
Set two environment variables on the Scout Deployment to activate remote mode:
apiVersion: apps/v1
kind: Deployment
metadata:
name: bindy-scout
namespace: bindy-system
spec:
template:
spec:
containers:
- name: scout
image: ghcr.io/firestoned/bindy:latest
args: ["scout", "--cluster-name", "cluster-a"]
env:
- name: BINDY_SCOUT_CLUSTER_NAME
value: "cluster-a"
- name: BINDY_SCOUT_NAMESPACE
value: "bindy-system"
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
# --- Multi-cluster mode ---
- name: BINDY_SCOUT_REMOTE_SECRET
value: "bindy-scout-remote-kubeconfig"
# Optional: if the Secret is in a different namespace than Scout
# - name: BINDY_SCOUT_REMOTE_SECRET_NAMESPACE
# value: "bindy-system"
- name: RUST_LOG
value: "info"
When BINDY_SCOUT_REMOTE_SECRET is set, Scout loads the kubeconfig from that Secret and uses it for all ARecord creation and DNSZone validation — the Queen Bee cluster, not the local cluster.
Also grant RBAC to read this Secret
Setting the env var alone is not enough — Scout's ClusterRole grants no Secret
access at all (see RBAC for Phase 2 Mode).
Apply the namespaced secrets-reader Role too, with resourceNames matching the
Secret name above:
bindy bootstrap scout, re-run it with
--remote-secret bindy-scout-remote-kubeconfig to provision this automatically.
Without it, Scout will fail to read the Secret with an RBAC-forbidden error.
4. Verify the connection¶
# Check Scout is running on the child cluster
kubectl --context=child-cluster-a get pods -n bindy-system -l app.kubernetes.io/component=scout
# Watch Scout logs — should see "remote mode" startup message
kubectl --context=child-cluster-a logs -n bindy-system -l app.kubernetes.io/component=scout -f
# Annotate an Ingress on the child cluster
kubectl --context=child-cluster-a annotate ingress my-app \
bindy.firestoned.io/scout-enabled=true \
bindy.firestoned.io/zone=example.com \
-n my-app-ns
# Verify the ARecord was created on the Queen Bee cluster
kubectl --context=queen-bee get arecords -n bindy-system -l bindy.firestoned.io/source-cluster=cluster-a
RBAC: What bootstrap mc Creates¶
bindy bootstrap mc creates three resources on the Queen Bee cluster, all named after the service account:
| Resource | Kind | Permissions |
|---|---|---|
bindy-scout-remote |
ServiceAccount |
— |
bindy-scout-remote |
Role |
arecords: get, list, watch, create, update, patch, delete |
bindy-scout-remote |
Role |
dnszones: get, list, watch (for zone validation) |
bindy-scout-remote |
RoleBinding |
Binds the Role to the SA |
The Role is namespace-scoped (defaults to bindy-system). Scout cannot read or modify any other namespace on the Queen Bee cluster. It cannot read Secrets, access Pods, or perform any cluster-wide operations.
A separate ServiceAccount-type token Secret (bindy-scout-remote-token) is created with the kubernetes.io/service-account.name annotation so the Kubernetes token controller populates a long-lived token. The token is embedded in the output kubeconfig.
One SA per child cluster (recommended)
Using a unique service account per child cluster (--service-account bindy-scout-remote-cluster-a) allows you to revoke access for one cluster by simply deleting its SA on the Queen Bee cluster, without affecting other clusters.
Multi-Cluster Configuration Reference¶
| Variable | Default | Description |
|---|---|---|
BINDY_SCOUT_REMOTE_SECRET |
— | Name of a Secret in the child cluster containing a kubeconfig key. When set, Scout uses that kubeconfig to create ARecord CRs and validate DNSZone resources on the Queen Bee cluster instead of the local cluster. |
BINDY_SCOUT_REMOTE_SECRET_NAMESPACE |
Scout's own namespace (POD_NAMESPACE) |
Namespace where the remote kubeconfig Secret lives. Only set this if the Secret is in a different namespace than Scout itself. |
Changing the Cluster Name¶
If you restart Scout with a different --cluster-name (or BINDY_SCOUT_CLUSTER_NAME), Scout automatically cleans up any ARecord CRs that were created by the old cluster name.
How It Works¶
ARecord CRs created by Scout carry a bindy.firestoned.io/source-cluster label with the cluster name that created them. The CR name also embeds the cluster name (e.g., scout-<cluster>-<namespace>-<ingress>-<idx>).
When Scout reconciles an Ingress under a new cluster name:
1. It creates new ARecord CRs under the new cluster name.
2. It selects all ARecord CRs for the same Ingress whose source-cluster label does not match the current cluster name.
3. It deletes those stale records automatically.
This happens on every reconcile, so all stale records are cleaned up on the next controller loop after the restart — no manual intervention required.
Example¶
# Scout was previously running with --cluster-name prod-a
# Rename the cluster:
kubectl set env deployment/bindy-scout BINDY_SCOUT_CLUSTER_NAME=prod-b -n bindy-system
# After the rollout, Scout logs will show:
# Deleted stale ARecord after cluster-name change
# arecord=scout-prod-a-default-my-app-0
# old_cluster=prod-a new_cluster=prod-b
One cluster name per scout instance
All scouts writing to the same Queen Bee namespace must use unique cluster names. Duplicate cluster names cause one scout to overwrite the other's records.
Roadmap¶
| Feature | Status | Roadmap |
|---|---|---|
| Same-cluster mode | ✅ Complete | bindy-scout-ingress-controller.md |
| Finalizer on Ingress | ✅ Complete | bindy-scout-ingress-controller.md |
| Remote cluster mode | ✅ Complete | bindy-scout-ingress-controller.md |
| LoadBalancer Service → ARecord | ✅ Complete | scout-service-watching.md |
| SRV records from Service ports and Ingress | 🔲 Planned | scout-srv-records.md |
| Namespace inclusion/exclusion via label selectors | 🔲 Planned | scout-namespace-selectors.md |