Skip to content

TLS Transport

bindcar can serve its REST API over TLS, optionally requiring a client certificate (mutual TLS).

Why this matters

Every request to bindcar carries a privileged credential — a Kubernetes ServiceAccount token under TokenReview auth, or the shared BIND_API_TOKEN.

Authentication proves who the caller is. It does nothing for confidentiality. Over plaintext HTTP that credential is readable by anything able to observe the link: a CNI with node-level capture, a sidecar holding NET_RAW, a compromised node, or any cluster without pod-network encryption. The credential exposed is the one authorizing zone creation, reload and deletion.

What is not exposed

RNDC/TSIG key material never transits this API — only the key name does. The exposure is the API credential, not your DNS signing keys.

Build-time feature

TLS lives behind the tls cargo feature, which is on by default — the published binary and container image can always terminate TLS, and cargo install bindcar gets it. tls implies server, since TLS is the transport for the HTTP server.

A library consumer that only wants bindcar's data types can drop both:

bindcar = { version = "0.8", default-features = false }

That takes the dependency graph from 178 crates to 98 — the whole HTTP stack (axum, tower-http, utoipa, utoipa-swagger-ui, tower_governor) and the whole crypto stack (rustls, tokio-rustls, rustls-webpki, ring, zeroize) go with it. What remains is the data types, the RNDC and nsupdate executors, and the configuration parsers.

A binary built without the feature serves plaintext only, and refuses to start if TLS options are supplied rather than ignoring them:

$ bindcar run --tls-cert /etc/bindcar/tls/tls.crt --tls-key /etc/bindcar/tls/tls.key
Error: TLS options were supplied but this bindcar binary was built without the
`tls` feature, so it can only serve plaintext. Rebuild with `--features tls`
(it is enabled by default), or remove the TLS options to serve plaintext
deliberately.

Quick start

bindcar run \
  --tls-cert /etc/bindcar/tls/tls.crt \
  --tls-key  /etc/bindcar/tls/tls.key

Or by environment variable, which is usually easier in a container:

BIND_TLS_CERT=/etc/bindcar/tls/tls.crt \
BIND_TLS_KEY=/etc/bindcar/tls/tls.key \
  bindcar run

The startup log confirms the transport:

bind9 rndc api server listening on 0.0.0.0:8080 (TLS)

Configuration

CLI flag Environment variable Meaning
--tls-cert BIND_TLS_CERT PEM certificate chain, leaf first
--tls-key BIND_TLS_KEY PEM private key (PKCS#8, PKCS#1 or SEC1)
--tls-client-ca BIND_TLS_CLIENT_CA PEM CA bundle; setting it enables mutual TLS

TLS is opt-in. With no certificate configured bindcar serves plaintext HTTP exactly as it always has, so upgrading does not change the behaviour of an existing deployment.

A certificate chain may contain intermediates; put the leaf first. Empty string values count as unset, so an unset variable that expands to "" in a shell wrapper or manifest will not half-configure the listener.

Mutual TLS

Setting --tls-client-ca requires every client to present a certificate that chains to that bundle. A client with no certificate, or one signed by any other CA, is rejected during the TLS handshake — before any request is parsed.

bindcar run \
  --tls-cert       /etc/bindcar/tls/tls.crt \
  --tls-key        /etc/bindcar/tls/tls.key \
  --tls-client-ca  /etc/bindcar/tls/ca.crt
mutual TLS enabled; clients must present a certificate trusted by the configured CA

In a Kubernetes topology where both ends have stable in-cluster identities — the bindy operator talking to its bindcar sidecar, for example — mTLS is a better fit than server-only TLS, because the client certificate can replace the bearer token entirely rather than merely wrapping it.

mTLS authenticates the transport, not the API

A valid client certificate satisfies the TLS handshake. It does not replace bindcar's API authentication: DISABLE_AUTH, BIND_API_TOKEN and TokenReview behave exactly as before. Do not turn API auth off because mTLS is on.

Misconfiguration fails closed

A half-configured listener is a startup error, never a silent downgrade to plaintext. That failure mode — an operator believing TLS is on when it is not — is the one worth being loud about.

Situation Result
Neither cert nor key set Plaintext HTTP (the default)
Both set TLS
Only one set Startup fails, exit code 1
Client CA set with no cert/key Startup fails, exit code 1
Cert or key missing/unreadable/malformed Startup fails, exit code 1
$ bindcar run --tls-cert /etc/bindcar/tls/tls.crt
Error: invalid TLS configuration

Caused by:
    TLS is half-configured: --tls-cert / BIND_TLS_CERT was set but
    --tls-key / BIND_TLS_KEY was not; set both to enable TLS, or neither
    to serve plaintext

The plaintext warning

When bindcar serves plaintext on a non-loopback address it warns at every start:

serving the API over plaintext HTTP on a non-loopback address; API credentials
cross the network in the clear. Set --tls-cert/--tls-key
(BIND_TLS_CERT/BIND_TLS_KEY) to enable TLS.

Loopback binds (127.0.0.0/8, localhost, ::1) are exempt — that traffic never leaves the host.

Certificates in Kubernetes

cert-manager is the straightforward route. Issue a certificate into a Secret and mount it into the bindcar container:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: bindcar-tls
  namespace: dns-system
spec:
  secretName: bindcar-tls
  duration: 2160h      # 90d
  renewBefore: 360h    # 15d
  issuerRef:
    name: cluster-ca-issuer
    kind: ClusterIssuer
  commonName: bindcar.dns-system.svc
  dnsNames:
    - bindcar.dns-system.svc
    - bindcar.dns-system.svc.cluster.local
    - name: bindcar
      image: ghcr.io/firestoned/bindcar:v0.7.4
      env:
        - name: BIND_TLS_CERT
          value: /etc/bindcar/tls/tls.crt
        - name: BIND_TLS_KEY
          value: /etc/bindcar/tls/tls.key
        # For mTLS, also mount the issuing CA and point at it:
        # - name: BIND_TLS_CLIENT_CA
        #   value: /etc/bindcar/tls/ca.crt
      volumeMounts:
        - name: bindcar-tls
          mountPath: /etc/bindcar/tls
          readOnly: true
  volumes:
    - name: bindcar-tls
      secret:
        secretName: bindcar-tls
        defaultMode: 0400

Certificate rotation

bindcar re-reads the certificate, key and client CA on an interval and swaps them in without restarting. A cert-manager renewal is picked up on its own.

CLI flag Environment variable Default Meaning
--tls-reload-interval BIND_TLS_RELOAD_INTERVAL 60 Seconds between checks. 0 disables reloading.
watching TLS certificate material for renewals every 60s (BIND_TLS_RELOAD_INTERVAL=0 to disable)
reloaded TLS certificate material; new connections will use it

Send SIGHUP to check immediately instead of waiting out the interval:

kill -HUP "$(pidof bindcar)"

Established connections are unaffected. The configuration is read once per accepted connection, so a connection in flight finishes under the certificate it started with and only new connections pick up the renewal. Nothing is dropped.

A failed reload is a non-event. Certificate files are not replaced atomically — a check can easily observe a new certificate alongside a not-yet-replaced key. When the new material does not load, bindcar logs a warning and keeps serving the certificate it already has, then retries on the next interval:

TLS material changed but the new configuration is not usable, continuing with
the previous certificate: TLS private key from /etc/bindcar/tls/tls.key was
rejected: keys may not be consistent: KeyMismatch

This is deliberately different from startup, where unreadable TLS material is fatal. At startup, continuing would mean silently serving plaintext; during a reload there is already a working configuration in memory, so keeping it is safer than either failing or downgrading.

Detection is by content, not timestamp

Kubernetes does not rewrite mounted Secret files in place — it swaps a ..data symlink. bindcar hashes the file contents rather than checking mtime, which is what makes renewal detection work on a projected volume.

Set BIND_TLS_RELOAD_INTERVAL=0 to restore the startup-only behaviour of bindcar 0.7.x, where a renewal requires a pod rollout.

Health and readiness probes

Kubernetes probes must use the matching scheme once TLS is on. The certificate is typically issued for the Service DNS name, not the pod IP the kubelet dials, so scheme: HTTPS alone is not enough — the kubelet does not verify the certificate for probes, which is what makes this work:

livenessProbe:
  httpGet:
    path: /api/v1/health
    port: 8080
    scheme: HTTPS
readinessProbe:
  httpGet:
    path: /api/v1/ready
    port: 8080
    scheme: HTTPS

Under mutual TLS the kubelet cannot present a client certificate, so httpGet probes fail. Use an exec probe with a client certificate instead, or keep probes on a separate plaintext loopback listener.

Client examples

# Server TLS with a private CA
curl --cacert /etc/bindcar/tls/ca.crt \
     -H "Authorization: Bearer $TOKEN" \
     https://bindcar.dns-system.svc:8080/api/v1/zones

# Mutual TLS
curl --cacert /etc/bindcar/tls/ca.crt \
     --cert /etc/client/tls.crt --key /etc/client/tls.key \
     https://bindcar.dns-system.svc:8080/api/v1/zones

Interaction with a service mesh

If Linkerd already provides automatic mTLS between pods, the credential is encrypted in transit and bindcar's own TLS is redundant for mesh-internal traffic. Enabling it anyway is defence in depth and costs a handshake; it matters most when traffic can bypass the mesh — a probe from the kubelet, a debug port-forward, or a caller outside the mesh.

Protocol details

  • TLS versions: 1.2 and 1.3, via rustls. SSLv3, TLS 1.0 and TLS 1.1 are not implemented and cannot be enabled.
  • ALPN: advertises h2 then http/1.1; HTTP/2 and HTTP/1.1 are both served.
  • Crypto provider: aws-lc-rs, installed process-wide at startup so the Kubernetes client under the k8s-token-review feature uses the same provider (ADR-0002).

Post-quantum key exchange

The listener offers the hybrid post-quantum key agreement X25519MLKEM768 (X25519 combined with FIPS 203 ML-KEM-768) as its most preferred TLS 1.3 group. No flag is needed and none exists: a hybrid group is at least as strong as its classical half, so there is no reason to turn it off.

  • A PQC-capable client (Chrome, Firefox, Go 1.24+, OpenSSL 3.5+, rustls with aws-lc-rs) negotiates X25519MLKEM768. The session's confidentiality then does not depend on classical elliptic-curve assumptions, which defeats harvest-now-decrypt-later recording of API traffic.
  • A classical-only client negotiates exactly what it negotiates today (X25519 or another classical group); nothing breaks.
  • TLS 1.2 has no hybrid groups and is unchanged.

Verify with an ML-KEM-capable OpenSSL:

openssl s_client -connect <host>:8443 -groups X25519MLKEM768 </dev/null \
  | grep 'Negotiated TLS1.3 group'

See the Cryptographic Inventory for the full PQC picture across bindcar's surfaces.

Testing

The transport has end-to-end coverage that runs without BIND9 or a cluster:

make tls-transport-test

It asserts a real TLS handshake, that plaintext is refused on a TLS port, that mTLS rejects both a missing and an untrusted client certificate, and that every misconfiguration above exits non-zero. In CI it runs as its own e2e-tls suite job in the e2e workflow matrix; locally it is also part of the serial make ci-e2e gate.

See also