mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 11:21:49 +00:00
2d4f0fa94c
* feat: IMDS-capable gateway signer for lb-agent + EKS clients Add internal/gwsign, a SigV4 signer that draws credentials from either fixed static keys or the AWS SDK default credential chain. In a guest VM the chain falls back to the in-VM IMDS endpoint, yielding scoped, auto-rotating instance-role credentials; a session token is signed as X-Amz-Security-Token so the gateway can verify temporary (ASIA) creds. Wire it into the lb-agent (lbagent.Agent) and the EKS on-VM client (internal/eksgw). When static keys are absent the clients now sign with IMDS credentials instead of failing; when present, behaviour is unchanged (static path). This is the client-side foundation for dropping long-lived secrets from VM user-data. Credentials are retrieved per request so rotated IMDS creds take effect without an agent restart. * feat: IMDS instance-role creds for EKS server + LB VMs (Phase B/C) Attach a Spinifex-managed system instance profile to the k3s control-plane VM and the elbv2 LB VM so both authenticate to the gateway with scoped, rotating IMDS instance-role credentials instead of a long-lived static secret baked into user-data. - Add a shared handlers/iam.EnsureSystemInstanceProfile that find-or-creates a role (EC2 trust) plus its instance profile and asserts an inline permission policy. The gateway enforces per-action authz on assumed-role principals, so the roles carry exactly the actions each VM calls: - EKS server: eks:PublishInternal, eks:ListInternalAddons. - LB agent: elasticloadbalancing:LBAgentHeartbeat, GetLBConfig. - Plumb IamInstanceProfileArn through sysinstance.SystemInstanceInput and both daemon launch paths (BootAMI via PrepareRunInstances->RunInstance; direct-boot microVM via RunInstance), which persist it so the in-VM IMDS endpoint serves the role. - Drop the baked secrets when a profile is attached: omit EKS_ACCESS_KEY/ EKS_SECRET_KEY from the k3s first-boot env and LB_ACCESS_KEY/LB_SECRET_KEY from the lb-agent fw_cfg env; relax k3s server validation to accept profile-only; drop the EKS key requires from the first-boot and state-report scripts. - Both verticals fall back to baked static creds when IAM is unwired, so an absent master key never blocks a launch. * fix: resolve IMDS instance-role creds under the instance account for system VMs Three boot-path defects kept the LB VM's lb-agent from authenticating to the gateway with IMDS-served instance-role creds; each produced a silent empty IMDS role list and a heartbeat failing with "unexpected empty EC2 IMDS role list". Found and fixed against env19. - Build the system IAM ensurer lazily on the first system-VM launch instead of eagerly at daemon startup, where it raced the NATS KV backend and fell back to baked static creds permanently. ELBv2 and the EKS deps now hold an IAMProvider resolved at launch time. - Create the LB instance profile in the system account the LB VM runs under (utils.GlobalAccountID), not the LB-owner account. IMDS resolves the profile under the instance's account, so a customer-account role was invisible to it. - Record the attached instance's owning account on the ENI (InstanceOwnerId, mirroring AWS Attachment.InstanceOwnerId) and resolve the instance and every IAM/STS lookup under it. The LB ENI lives in the customer account while the system LB VM and its role live in the system account; keying the whole IMDS chain off the ENI account left the instance invisible and the role lookup missing. VPC leaves (subnet/VPC/SG CIDR) keep using the ENI account. A fresh LB on env19 then authenticated end-to-end: IMDS serves a non-empty role list plus ASIA creds, and the gateway accepts the heartbeat as an assumed-role principal with no static keys in user-data. * test(e2e): assert LB VM authenticates via IMDS instance-role creds The LB suite's targets-healthy assertion already depends on the lb-agent authenticating to the gateway, and with static keys dropped from user-data that auth is IMDS-only. Add an explicit check that a recent gateway authentication for the LB used an assumed-role (ASIA) principal, so a regression to the instance-account IMDS resolution fails with a clear message instead of a mysterious datapath timeout. The LB VM is a system-account instance invisible to the customer AWS API, so the assertion observes the gateway journal on the cluster nodes; it is time-bounded to avoid the stale-console-log false-fail and skips when no node is SSH-reachable.
142 lines
6.6 KiB
Bash
Executable File
142 lines
6.6 KiB
Bash
Executable File
#!/bin/sh
|
|
set -eu
|
|
|
|
# k3s-first-boot — runs once after the K3s server reaches a healthy state.
|
|
# Reads the bootstrap node-token and admin kubeconfig that K3s writes at
|
|
# server startup, rewrites the kubeconfig server address to the cluster's
|
|
# NLB endpoint (so workers and external kubectl can use it), and publishes
|
|
# the bootstrap artifacts to the host via the AWS gateway (SigV4-signed HTTPS
|
|
# POST — the eks-gateway-publish helper) for the spinifex cluster reconciler
|
|
# to consume into KV. The gateway relays onto the eks.bus.* NATS subjects; the
|
|
# VM never speaks NATS directly.
|
|
#
|
|
# Required env (from cloud-init user-data /etc/spinifex-eks/first-boot.env):
|
|
# EKS_GATEWAY_URL https://{mgmt-host}:9999 (AWS gateway)
|
|
# EKS_GATEWAY_CA /etc/spinifex-eks/gateway-ca.pem (TLS CA bundle)
|
|
# EKS_ACCESS_KEY system SigV4 access key id
|
|
# EKS_SECRET_KEY system SigV4 secret access key
|
|
# EKS_REGION SigV4 signing region
|
|
# EKS_ACCOUNT_ID
|
|
# EKS_CLUSTER_NAME
|
|
# EKS_NLB_ENDPOINT https://{cluster}.{accountID}.eks.{region}.{suffix}
|
|
#
|
|
# Idempotent: a sentinel file at /var/lib/spinifex-eks/first-boot.pending gates
|
|
# execution. On success the sentinel is removed and the OpenRC service is
|
|
# pulled from the default runlevel so it does not retry on subsequent boots.
|
|
|
|
SENTINEL=/var/lib/spinifex-eks/first-boot.pending
|
|
ENVFILE=/etc/spinifex-eks/first-boot.env
|
|
LOGTAG="k3s-first-boot"
|
|
|
|
log() { echo "[${LOGTAG}] $*"; }
|
|
die() { log "ERROR: $*"; exit 1; }
|
|
|
|
if [ ! -f "${SENTINEL}" ]; then
|
|
log "sentinel missing — first boot already complete"
|
|
exit 0
|
|
fi
|
|
|
|
if [ ! -f "${ENVFILE}" ]; then
|
|
die "${ENVFILE} not found — cloud-init did not seed first-boot env"
|
|
fi
|
|
|
|
# set -a so the sourced KEY=value lines are exported: the eks-gateway-publish
|
|
# child reads its config from the environment (EKS_ACCOUNT_ID etc.). A bare
|
|
# source leaves them unexported and the helper exits "--account-id is required".
|
|
set -a
|
|
# shellcheck disable=SC1090
|
|
. "${ENVFILE}"
|
|
set +a
|
|
|
|
# EKS_ACCESS_KEY/EKS_SECRET_KEY are intentionally not required: when absent,
|
|
# eks-gateway-publish signs with IMDS instance-role creds via the SDK chain.
|
|
for v in EKS_GATEWAY_URL EKS_ACCOUNT_ID EKS_CLUSTER_NAME EKS_NLB_ENDPOINT; do
|
|
eval "val=\${$v:-}"
|
|
[ -n "${val}" ] || die "env ${v} not set"
|
|
done
|
|
|
|
# Paths K3s writes during server startup: the bootstrap node-token and the
|
|
# admin kubeconfig (used both to gate readiness and to publish downstream).
|
|
TOKEN_FILE=/var/lib/rancher/k3s/server/node-token
|
|
KUBECONFIG_FILE=/etc/rancher/k3s/k3s.yaml
|
|
|
|
# 1. Wait for the apiserver to be ready to serve, gating on /readyz. K3s runs
|
|
# the apiserver with anonymous-auth=false, so an anonymous probe returns 401,
|
|
# never "ok" — the probe must be authenticated. Use the node's admin
|
|
# kubeconfig (kubectl get --raw), which k3s writes early in startup. kubectl
|
|
# exits 0 only when /readyz returns 200; on a failing sub-check it exits
|
|
# non-zero, so the body "ok" check plus exit status gates correctly. Every
|
|
# 30s the failing /readyz checks are logged so a stuck boot is diagnosable
|
|
# from the captured serial console.
|
|
log "waiting for K3s apiserver readiness (/readyz) on 127.0.0.1:6443"
|
|
i=0
|
|
ready=0
|
|
while [ "${i}" -lt 300 ]; do
|
|
if [ -r "${KUBECONFIG_FILE}" ] && \
|
|
[ "$(kubectl --kubeconfig "${KUBECONFIG_FILE}" get --raw='/readyz' 2>/dev/null)" = "ok" ]; then
|
|
log "K3s apiserver ready after ${i}s"
|
|
ready=1
|
|
break
|
|
fi
|
|
if [ $((i % 30)) -eq 0 ]; then
|
|
log "apiserver not ready after ${i}s:"
|
|
if [ -r "${KUBECONFIG_FILE}" ]; then
|
|
kubectl --kubeconfig "${KUBECONFIG_FILE}" get --raw='/readyz?verbose' 2>&1 \
|
|
| grep -E '^\[-\]|failed$' | head -20 | while IFS= read -r l; do log " ${l}"; done
|
|
else
|
|
log " (admin kubeconfig ${KUBECONFIG_FILE} not written yet)"
|
|
fi
|
|
fi
|
|
i=$((i + 5))
|
|
sleep 5
|
|
done
|
|
if [ "${ready}" -ne 1 ]; then
|
|
log "K3s apiserver not ready within 5 minutes; last /readyz body:"
|
|
kubectl --kubeconfig "${KUBECONFIG_FILE}" get --raw='/readyz?verbose' 2>&1 \
|
|
| head -40 | while IFS= read -r l; do log " ${l}"; done
|
|
log "last 40 lines of /var/log/k3s.log follow:"
|
|
tail -n 40 /var/log/k3s.log 2>/dev/null || log "(no /var/log/k3s.log)"
|
|
die "K3s apiserver not ready within 5 minutes"
|
|
fi
|
|
|
|
# 2. Read the four bootstrap artifacts K3s wrote at server startup.
|
|
[ -r "${TOKEN_FILE}" ] || die "${TOKEN_FILE} unreadable"
|
|
[ -r "${KUBECONFIG_FILE}" ] || die "${KUBECONFIG_FILE} unreadable"
|
|
|
|
NODE_TOKEN=$(cat "${TOKEN_FILE}")
|
|
# K3s ships kubeconfig with server: https://127.0.0.1:6443 — rewrite to the
|
|
# NLB endpoint so it works from outside the control plane VM.
|
|
KUBECONFIG_REWRITTEN=$(sed "s|server: https://127\.0\.0\.1:6443|server: ${EKS_NLB_ENDPOINT}|" "${KUBECONFIG_FILE}")
|
|
# The cluster CA the daemon records (base64 PEM) is exactly the
|
|
# certificate-authority-data the admin kubeconfig already embeds.
|
|
CA_B64=$(awk '/certificate-authority-data:/ {print $2; exit}' "${KUBECONFIG_FILE}")
|
|
[ -n "${CA_B64}" ] || die "no certificate-authority-data in ${KUBECONFIG_FILE}"
|
|
# The OIDC JWKS the apiserver serves from the signing key cloud-init seeded. The
|
|
# daemon cross-checks its kid/kty against the controller-generated keypair.
|
|
JWKS=$(kubectl --kubeconfig "${KUBECONFIG_FILE}" get --raw='/openid/v1/jwks' 2>/dev/null)
|
|
[ -n "${JWKS}" ] || die "apiserver returned empty /openid/v1/jwks"
|
|
|
|
# 3. Publish the four bootstrap messages through the AWS gateway. Each is a
|
|
# BootstrapEnvelope JSON document (handlers/eks/nats_bootstrap.go); jq encodes
|
|
# the values so embedded newlines/quotes in the kubeconfig and JWKS stay valid
|
|
# JSON. eks-gateway-publish SigV4-signs the POST and retries until the gateway
|
|
# acks, then the gateway relays onto eks.bus.{account}.{cluster}.{kind}. It
|
|
# reads EKS_GATEWAY_URL/EKS_GATEWAY_CA/EKS_ACCESS_KEY/EKS_SECRET_KEY/EKS_REGION/
|
|
# EKS_ACCOUNT_ID/EKS_CLUSTER_NAME from the env file already sourced above.
|
|
|
|
# publish_envelope <kind-suffix>: reads the envelope JSON from stdin.
|
|
publish_envelope() {
|
|
log "publishing $1 via gateway"
|
|
eks-gateway-publish -channel bootstrap -kind "$1"
|
|
}
|
|
|
|
jq -n --arg t "${NODE_TOKEN}" '{token: $t}' | publish_envelope k3s-bootstrap-token
|
|
jq -n --arg k "${KUBECONFIG_REWRITTEN}" '{adminKubeconfig: $k}' | publish_envelope k3s-admin-kubeconfig
|
|
jq -n --arg j "${JWKS}" '{jwks: $j}' | publish_envelope k3s-oidc-jwks
|
|
jq -n --arg c "${CA_B64}" '{certificateAuthority: $c}' | publish_envelope k3s-ca
|
|
|
|
# 4. Self-disable. Remove sentinel, pull from runlevel.
|
|
rm -f "${SENTINEL}"
|
|
rc-update del k3s-first-boot default 2>/dev/null || true
|
|
log "first boot complete"
|