Skip to main content

Restrict API server access

Inspect 1.36

KubeGate can restrict which clients reach the Kubernetes API server through the load balancer. By default it forwards every load-balancer connection and the API server's own authentication gates each request. A source-CIDR policy adds a check in front: KubeGate drops any load-balancer connection whose source IP is not on your allow-list, before it reaches the API server. The management plane and joining nodes carry a gate ticket, a signed token that lets trusted platform traffic through before the allow-list check, so they stay unaffected.

KubeGate fails closed: once a policy is loaded and PROXY protocol is active on the load balancer, a client that does not match the allow-list is dropped and never reaches the API server. The platform enables PROXY protocol on the control-plane load balancer, but it only takes effect once every control plane runs KubeGate, so turn a policy on after that. Until then, KubeGate cannot read the real client address and the policy is bypassable. The other real risk is locking yourself out, and that comes from address families, not from a weak rule. See Address families and the lockout risk below.

For how KubeGate fits the wider zero-trust design, see .

Prerequisites#

  • kubectl access to the management cluster.
  • The client CIDRs that should keep API server access (your office or admin ranges).

Write the policy #

A Policy lists the source CIDRs allowed through the load balancer. List the ranges that may reach the API server:

yaml
		# policy.yaml
apiVersion: kubegate.syself.com/v1alpha1
kind: Policy
metadata:
  name: lb-clients
spec:
  sourceCIDRs:
    - "203.0.113.0/24" # your office/admin ranges
	

An empty sourceCIDRs is rejected, so a policy can never open the load-balancer path by accident. If you mean the whole internet, write both 0.0.0.0/0 and ::/0. Matching is address-family strict: 0.0.0.0/0 covers only IPv4 clients, so on its own it drops every IPv6 client. See Address families and the lockout risk.

Create the secret #

Create the Secret in the cluster's namespace on the management cluster:

		$ kubectl -n <namespace> create secret generic kubegate-policy \
  --from-file=policy.yaml=policy.yaml
	

Enable the policy #

Set the kubeGatePolicySecretRef variable on the Cluster object:

yaml
		spec:
  topology:
    variables:
      - name: kubeGatePolicySecretRef
        value:
          enabled: true
          name: kubegate-policy # the Secret name
          key: policy.yaml # the key inside it
	

This rolls the control plane so each KubeGate writes /etc/kubegate/policies.d/policy.yaml and loads it.

Wait for the rollout #

		$ kubectl -n <namespace> get kubeadmcontrolplane <name> -w
	

Wait until every control-plane machine is up to date.

Validate it #

Reach the API server through the load balancer from an IP that is not on the allow-list. KubeGate drops the connection before the TLS handshake finishes, so you get a connection error, not an HTTP response:

		$ curl -sk https://<control-plane-endpoint>:443/healthz
# expect: a curl TLS/connection error, no HTTP status
	

From an allow-listed IP, the same request reaches the API server, which answers:

		$ curl -sk https://<control-plane-endpoint>:443/healthz -w '%{http_code}\n'
# expect: 401 (the API server responded; it rejects the missing credential)
	

That difference is the proof. A denied client never reaches the API server. An allowed client does, and the 401 is the API server, not KubeGate. Your kubectl keeps working from an allow-listed IP, and the management plane keeps working from anywhere, because it presents a gate ticket.

Read the result this way:

  • A dropped or timed-out connection (no HTTP status) means KubeGate blocked you: your source address and family are not in sourceCIDRs.
  • A connection refused means nothing is listening: wrong host or port, not a policy decision.
  • Any HTTP reply, even 401, means you reached the API server: the policy let you through.

If the endpoint is a DNS name, test each address family on its own so you know which one your allow-list is missing. --resolve pins the name to one address and skips DNS:

		# IPv4 path to the load balancer
$ curl -sk --resolve <endpoint>:443:<lb-ipv4> https://<endpoint>:443/livez -w '%{http_code}\n'
# IPv6 path to the same load balancer
$ curl -sk --resolve <endpoint>:443:<lb-ipv6> https://<endpoint>:443/livez -w '%{http_code}\n'
	

If one family answers and the other drops, that family is missing from your allow-list. Read Address families and the lockout risk next.

Update or remove the policy later#

Changing the Secret's content alone does not reach running nodes. The policy file is written from the Secret when a control plane provisions, so a running control plane keeps the version it booted with.

To push a changed policy, edit the Secret, then roll the control plane so each machine re-reads it. Trigger the roll by setting rollout.after on the Cluster to the current time:

		$ kubectl -n <namespace> patch cluster <cluster-name> --type merge \
  -p "{\"spec\":{\"topology\":{\"controlPlane\":{\"rollout\":{\"after\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}}}}"
	

rollout.after replaces every control-plane machine created before that timestamp, one at a time. It does this even though the rest of the Cluster spec has not changed. Each replacement control plane re-reads the Secret through contentFrom and writes the new policy. Watch it with kubectl -n <namespace> get kubeadmcontrolplane <name> -w.

To turn the policy off, set enabled: false on the kubeGatePolicySecretRef variable. That changes the control-plane spec and rolls on its own.

The platform is never locked out#

You set a policy for your own clients. The platform's own traffic is handled for you, so a policy can never lock the management plane or the cluster's own nodes out:

  • The management plane reaches the API server with a gate ticket the platform adds for it. A ticket is checked before the policy, so the management plane passes whatever the allow-list says.
  • A joining node has no cluster identity yet, so it also crosses the load balancer with a gate ticket and passes the same way.
  • A running node that reaches the API server through the load balancer is admitted by its own IP, which KubeGate reads from the node list the platform maintains (nodes.json). This is also checked before the policy.

You never list any of these in sourceCIDRs. The allow-list is only for your clients.

Address families and the lockout risk#

The one way a policy hurts you is by locking yourself out, and it happens through IPv6. KubeGate stays fail-closed the whole time: it never lets a blocked client through, it only drops a client you did not mean to drop.

Matching is address-family strict. An IPv4 range never matches an IPv6 client, and an IPv6 range never matches an IPv4 client. KubeGate reads the real client address from the load balancer's PROXY header, so it sees the exact family your client used.

The default control-plane endpoint is the load balancer's IPv4 literal, so clients dial IPv4 only and this never comes up. It starts to matter when the endpoint is a DNS name with an AAAA record: kubectl often prefers IPv6, reaches the load balancer over IPv6, and an allow-list that lists only your IPv4 drops it. The connection is dropped, not refused, and the API server never sees it.

These rules keep you out of that trap:

  • To allow the whole internet, list both 0.0.0.0/0 and ::/0. One default route covers only its own family.
  • To allow one admin on a dual-stack endpoint, list both their IPv4 (/32) and their IPv6. Use the /128 for a fixed address, or the /64 your ISP rotates you within.

If you are already locked out, confirm which family is missing with the per-family curl --resolve test in Step 5 above, then correct the policy Secret to include it and re-apply.

The endpoint name must resolve from the management side#

If your endpoint is a DNS name, it has to resolve to the load balancer from where the platform's controllers run, not just from your laptop. Those controllers manage the cluster through the same endpoint name. If the name does not point at the load balancer for them, the cluster cannot be managed: provisioning never finishes and the control plane stays "not available". You may see log lines like cluster is not reachable ... Bad Gateway or DNS lookup failures against the endpoint name.

Publish a real, public A record for the endpoint name, pointing at the load balancer's IPv4, before or right after you create the cluster. Do not rely on a name that only your laptop can resolve (an /etc/hosts entry, or a private DNS only your network can see).

Do not publish an AAAA. Clusters run single-stack IPv4 today: nodes boot with ipv6.disable=1 and only the load balancer's IPv4 is attached, so an IPv6 client cannot be attributed and an AAAA record is the main cause of the accidental lockout described above.

		$ dig +short A    <endpoint>   # must return the load balancer's IPv4
$ dig +short AAAA <endpoint>   # should return nothing
	

Check metrics and audit the denials#

KubeGate writes an audit line for every connection it handles, allowed or denied, to /var/log/kubegate/audit.log on each control plane (audit level: all). Filter for denials rather than tailing the whole file:

		$ grep '"decision":"deny"' /var/log/kubegate/audit.log | tail -n 20
	

Each line names why a connection was dropped and who it was:

json
		{
	"time": "2026-07-22T08:48:06Z",
	"decision": "deny",
	"gate": "source",
	"reason": "source_blocked",
	"clientIP": "195.201.142.32",
	"clientIPSource": "proxy",
	"peerIP": "49.12.18.192"
}
	
  • reason: source_blocked means the policy dropped it (the source was not in sourceCIDRs and it carried no ticket). This is also what your own lockout looks like: if you reached the load balancer over a family your allow-list does not cover, KubeGate logs you exactly like a foreign client. Check the clientIP family against your sourceCIDRs before you assume it was a stranger.
  • clientIP is the real client address, and it can be IPv6 (for example 2001:db8::1) when the client dialed the endpoint over IPv6. clientIPSource: proxy means that address came from the load balancer's PROXY header, which a client cannot spoof. If clientIPSource ever shows any other value while a policy is loaded, PROXY protocol is off and the policy can be bypassed. See the proxy-protocol note below.

KubeGate also serves Prometheus metrics on 127.0.0.1:8080, bound to loopback like the other control-plane components (scrape them with the DaemonSet from ). These are worth watching once a policy is on:

  • kubegate_policies_loaded is 1 when a policy is active and 0 when KubeGate forwards everything. Use it to confirm the policy loaded.
  • kubegate_proxy_protocol_total{source="proxy"} must be above zero while a policy is loaded. If it stays at zero, the load balancer is not sending PROXY headers, KubeGate is reading spoofable socket addresses, and the policy is bypassable.
  • kubegate_routes_total{gate,result} breaks down every decision by gate: gate="source",result="dropped" is the policy blocking a client, while gate="ticket" and gate="nodes" forwarded are the management plane and the cluster's own nodes passing through.

Things to know#

  • The policy gates only the load-balancer path. Direct access to the API server on a node is already blocked by the host firewall.
  • Proxy protocol must be active for KubeGate to tell clients apart. The platform turns it on once every control plane runs KubeGate, so you do not configure it. Turn a policy on only after that, or a policy is bypassable.