Files

161 lines
5.3 KiB
Go

// Package eksgw is the on-VM SigV4 client used by EKS helpers (eks-gateway-publish,
// eks-token-webhook) to POST over HTTPS to the AWS gateway instead of NATS.
// Signs for service "eks" with either static keys or IMDS instance-role creds.
package eksgw
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/mulgadc/bluebottle/pkg/tlsconfig"
"github.com/mulgadc/spinifex/internal/gwsign"
)
// Client posts SigV4-signed requests to the AWS gateway for the "eks" service.
type Client struct {
baseURL string
signer *gwsign.Signer
region string
httpClient *http.Client
}
// New builds a client. caPath optionally pins the gateway TLS CA; when empty the
// client relies on the system trust store. region defaults to us-east-1 when
// empty (SigV4 requires a non-empty region). When accessKey/secretKey are empty
// the client signs with IMDS instance-role credentials served in-VM.
func New(baseURL, caPath, accessKey, secretKey, region string) (*Client, error) {
if baseURL == "" {
return nil, fmt.Errorf("eksgw: baseURL is required")
}
if region == "" {
region = "us-east-1"
}
var signer *gwsign.Signer
if accessKey != "" && secretKey != "" {
signer = gwsign.NewStatic(accessKey, secretKey)
} else {
s, err := gwsign.NewIMDS(context.Background(), region)
if err != nil {
return nil, fmt.Errorf("eksgw: init IMDS signer: %w", err)
}
// The per-tap IMDS datapath lags VM boot by minutes (data-NIC DHCP lease
// + br-imds reconcile), and the SDK's IMDS client fast-fails each probe.
// Block until the instance-role creds first retrieve so the first signed
// request already has them, rather than letting each caller burn its own
// short POST-retry budget against a still-cold datapath (observed: the CP
// datapath warms at ~264s, past gateway-publish's 150s budget).
if err := warmIMDSCredentials(s); err != nil {
return nil, fmt.Errorf("eksgw: IMDS instance-role credentials unavailable: %w", err)
}
signer = s
}
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: tlsconfig.Curves,
}
if caPath != "" {
pem, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("eksgw: read gateway CA %q: %w", caPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("eksgw: gateway CA %q has no usable certificates", caPath)
}
tlsCfg.RootCAs = pool
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
signer: signer,
region: region,
httpClient: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsCfg, MaxIdleConns: 2, IdleConnTimeout: 30 * time.Second},
},
}, nil
}
const (
// imdsWarmupTimeout bounds the wait for the IMDS instance-role datapath to
// come up — aligned with cloud-init's Ec2 max_wait (600s), since it is the
// same per-tap datapath. Exceeding it means the datapath is genuinely broken
// (not merely slow), so New fails loudly instead of signing credential-less.
imdsWarmupTimeout = 10 * time.Minute
// imdsWarmupInterval spaces the retrieve probes; each SDK probe fast-fails
// (~250ms dial) against a cold datapath, so a short interval keeps the total
// wait close to the datapath's actual readiness time.
imdsWarmupInterval = 2 * time.Second
)
// warmIMDSCredentials blocks until the signer's IMDS provider first yields
// credentials, or imdsWarmupTimeout elapses. Retries because the SDK client
// fast-fails each probe while the per-tap datapath is still cold.
func warmIMDSCredentials(s *gwsign.Signer) error {
ctx, cancel := context.WithTimeout(context.Background(), imdsWarmupTimeout)
defer cancel()
var lastErr error
for {
if lastErr = s.EnsureCredentials(ctx); lastErr == nil {
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("after %s: %w", imdsWarmupTimeout, lastErr)
case <-time.After(imdsWarmupInterval):
}
}
}
// Post SigV4-signs and sends body to path, returning the response body on 2xx.
// Errors include the gateway status and body. No retry; callers wrap as needed.
func (c *Client) Post(path string, body []byte) ([]byte, error) {
return c.do(http.MethodPost, path, body)
}
// Get SigV4-signs and sends a single GET to path (e.g.
// "/clusters/alpha/internal-addons?accountId=..."). Same contract as Post: 2xx
// body or an error carrying the gateway status. No retry.
func (c *Client) Get(path string) ([]byte, error) {
return c.do(http.MethodGet, path, nil)
}
func (c *Client) do(method, path string, body []byte) ([]byte, error) {
req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
}
sum := sha256.Sum256(body)
if err := c.signer.Sign(req, hex.EncodeToString(sum[:]), "eks", c.region); err != nil {
return nil, fmt.Errorf("sign request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("gateway returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return respBody, nil
}