Files
spinifex/cmd/eks-token-webhook/main.go
T
Josh Siviour 496424c41c test(eks): EKS control-plane E2E suite (Sprint 6g) (#304)
* test(eks): EKS control-plane e2e suite (Phase 1, API-level)

Add tests/e2e/eks/eks_test.go driving the EKS lifecycle against awsgw:
CreateCluster -> ACTIVE (endpoint + CA asserted), kubeconfig artifact
assembly, AccessEntry CRUD, get-token presign decode, DeleteCluster -> gone.

Harness: EKS SDK client on AWSClient + WaitForEKSCluster{Active,Deleted}
pollers. Wire e2e-eks into the Makefile and the single-node CI suite list.

* test(eks): Phase 2 kubectl get-nodes data-plane gate

Add the reachability shim (harness/eks_mgmt.go) that reads the control-plane
VM's br-mgmt IP from the cluster KV meta over NATS, plus a kubectl wrapper
(harness/kubectl.go) that skips when kubectl is absent. KubectlGetNodes points
kubectl at https://<mgmtIP>:6443 (insecure-skip-tls-verify; cert SANs the NLB
DNS, not the mgmt IP) with an aws eks get-token exec block and polls until the
control-plane node is Ready — the get-token webhook auth regression gate.

IRSA pod + the real NLB/Route53 passthrough are deferred to a follow-up.

* test(eks): tolerate doubled NotFound code + dial loopback NATS

DeleteCluster detection matched the SDK constant ResourceNotFoundException,
but awsgw emits ResourceNotFoundExceptionException on the wire — match the
NotFound code by substring so deletion is detected immediately instead of
timing out. The mgmt-IP shim dialed NATS.Host verbatim (0.0.0.0:4222), which
the serving cert does not SAN; rewrite the wildcard bind to loopback.

Validated live: all 6 subtests green, kubectl get nodes shows the
control-plane node Ready via the get-token webhook auth path.

* feat: build system images with libguestfs (no host block devices)

Replace the qemu-nbd + sudo mount + chroot + /dev-bind flow in
build-system-image.sh with libguestfs (virt-customize / virt-resize /
guestfish). Every operation runs in an isolated userspace appliance VM with
its own kernel, so the build touches no host block device, no host mount, and
needs no sudo — removing the failure class that wiped the host's /dev on shared
CI runners (a leaked /dev bind traversed by rm -rf).

Applies to all distros: alpine resizes via guestfish e2fsck+resize2fs, ubuntu
via virt-resize; customization (packages, binaries, files, setup script,
service enable, cache cleanup) is one ordered virt-customize invocation.
LIBGUESTFS_BACKEND=direct (no libvirt on runners).

Wire the eks-node AMI into the single-node e2e job: the runner builds the
device-free raw, ships it to the VM, and the VM imports it (predastore/NATS are
VM-only) so the eks suite's CreateCluster has an AMI to boot.

* feat(eks): bootstrap + state-report via AWS gateway broker

Converge the EKS control-plane → host channel onto the ELBv2 lb-agent model:
the K3s server VM publishes its bootstrap envelopes and periodic state reports
via SigV4-signed HTTPS POST to the AWS gateway, instead of dialing core NATS
directly. A new gateway action (PublishInternal, POST
/clusters/{name}/internal-publish) relays the payload verbatim onto the
existing eks.bus.* / eks.state.* subjects, so the daemon-side subscriber, KV
persistence/encryption, and reconciler are unchanged.

This removes the VM's need to reach NATS at all, so NATS can stay
cluster-internal behind its bind/firewall/mTLS controls — the prior direct
path broke when NATS was not VM-reachable (e2e CreateCluster timeout) and was
DDIL-fragile (fire-and-forget nats pub, no ack/retry).

- gateway/eks: PublishInternal broker + route; closed-set channel/kind validation
- cmd/eks-gateway-publish: one-shot SigV4 publisher (retry-until-ack) baked into
  the eks-node AMI; replaces the nats-cli publisher in k3s-first-boot.sh and
  mulga-eks-state-report.sh
- handlers/eks + daemon: hand the K3s server VM the mgmt-reachable gateway URL +
  system (Predastore) SigV4 creds + gateway CA instead of NATS URL/token/CA;
  new resolveSystemGatewayBaseURL (mgmt-bridge-first)
- eks-node image: build/install eks-gateway-publish, drop nats-cli download

* feat: migrate eks-token-webhook to the AWS gateway broker

The TokenReview webhook dialed core NATS directly (STS verify + AccessEntry
KV read). On a mgmt-only control-plane VM with NATS bound off-subnet that
fails, so the webhook exited at startup, the apiserver token-webhook
kubeconfig was never written, and the apiserver crash-looped — the cluster
never reached ACTIVE.

Converge it onto the same gateway-broker model as bootstrap/state-report and
ELBv2's lb-agent: the webhook SigV4-signs an HTTPS POST {accountId,token} to
/clusters/{name}/token-review, and the gateway runs the verify + lookup
host-side and returns the resolved identity. The VM now speaks no core NATS.

- internal/eksgw: shared on-VM SigV4 client (also adopted by
  eks-gateway-publish).
- handlers/eks/token_review.go: Authenticate decision core (moved from the
  webhook binary) + ResolveTokenReview host-side wiring.
- gateway/eks token-review route + WebhookTokenReview handler.
- webhook: drop NATS, relay via eksgw.Client; broker fault -> 503 (retryable)
  not a silent deny.
- initd: export EKS_GATEWAY_* / EKS_ACCESS_* creds, not SPINIFEX_NATS_*.

* fix: build eks-node helpers with GOFIPS140; cover token-review broker

eks-gateway-publish blank-imports internal/fipsboot, whose init() panics
unless the binary is built with GOFIPS140=v1.0.0. The eks-node AMI build
omitted the flag, so the binary paniced at first boot right after k3s came
up — k3s-first-boot (set -eu) died before publishing the bootstrap token and
the cluster never reached ACTIVE. Build both eks-node helpers with
GOFIPS140=v1.0.0, matching cmd/lb-agent.

Add NATS-backed unit tests for ResolveTokenReview and the WebhookTokenReview
gateway handler (verify responder + account KV) to cover the new broker path.

* fix: export first-boot env so eks-gateway-publish child sees it

k3s-first-boot.sh and mulga-eks-state-report.sh sourced /etc/spinifex-eks/
first-boot.env but never exported the vars, so the eks-gateway-publish child
process inherited an empty environment and exited '--account-id is required'.
The control-plane VM came up healthy but never published its bootstrap
envelopes, so the cluster never reached ACTIVE. Wrap the source in set -a /
set +a so the KEY=value lines export to child processes.

* fix: make awsgw start --host override actually bind

The 'awsgw start --host' override printed 'Overwriting awsgw host' but the
assignment was commented out, so AWSGW always bound the spinifex.toml host
(a specific WAN IP, e.g. 192.168.0.10). A system instance (EKS K3s control
plane, internal LB agent) has a mgmt-only NIC on-link to the br-mgmt IP with
no route to the WAN IP, so its SigV4 POSTs to the gateway were refused and the
cluster never bootstrapped. Honour --host (e2e passes 0.0.0.0:9999) so AWSGW
listens on all interfaces incl. br-mgmt; the serving cert already SANs every
local interface IP, and WAN clients still reach it.

* test(eks): install kubectl on e2e VM so KubectlGetNodes runs

The eks data-plane subtest (TestEKS/KubectlGetNodes) drives the full
aws eks get-token -> kubectl -> apiserver -> token-webhook -> gateway broker
-> STS verify path — the only end-to-end exercise of the token-review broker.
The e2e binaries run on the VM, where kubectl was absent, so
harness.NewKubectl t.Skip'd it and the path went unvalidated. Install a
checksum-verified kubectl (pinned to the k3s server version) on the VM,
gated on e2e-eks.
2026-06-07 20:42:25 +10:00

228 lines
7.7 KiB
Go

// eks-token-webhook is the K8s TokenReview webhook authenticator baked into
// the eks-server AMI. Per kubectl call the kube-apiserver POSTs the bearer
// token (produced by `aws eks get-token`); the webhook relays it through the
// AWS gateway broker (SigV4 HTTPS POST to /clusters/{name}/token-review), which
// host-side runs the STS verify (cross-cluster x-k8s-aws-id pin) + AccessEntry
// KV lookup and returns the resolved identity. The webhook never speaks core
// NATS — same gateway-broker model as ELBv2's lb-agent and eks-gateway-publish.
//
// It binds 127.0.0.1 only; the kube-apiserver is the sole client (loopback).
// On startup it writes the apiserver webhook kubeconfig (with its self-signed
// serving CA) so k3s can be pointed at it via
// --authentication-token-webhook-config-file.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"github.com/mulgadc/spinifex/internal/eksgw"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
)
// config is the webhook's runtime configuration, sourced from the first-boot
// env (/etc/spinifex-eks/first-boot.env) cloud-init seeds onto the VM.
type config struct {
addr string
gatewayURL string
gatewayCA string
accessKey string
secretKey string
region string
accountID string
clusterName string
certPath string
keyPath string
kubeconfig string
}
func loadConfig() (config, error) {
c := config{
addr: flagAddr,
gatewayURL: os.Getenv("EKS_GATEWAY_URL"),
gatewayCA: os.Getenv("EKS_GATEWAY_CA"),
accessKey: os.Getenv("EKS_ACCESS_KEY"),
secretKey: os.Getenv("EKS_SECRET_KEY"),
region: os.Getenv("EKS_REGION"),
accountID: os.Getenv("EKS_ACCOUNT_ID"),
clusterName: os.Getenv("EKS_CLUSTER_NAME"),
certPath: envOr("EKS_WEBHOOK_CERT", "/etc/spinifex-eks/token-webhook.crt"),
keyPath: envOr("EKS_WEBHOOK_KEY", "/etc/spinifex-eks/token-webhook.key"),
kubeconfig: envOr("EKS_WEBHOOK_KUBECONFIG", "/etc/spinifex-eks/token-webhook.kubeconfig"),
}
switch {
case c.gatewayURL == "":
return c, errors.New("EKS_GATEWAY_URL not set")
case c.accessKey == "" || c.secretKey == "":
return c, errors.New("EKS_ACCESS_KEY / EKS_SECRET_KEY not set")
case c.accountID == "":
return c, errors.New("EKS_ACCOUNT_ID not set")
case c.clusterName == "":
return c, errors.New("EKS_CLUSTER_NAME not set")
}
return c, nil
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
var flagAddr string
func main() {
flag.StringVar(&flagAddr, "addr", "127.0.0.1:8443", "listen address (loopback only)")
flag.Parse()
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
cfg, err := loadConfig()
if err != nil {
slog.Error("eks-token-webhook config error", "err", err)
os.Exit(1)
}
if err := run(cfg); err != nil {
slog.Error("eks-token-webhook fatal", "err", err)
os.Exit(1)
}
}
func run(cfg config) error {
// Serving cert + apiserver kubeconfig. Persisted and reused across restarts
// so a webhook crash does not invalidate the CA the apiserver already loaded.
tlsCert, certPEM, err := ensureServingCert(cfg.certPath, cfg.keyPath)
if err != nil {
return fmt.Errorf("serving cert: %w", err)
}
if err := writeAPIServerKubeconfig(cfg.kubeconfig, cfg.addr, certPEM); err != nil {
return fmt.Errorf("write apiserver kubeconfig: %w", err)
}
client, err := eksgw.New(cfg.gatewayURL, cfg.gatewayCA, cfg.accessKey, cfg.secretKey, cfg.region)
if err != nil {
return fmt.Errorf("build gateway client: %w", err)
}
authr := &authenticator{
accountID: cfg.accountID,
clusterName: cfg.clusterName,
review: gatewayReviewer(client, cfg.clusterName, cfg.accountID),
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/authenticate", authr.handle)
server := newServer(cfg.addr, mux, tlsCert)
slog.Info("eks-token-webhook listening", "addr", cfg.addr, "cluster", cfg.clusterName)
if err := server.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve: %w", err)
}
return nil
}
// tokenReviewRequest is the body POSTed to /clusters/{name}/token-review. The
// webhook holds system SigV4 creds (system account, not the cluster account),
// so accountId names the cluster account explicitly — same as PublishInternal.
type tokenReviewRequest struct {
AccountID string `json:"accountId"`
Token string `json:"token"`
}
// gatewayReviewer returns the per-call relay: SigV4-POST {accountId,token} to
// the gateway and decode the resolved identity. A non-2xx / transport error is
// surfaced (handle() turns it into a 5xx so the apiserver retries) rather than a
// silent deny, distinguishing "webhook broker down" from "token rejected".
func gatewayReviewer(client *eksgw.Client, clusterName, accountID string) func(string) (handlers_eks.WebhookTokenReviewResult, error) {
path := "/clusters/" + clusterName + "/token-review"
return func(token string) (handlers_eks.WebhookTokenReviewResult, error) {
body, err := json.Marshal(tokenReviewRequest{AccountID: accountID, Token: token})
if err != nil {
return handlers_eks.WebhookTokenReviewResult{}, fmt.Errorf("marshal request: %w", err)
}
respBody, err := client.Post(path, body)
if err != nil {
return handlers_eks.WebhookTokenReviewResult{}, err
}
var res handlers_eks.WebhookTokenReviewResult
if err := json.Unmarshal(respBody, &res); err != nil {
return handlers_eks.WebhookTokenReviewResult{}, fmt.Errorf("decode response: %w", err)
}
return res, nil
}
}
// authenticator resolves a TokenReview by relaying to the gateway broker.
type authenticator struct {
accountID string
clusterName string
review func(token string) (handlers_eks.WebhookTokenReviewResult, error)
}
func (a *authenticator) handle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var review tokenReview
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
res, err := a.review(review.Spec.Token)
if err != nil {
// Broker fault (gateway down, transport error): a 5xx tells the apiserver
// to retry rather than treating a transient outage as a hard deny.
slog.Error("TokenReview relay failed", "cluster", a.clusterName, "err", err)
http.Error(w, "token review unavailable", http.StatusServiceUnavailable)
return
}
status := tokenReviewStatus{Authenticated: res.Authenticated}
if res.Authenticated {
status.User = userInfo{
Username: res.Username,
UID: res.UID,
Groups: res.Groups,
}
// Confirm the token for the audiences the apiserver asked about; an empty
// status.audiences is rejected when --api-audiences is configured.
status.Audiences = review.Spec.Audiences
}
slog.Info("TokenReview decision", "authenticated", status.Authenticated, "username", status.User.Username, "audiences", status.Audiences)
resp := tokenReview{
APIVersion: "authentication.k8s.io/v1",
Kind: "TokenReview",
Status: status,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
slog.Error("encode TokenReview response", "err", err)
}
}
// newServer builds the loopback TLS server. Split out so tests can construct it
// without binding a port.
func newServer(addr string, handler http.Handler, cert tlsCertificate) *http.Server {
return &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
TLSConfig: cert.serverTLSConfig(),
}
}