#!/usr/bin/env bash # # Make sure kubectl can talk to the cluster API server. # # The kubeconfig embeds the k3s server CA. If the cluster's CA has been # rotated since the secret was created, kubectl fails with: # # tls: failed to verify certificate: x509: certificate signed by unknown authority # # which reads like a network problem rather than a stale secret. This script # refreshes the CA in place. # # The refresh is PINNED: the server's CA is fetched over an unverified # connection, but it is only used if its SHA-256 fingerprint matches the value # in the KUBE_SERVER_CA_FINGERPRINT secret. An attacker able to intercept # the fetch cannot use it - their CA will not hash to the pinned fingerprint. # The runner has cluster-admin credentials, so trusting an unverified CA here # would be a privilege-escalation hole. # # Usage: # kube-ca-refresh.sh # # Exit codes: # 0 the cluster is reachable # 1 the cluster is not reachable and could not be fixed safely set -euo pipefail EXPECTED="${1:-}" if kubectl version --request-timeout=15s >/dev/null 2>&1; then echo "cluster reachable" exit 0 fi if [ -z "$EXPECTED" ]; then echo "ERROR: the kubeconfig cannot verify the API server, and no expected CA" >&2 echo " fingerprint was supplied, so the CA cannot be refreshed safely." >&2 echo " Set KUBE_SERVER_CA_FINGERPRINT to the SHA-256 fingerprint of the" >&2 echo " cluster's CA:" >&2 echo " openssl x509 -in server-ca.crt -noout -fingerprint -sha256" >&2 exit 1 fi SERVER=$(kubectl config view --raw -o jsonpath='{.clusters[0].cluster.server}') # The cluster entry's NAME - patching the CA into a *new* entry named # "default" would leave the original entry (and its stale CA) in use. CLUSTER_NAME=$(kubectl config view --raw -o jsonpath='{.clusters[0].name}') echo "server: $SERVER cluster entry: $CLUSTER_NAME" CA_FILE=$(mktemp) trap 'rm -f "$CA_FILE"' EXIT curl -sk "$SERVER/cacerts" -o "$CA_FILE" if [ ! -s "$CA_FILE" ]; then echo "ERROR: could not fetch the cluster CA from $SERVER/cacerts" >&2 exit 1 fi # Normalise both to lowercase hex without separators, so the comparison does # not depend on the colon-separated format openssl prints. normalise() { tr -d ':' | tr 'A-Z' 'a-z' } ACTUAL=$(openssl x509 -in "$CA_FILE" -noout -fingerprint -sha256 | cut -d= -f2 | normalise) WANTED=$(printf '%s' "$EXPECTED" | normalise) echo "expected CA fingerprint: $WANTED" echo "actual CA fingerprint: $ACTUAL" if [ "$ACTUAL" != "$WANTED" ]; then echo "ERROR: the cluster CA does not match the pinned fingerprint." >&2 echo " Refusing to use it. Either the CA has been rotated and the" >&2 echo " fingerprint secret needs updating, or the connection was" >&2 echo " intercepted." >&2 exit 1 fi # The fingerprint matches the pinned value, so this is the CA we expect. kubectl config set-cluster "$CLUSTER_NAME" \ --embed-certs=true \ --certificate-authority="$CA_FILE" >/dev/null if kubectl version --request-timeout=15s >/dev/null 2>&1; then echo "cluster reachable after refreshing the CA" exit 0 fi echo "ERROR: cannot reach the cluster API server even with a refreshed CA." >&2 echo " The kubeconfig secret may be stale or invalid." >&2 exit 1