Round-2 review findings, all low severity. - `curl` now has `--connect-timeout` and `--max-time`, so an unreachable API server fails the job instead of hanging the runner indefinitely. - A guard for a kubeconfig with no cluster entries, or an empty one, with a clear message rather than an opaque kubectl error. - The fingerprint mismatch message now prints both the expected and the actual value, which cuts the time to diagnose the next CA rotation. - `set -euo pipefail` was already there; the script now passes shellcheck clean, and a shellcheck step was added to the CI test job so the operational scripts get the same treatment as the Python code.
125 lines
4.6 KiB
Bash
Executable File
125 lines
4.6 KiB
Bash
Executable File
#!/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
|
|
# pinned in scripts/cluster-ca.sha256. 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.
|
|
#
|
|
# The fingerprint is a PUBLIC value - it can be read straight off the
|
|
# certificate - so it lives in the repo rather than in a secret. That way a
|
|
# change to it is a reviewable commit, and nobody with secret-write access
|
|
# can silently repoint the CI runner at a different CA.
|
|
#
|
|
# The expected fingerprint is read from scripts/cluster-ca.sha256 next to
|
|
# this script. It can be overridden with the first argument.
|
|
#
|
|
# Usage:
|
|
# kube-ca-refresh.sh [expected-fingerprint]
|
|
#
|
|
# Exit codes:
|
|
# 0 the cluster is reachable
|
|
# 1 the cluster is not reachable and could not be fixed safely
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
EXPECTED="${1:-}"
|
|
if [ -z "$EXPECTED" ] && [ -f "$SCRIPT_DIR/cluster-ca.sha256" ]; then
|
|
# The file may carry comments; take the last non-comment line.
|
|
EXPECTED=$(grep -vE '^[[:space:]]*(#|$)' "$SCRIPT_DIR/cluster-ca.sha256" | tail -1 | tr -d '[:space:]')
|
|
fi
|
|
|
|
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 " and no pinned CA fingerprint could be found." >&2
|
|
echo " Set one in scripts/cluster-ca.sha256:" >&2
|
|
echo " openssl x509 -in server-ca.crt -noout -fingerprint -sha256" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Guard against a kubeconfig with no clusters, or an empty one.
|
|
CLUSTER_COUNT=$(kubectl config view --raw -o jsonpath='{.clusters}' 2>/dev/null \
|
|
| grep -c '"name"' || true)
|
|
if [ "${CLUSTER_COUNT:-0}" -eq 0 ]; then
|
|
echo "ERROR: the kubeconfig has no cluster entries." >&2
|
|
echo " The KUBEC_CONFIG_BUILDX_NEW_2 secret looks wrong or empty." >&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}')
|
|
|
|
if [ -z "$SERVER" ] || [ -z "$CLUSTER_NAME" ]; then
|
|
echo "ERROR: could not read the server or cluster name from the kubeconfig." >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "server: $SERVER cluster entry: $CLUSTER_NAME"
|
|
|
|
CA_FILE=$(mktemp)
|
|
trap 'rm -f "$CA_FILE"' EXIT
|
|
|
|
# Timeouts so an unreachable API server fails the job rather than hanging
|
|
# the runner indefinitely.
|
|
curl -sk --connect-timeout 10 --max-time 30 "$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 '[:upper:]' '[:lower:]'
|
|
}
|
|
|
|
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 " expected: $WANTED" >&2
|
|
echo " actual: $ACTUAL" >&2
|
|
echo " Refusing to use it. Either the CA has been rotated and the" >&2
|
|
echo " pinned fingerprint 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
|