Skip to content

Commit 765591c

Browse files
committed
Detect SA token rotation
Fixes linkerd/linkerd2#12573 ## Problem When deployed, the linkerd-cni pod gets its service account token mounted automatically by k8s: ```yaml - name: kube-api-access-729gv projected: defaultMode: 420 sources: - serviceAccountToken: expirationSeconds: 3607 path: token - configMap: items: - key: ca.crt path: ca.crt name: kube-root-ca.crt - downwardAPI: items: - fieldRef: apiVersion: v1 fieldPath: metadata.namespace path: namespace ``` According to this, the token is set to expire after an hour. When the linkerd-cni pod starts it deploys the file `ZZZ-linkerd-cni-kubeconfig` in to the **host** file system. That config contains the token sourced from `/var/run/secrets/kubernetes.io/serviceaccount` (mounted by the pod). When the token gets rotated after an hour, that token file is updated but `ZZZ-linkerd-cni-kubeconfig` is not updated. The `linkerd-cni` binary uses that token to connect to the kube-api, so having an outdated token should forbid it from functioning properly, which would manifest as new pods in the data plane not being able to acquire a proper network config. However, that failure isn't usually observed, except for the cases pointed out in linkerd/linkerd2#12573. The reason is that the token's actual lifetime is one year, due to kube-api's `--service-account-extend-token-expiration` [flag](https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/#options) which is usually set as `true` to avoid breaking too many instances not yet adapted to use tokens with short expirations: > Turns on projected service account expiration extension during token generation, which helps safe transition from legacy token to bound service account token feature. If this flag is enabled, admission injected tokens would be extended up to 1 year to prevent unexpected failure during transition, ignoring value of service-account-max-token-expiration. ## Repro ### AKS The issue currently affects AKS clusters using OIDC keys. To reproduce, create a new cluster in AKS, making sure "Enable OIDC" and "Workload Identity" is ticked in the UI. Then install the linkerd-cni plugin, labelling the linkerd-cni DaemonSet so that its ServiceAccount token is provided via OIDC: ``` linkerd install-cni --set-string "podLabels.azure\.workload\.identity/use"="true" | kubectl apply -f - ``` And install linkerd with cni enabled, and an injected instance of emojivoto. The secret token is rotated after an hour, but the old one remains valid for a 24h. Manually rotating the key as detailed in the [docs](https://learn.microsoft.com/en-us/azure/aks/use-oidc-issuer#rotate-the-oidc-key) should invalidate the old key. After that, bouncing any emojivoto pod will prove unsuccessful with the following event being raised: ``` Warning FailedCreatePodSandBox 15s kubelet Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "8121291446642b272cea9ee5f083958a37bab0dd7060c4d9c06bb05fecf911d2": plugin type="linkerd-cni" name="linkerd-cni" failed (add): Unauthorized ``` ## Fix This change adds a new function `monitor_service_account_token()` that monitors the rollout of the token file; which is a symlink whose target changes as a new token is deployed. When detecting a new token file, this function calls the new `create_kubeconfig()` function. This change also removes the existing logic around the DELETE event, which is a leftover from previous changes and is now a no-op. Also, as detailed in linkerd/linkerd2#13407, the ServiceAccount token has been removed from the cni config template because it's not used, simplifying things as we can regenerate the kubeconfig file without having to touch the cni config file. Finally, the file `linkerd-cni.conf.default` has been removed as is not used. ## Test Same as with the repro above, but use the cni-plugin image that contains the fix: ``` linkerd install-cni --set-string "podLabels.azure\.workload\.identity/use"="true" --set image.name="ghcr.io/alpeb/cni-plugin" --set image.version="v1.5.3" | kubectl apply -f - ``` After an hour when the token gets rotated you should see the event in the linkerd-cni pod logs.
1 parent 30889be commit 765591c

File tree

3 files changed

+75
-101
lines changed

3 files changed

+75
-101
lines changed

Dockerfile-cni-plugin

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ COPY --from=go /go/bin/linkerd-cni /opt/cni/bin/
4848
COPY --from=cni-repair-controller /build/linkerd-cni-repair-controller /usr/lib/linkerd/
4949
COPY LICENSE .
5050
COPY cni-plugin/deployment/scripts/install-cni.sh .
51-
COPY cni-plugin/deployment/linkerd-cni.conf.default .
5251
COPY cni-plugin/deployment/scripts/filter.jq .
5352
ENV PATH=/linkerd:/opt/cni/bin:$PATH
5453
CMD ["install-cni.sh"]

cni-plugin/deployment/linkerd-cni.conf.default

Lines changed: 0 additions & 24 deletions
This file was deleted.

cni-plugin/deployment/scripts/install-cni.sh

Lines changed: 75 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ HOST_CNI_NET="${CONTAINER_MOUNT_PREFIX}${DEST_CNI_NET_DIR}"
5656
# Location of legacy "interface mode" file, to be automatically deleted
5757
DEFAULT_CNI_CONF_PATH="${HOST_CNI_NET}/01-linkerd-cni.conf"
5858
KUBECONFIG_FILE_NAME=${KUBECONFIG_FILE_NAME:-ZZZ-linkerd-cni-kubeconfig}
59+
SERVICEACCOUNT_PATH=/var/run/secrets/kubernetes.io/serviceaccount
5960

6061
############################
6162
### Function definitions ###
@@ -119,56 +120,32 @@ install_cni_bin() {
119120
log "Wrote linkerd CNI binaries to ${dir}"
120121
}
121122

122-
create_cni_conf() {
123-
# Create temp configuration and kubeconfig files
124-
#
125-
TMP_CONF='/tmp/linkerd-cni.conf.default'
126-
# If specified, overwrite the network configuration file.
127-
CNI_NETWORK_CONFIG_FILE="${CNI_NETWORK_CONFIG_FILE:-}"
128-
CNI_NETWORK_CONFIG="${CNI_NETWORK_CONFIG:-}"
123+
create_kubeconfig() {
124+
KUBE_CA_FILE=${KUBE_CA_FILE:-${SERVICEACCOUNT_PATH}/ca.crt}
125+
SKIP_TLS_VERIFY=${SKIP_TLS_VERIFY:-false}
126+
SERVICEACCOUNT_TOKEN=$(cat ${SERVICEACCOUNT_PATH}/token)
129127

130-
# If the CNI Network Config has been overwritten, then use template from file
131-
if [ -e "${CNI_NETWORK_CONFIG_FILE}" ]; then
132-
log "Using CNI config template from ${CNI_NETWORK_CONFIG_FILE}."
133-
cp "${CNI_NETWORK_CONFIG_FILE}" "${TMP_CONF}"
134-
elif [ "${CNI_NETWORK_CONFIG}" ]; then
135-
log 'Using CNI config template from CNI_NETWORK_CONFIG environment variable.'
136-
cat >"${TMP_CONF}" <<EOF
137-
${CNI_NETWORK_CONFIG}
138-
EOF
128+
# Check if we're not running as a k8s pod.
129+
if [[ ! -f "${SERVICEACCOUNT_PATH}/token" ]]; then
130+
return
139131
fi
140132

141-
SERVICE_ACCOUNT_PATH=/var/run/secrets/kubernetes.io/serviceaccount
142-
KUBE_CA_FILE=${KUBE_CA_FILE:-${SERVICE_ACCOUNT_PATH}/ca.crt}
143-
SKIP_TLS_VERIFY=${SKIP_TLS_VERIFY:-false}
144-
# Pull out service account token.
145-
SERVICEACCOUNT_TOKEN=$(cat ${SERVICE_ACCOUNT_PATH}/token)
146-
147-
# Check if we're running as a k8s pod.
148-
# The check will assert whether token exists and is a regular file
149-
if [ -f "${SERVICE_ACCOUNT_PATH}/token" ]; then
150-
# We're running as a k8d pod - expect some variables.
151-
# If the variables are null, exit
152-
if [ -z "${KUBERNETES_SERVICE_HOST}" ]; then
153-
log 'KUBERNETES_SERVICE_HOST not set'; exit 1;
154-
fi
155-
if [ -z "${KUBERNETES_SERVICE_PORT}" ]; then
156-
log 'KUBERNETES_SERVICE_PORT not set'; exit 1;
157-
fi
133+
if [ -z "${KUBERNETES_SERVICE_HOST}" ]; then
134+
log 'KUBERNETES_SERVICE_HOST not set'; exit 1;
135+
fi
136+
if [ -z "${KUBERNETES_SERVICE_PORT}" ]; then
137+
log 'KUBERNETES_SERVICE_PORT not set'; exit 1;
138+
fi
158139

159-
if [ "${SKIP_TLS_VERIFY}" = 'true' ]; then
160-
TLS_CFG='insecure-skip-tls-verify: true'
161-
elif [ -f "${KUBE_CA_FILE}" ]; then
162-
TLS_CFG="certificate-authority-data: $(base64 "${KUBE_CA_FILE}" | tr -d '\n')"
163-
fi
140+
if [ "${SKIP_TLS_VERIFY}" = 'true' ]; then
141+
TLS_CFG='insecure-skip-tls-verify: true'
142+
elif [ -f "${KUBE_CA_FILE}" ]; then
143+
TLS_CFG="certificate-authority-data: $(base64 "${KUBE_CA_FILE}" | tr -d '\n')"
144+
fi
164145

165-
# Write a kubeconfig file for the CNI plugin. Do this
166-
# to skip TLS verification for now. We should eventually support
167-
# writing more complete kubeconfig files. This is only used
168-
# if the provided CNI network config references it.
169-
touch "${CONTAINER_MOUNT_PREFIX}${DEST_CNI_NET_DIR}/${KUBECONFIG_FILE_NAME}"
170-
chmod "${KUBECONFIG_MODE:-600}" "${CONTAINER_MOUNT_PREFIX}${DEST_CNI_NET_DIR}/${KUBECONFIG_FILE_NAME}"
171-
cat > "${CONTAINER_MOUNT_PREFIX}${DEST_CNI_NET_DIR}/${KUBECONFIG_FILE_NAME}" <<EOF
146+
touch "${CONTAINER_MOUNT_PREFIX}${DEST_CNI_NET_DIR}/${KUBECONFIG_FILE_NAME}"
147+
chmod "${KUBECONFIG_MODE:-600}" "${CONTAINER_MOUNT_PREFIX}${DEST_CNI_NET_DIR}/${KUBECONFIG_FILE_NAME}"
148+
cat > "${CONTAINER_MOUNT_PREFIX}${DEST_CNI_NET_DIR}/${KUBECONFIG_FILE_NAME}" <<EOF
172149
# Kubeconfig file for linkerd CNI plugin.
173150
apiVersion: v1
174151
kind: Config
@@ -188,31 +165,36 @@ contexts:
188165
user: linkerd-cni
189166
current-context: linkerd-cni-context
190167
EOF
168+
}
191169

192-
fi
170+
create_cni_conf() {
171+
# Create temp configuration and kubeconfig files
172+
#
173+
TMP_CONF='/tmp/linkerd-cni.conf.default'
174+
# If specified, overwrite the network configuration file.
175+
CNI_NETWORK_CONFIG_FILE="${CNI_NETWORK_CONFIG_FILE:-}"
176+
CNI_NETWORK_CONFIG="${CNI_NETWORK_CONFIG:-}"
193177

194-
# Insert any of the supported "auto" parameters.
195-
grep '__KUBERNETES_SERVICE_HOST__' ${TMP_CONF} && sed -i s/__KUBERNETES_SERVICE_HOST__/"${KUBERNETES_SERVICE_HOST}"/g ${TMP_CONF}
196-
grep '__KUBERNETES_SERVICE_PORT__' ${TMP_CONF} && sed -i s/__KUBERNETES_SERVICE_PORT__/"${KUBERNETES_SERVICE_PORT}"/g ${TMP_CONF}
197-
# Check in container
198-
sed -i s/__KUBERNETES_NODE_NAME__/"${KUBERNETES_NODE_NAME:-$(hostname)}"/g ${TMP_CONF}
199-
sed -i s/__KUBECONFIG_FILENAME__/"${KUBECONFIG_FILE_NAME}"/g ${TMP_CONF}
200-
sed -i s/__CNI_MTU__/"${CNI_MTU:-1500}"/g ${TMP_CONF}
178+
# If the CNI Network Config has been overwritten, then use template from file
179+
if [ -e "${CNI_NETWORK_CONFIG_FILE}" ]; then
180+
log "Using CNI config template from ${CNI_NETWORK_CONFIG_FILE}."
181+
cp "${CNI_NETWORK_CONFIG_FILE}" "${TMP_CONF}"
182+
elif [ "${CNI_NETWORK_CONFIG}" ]; then
183+
log 'Using CNI config template from CNI_NETWORK_CONFIG environment variable.'
184+
cat >"${TMP_CONF}" <<EOF
185+
${CNI_NETWORK_CONFIG}
186+
EOF
187+
fi
201188

202189
# Use alternative command character "~", since these include a "/".
203190
sed -i s~__KUBECONFIG_FILEPATH__~"${DEST_CNI_NET_DIR}/${KUBECONFIG_FILE_NAME}"~g ${TMP_CONF}
204191

205-
# Log the config file before inserting service account token.
206-
# This way auth token is not visible in the logs.
207192
log "CNI config: $(cat ${TMP_CONF})"
208-
209-
sed -i s/__SERVICEACCOUNT_TOKEN__/"${SERVICEACCOUNT_TOKEN:-}"/g ${TMP_CONF}
210193
}
211194

212195
install_cni_conf() {
213196
local cni_conf_path=$1
214-
215-
create_cni_conf
197+
216198
local tmp_data=''
217199
local conf_data=''
218200
if [ -e "${cni_conf_path}" ]; then
@@ -257,14 +239,7 @@ sync() {
257239

258240
local config_file_count
259241
local new_sha
260-
if [ "$ev" = 'DELETE' ]; then
261-
# When the event type is 'DELETE', we check to see if there are any `*conf` or `*conflist`
262-
# files on the host's filesystem.
263-
config_file_count=$(find "${HOST_CNI_NET}" -maxdepth 1 -type f \( -iname '*conflist' -o -iname '*conf' \) | sort | wc -l)
264-
if [ "$config_file_count" -eq 0 ]; then
265-
log "No active CNI configuration file found after $ev event"
266-
fi
267-
elif [ "$ev" = 'CREATE' ] || [ "$ev" = 'MOVED_TO' ] || [ "$ev" = 'MODIFY' ]; then
242+
if [ "$ev" = 'CREATE' ] || [ "$ev" = 'MOVED_TO' ] || [ "$ev" = 'MODIFY' ]; then
268243
# When the event type is 'CREATE', 'MOVED_TO' or 'MODIFY', we check the
269244
# previously observed SHA (updated with each file watch) and compare it
270245
# against the new file's SHA. If they differ, it means something has
@@ -273,7 +248,9 @@ sync() {
273248
if [ "$new_sha" != "$prev_sha" ]; then
274249
# Create but don't rm old one since we don't know if this will be configured
275250
# to run as _the_ cni plugin.
276-
log "New file [$filename] detected; re-installing"
251+
log "New/changed file [$filename] detected; re-installing"
252+
create_kubeconfig
253+
create_cni_conf
277254
install_cni_conf "$filepath"
278255
else
279256
# If the SHA hasn't changed or we get an unrecognised event, ignore it.
@@ -285,22 +262,40 @@ sync() {
285262
fi
286263
}
287264
288-
# Monitor will start a watch on host's CNI config directory
289-
monitor() {
290-
inotifywait -m "${HOST_CNI_NET}" -e create,delete,moved_to,modify |
265+
# monitor_cni_config starts a watch on the host's CNI config directory
266+
monitor_cni_config() {
267+
inotifywait -m "${HOST_CNI_NET}" -e create,moved_to,modify |
291268
while read -r directory action filename; do
292269
if [[ "$filename" =~ .*.(conflist|conf)$ ]]; then
293270
log "Detected change in $directory: $action $filename"
294271
sync "$filename" "$action" "$cni_conf_sha"
295-
# When file exists (i.e we didn't deal with a DELETE ev)
296-
# then calculate its sha to be used the next turn.
297-
if [[ -e "$directory/$filename" && "$action" != 'DELETE' ]]; then
272+
# calculate file SHA to use in the next iteration
273+
if [[ -e "$directory/$filename" ]]; then
298274
cni_conf_sha="$(sha256sum "$directory/$filename" | while read -r s _; do echo "$s"; done)"
299275
fi
300276
fi
301277
done
302278
}
303279
280+
# Kubernetes rolls out serviceaccount tokens by creating new directories
281+
# containing a new token file and re-creating the
282+
# /var/run/secrets/kubernetes.io/serviceaccount/token symlink pointing to it.
283+
# This function listens to creation events under the serviceaccount directory,
284+
# only reacting to direct creation of a "token" file, or creation of
285+
# directories containing a "token" file.
286+
monitor_service_account_token() {
287+
inotifywait -m "${SERVICEACCOUNT_PATH}" -e create |
288+
while read -r directory _ filename; do
289+
target=$(realpath "$directory/$filename")
290+
if [[ (-f "$target" && "${target##*/}" == "token") || (-d "$target" && -e "$target/token") ]]; then
291+
log "Detected creation of file in $directory: $filename; recreating kubeconfig file"
292+
create_kubeconfig
293+
else
294+
log "Detected creation of file in $directory: $filename; ignoring"
295+
fi
296+
done
297+
}
298+
304299
log() {
305300
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1"
306301
}
@@ -327,6 +322,8 @@ else
327322
find "${HOST_CNI_NET}" -maxdepth 1 -type f \( -iname '*conflist' -o -iname '*conf' \) -print0 |
328323
while read -r -d $'\0' file; do
329324
log "Installing CNI configuration for $file"
325+
create_kubeconfig
326+
create_cni_conf
330327
install_cni_conf "$file"
331328
done
332329
fi
@@ -349,5 +346,7 @@ fi
349346
# builtin, the reception of a signal for which a trap has been set will cause
350347
# the wait builtin to return immediately with an exit status greater than 128,
351348
# immediately after which the trap is executed."
352-
monitor &
353-
wait $!
349+
monitor_cni_config &
350+
monitor_service_account_token &
351+
# uses -n so that we exit when the first background job exits (when there's an error)
352+
wait -n

0 commit comments

Comments
 (0)