mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 03:11:46 +00:00
704 lines
35 KiB
Go
704 lines
35 KiB
Go
//go:build e2e
|
|
|
|
package iam
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/aws/aws-sdk-go/service/iam"
|
|
"github.com/aws/aws-sdk-go/service/sts"
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// IMDS E2E identifiers. The role/profile carry an imds-e2e- prefix so they
|
|
// never collide with the IAM-roles suite ("app-role"/"app-profile") or the
|
|
// STS suite ("sts-e2e-role").
|
|
const (
|
|
imdsRoleName = "imds-e2e-role"
|
|
imdsProfileName = "imds-e2e-profile"
|
|
|
|
// imdsAdminPolicyName is the bootstrap managed policy attached to the IMDS
|
|
// role so the minted instance-role creds round-trip a real API call.
|
|
imdsAdminPolicyName = "AdministratorAccess"
|
|
// imdsTrustPolicyEC2 lets ec2.amazonaws.com assume the IMDS instance role.
|
|
imdsTrustPolicyEC2 = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`
|
|
|
|
// metaURL is the AWS-compatible link-local IMDS endpoint, answered L2 on each
|
|
// subnet's own switch from the host via SO_BINDTODEVICE on the
|
|
// imds-h-<shortSubnetID> veth (no router, no proxy-ARP, no /32 route).
|
|
metaURL = "http://169.254.169.254"
|
|
|
|
// imdsUserData is a no-op cloud-config carrying a unique marker so the
|
|
// /latest/user-data round-trip can assert the body the guest sees matches
|
|
// what RunInstances was given. A comment-only cloud-config is a valid empty
|
|
// config, so it doesn't perturb boot.
|
|
imdsUserData = "#cloud-config\n# imds-e2e-userdata-marker-7f3a91\n"
|
|
imdsUDMarker = "imds-e2e-userdata-marker-7f3a91"
|
|
|
|
// imdsProbeVPCCIDR / imdsProbeSubnetCIDR are shared by BOTH isolation VPCs.
|
|
// Overlapping CIDRs across VPCs are legal (each VPC is its own routing
|
|
// domain); using identical subnets makes IPAM hand the first VM in each the
|
|
// SAME private IP (network+4, deterministic per ipam.go), which is exactly
|
|
// the cross-VPC source-IP collision the isolation assertion needs.
|
|
imdsProbeVPCCIDR = "10.211.0.0/16"
|
|
imdsProbeSubnetCIDR = "10.211.7.0/24"
|
|
)
|
|
|
|
// imdsCredDoc mirrors the AWS credential JSON served at
|
|
// /latest/meta-data/iam/security-credentials/<role>. Only the fields the test
|
|
// asserts on are decoded.
|
|
type imdsCredDoc struct {
|
|
Code string `json:"Code"`
|
|
Type string `json:"Type"`
|
|
AccessKeyId string `json:"AccessKeyId"`
|
|
SecretAccessKey string `json:"SecretAccessKey"`
|
|
Token string `json:"Token"`
|
|
Expiration string `json:"Expiration"`
|
|
AccountId string `json:"AccountId"`
|
|
}
|
|
|
|
// runIMDS exercises the host-served IMDSv2 surface end-to-end against real guest
|
|
// VMs (docs/development/feature/imds-v1.md Step 11). It stands up two VPCs with
|
|
// identical subnet CIDRs and one VM in each so the two VMs share a private IP:
|
|
//
|
|
// - VM X (profile-bound, user-data) drives the full surface: IMDSv2 token
|
|
// issuance, the v2-only stance (tokenless / garbage-token GET → 401), the
|
|
// metadata fields, the instance-role credential path + a wire round-trip
|
|
// proving the ASIA creds resolve to the instance assumed-role ARN, the
|
|
// /latest/user-data round-trip, and the on-link
|
|
// metadata route the guest's cloud-init network-config installs to reach the
|
|
// subnet localport.
|
|
// - The per-subnet L2 datapath invariant: imds-port-<subnetID> is a localport
|
|
// LSP on the guest's own subnet switch, and ovn-trace confirms the request
|
|
// and the established reply both resolve over a single L2 hop with no logical
|
|
// router in the path to shadow them.
|
|
// - Cross-VPC isolation: VM X and VM Y share the same source IP in different
|
|
// VPCs, yet each IMDS returns its OWN instance-id — the load-bearing
|
|
// (VPC-ID, source-IP) → ENI boundary (plan §Datapath-attested identity).
|
|
//
|
|
// OVN + SSH gated: the IMDS datapath needs ovn-controller to bind the localport
|
|
// per chassis, and every assertion runs via SSH into the guest.
|
|
func runIMDS(t *testing.T, fix *Fixture) {
|
|
harness.Phase(t, "Single — IMDSv2 Host-Served Instance Metadata")
|
|
harness.SkipIfNoOVN(t)
|
|
requireSSHHealthy(t)
|
|
|
|
adminAccount := harness.IAMAccountID(t, fix.AWS)
|
|
keyName, keyPath := needKeyPair(t, fix)
|
|
imdsEnsureRoleProfile(t, fix, adminAccount)
|
|
|
|
// Two VPCs, identical subnet CIDR → the first VM in each gets the same IP.
|
|
// The VPC IDs are logged inside imdsEnsureProbeVPC; the L2 datapath and the
|
|
// isolation assertions key off the subnet and the instance identity, not the
|
|
// VPC ID, so they aren't bound here.
|
|
_, subX, sgX := imdsEnsureProbeVPC(t, fix, imdsProbeVPCCIDR, imdsProbeSubnetCIDR)
|
|
_, subY, sgY := imdsEnsureProbeVPC(t, fix, imdsProbeVPCCIDR, imdsProbeSubnetCIDR)
|
|
|
|
// VM X — profile-bound + user-data; drives the full surface. eniX builds the
|
|
// guest LSP name (port-<eni>) for the ovn-trace datapath assertion.
|
|
idX, privX, eniX, tgtX := imdsProbe(t, fix, keyPath, imdsVMSpec{
|
|
subnetID: subX,
|
|
sgID: sgX,
|
|
profileName: imdsProfileName,
|
|
userData: imdsUserData,
|
|
})
|
|
// VM Y — isolation peer; no profile needed for the instance-id probe.
|
|
idY, privY, _, tgtY := imdsProbe(t, fix, keyPath, imdsVMSpec{
|
|
subnetID: subY,
|
|
sgID: sgY,
|
|
})
|
|
require.NotEqual(t, idX, idY, "the two probe VMs must be distinct instances")
|
|
require.Equalf(t, privX, privY,
|
|
"isolation premise: both first-in-subnet VMs must share a private IP "+
|
|
"(IPAM is deterministic at network+4) — got %s vs %s", privX, privY)
|
|
harness.Detail(t, "vm_x", idX, "vm_y", idY, "shared_priv", privX)
|
|
|
|
// --- Per-subnet L2 datapath invariant: imds-port-<subnet> is a localport ---
|
|
harness.Step(t, "ovn-nbctl imds-port-%s must be a localport LSP", subX)
|
|
imdsLSP := "imds-port-" + subX // mirrors topology.IMDSPort (inlined, as datapath_test does)
|
|
lspType := harness.OvnNbctl(t, "--no-leader-only", "--bare", "--columns=type",
|
|
"find", "logical_switch_port", "name="+imdsLSP)
|
|
require.Equalf(t, "localport", lspType,
|
|
"imds-port LSP %s must be type=localport so every chassis self-serves IMDS "+
|
|
"(a regular LSP binds one chassis and forces Geneve tunnelling)", imdsLSP)
|
|
|
|
// --- ovn-trace: the L2 round-trip stays off the router (Phase-0 gate) -----
|
|
// The defining property of the L2 datapath is that 169.254.169.254 is answered
|
|
// on the guest's own broadcast domain, so neither the request nor the
|
|
// established reply may enter the VPC logical router — where v1's reroute,
|
|
// /32-static-route and hop-limit bugs all lived. Trace both directions on the
|
|
// subnet switch and assert each resolves over a single L2 hop with no
|
|
// lr_in_*/lr_out_* stage and lands on the expected port.
|
|
imdsAssertL2RoundTrip(t, subX, eniX, privX)
|
|
|
|
// --- IMDSv2 token issuance ----------------------------------------------
|
|
harness.Step(t, "PUT /latest/api/token (IMDSv2)")
|
|
tokenX := imdsAwaitToken(t, fix, tgtX, subX, privX)
|
|
harness.Detail(t, "token_len", len(tokenX))
|
|
|
|
// --- v2-only stance: tokenless + garbage-token GET → 401 -----------------
|
|
harness.Step(t, "tokenless GET /latest/meta-data/instance-id → 401")
|
|
require.Equal(t, "401", imdsCode(tgtX, "", "/latest/meta-data/instance-id"),
|
|
"IMDSv1 (tokenless) GET must be rejected with 401")
|
|
harness.Step(t, "garbage-token GET → 401")
|
|
require.Equal(t, "401",
|
|
imdsCode(tgtX, `-H "X-aws-ec2-metadata-token: not-a-real-token"`, "/latest/meta-data/instance-id"),
|
|
"unknown token must be rejected with 401 (no token-exists leak)")
|
|
|
|
// --- SSRF defence: X-Forwarded-For → 403 even with a valid token ---------
|
|
// A forwarded request is a proxied SSRF attempt; the host refuses it before
|
|
// the token is consulted, so a valid token does not rescue it.
|
|
harness.Step(t, "X-Forwarded-For GET with valid token → 403")
|
|
require.Equal(t, "403",
|
|
imdsCode(tgtX, fmt.Sprintf(`-H "X-aws-ec2-metadata-token: %s" -H "X-Forwarded-For: 10.0.0.1"`, tokenX),
|
|
"/latest/meta-data/instance-id"),
|
|
"X-Forwarded-For must be rejected with 403 even with a valid token (SSRF defence)")
|
|
|
|
// --- Token-TTL validation: missing / 0 / over-max / non-numeric → 400 ----
|
|
// PUT /latest/api/token requires X-aws-ec2-metadata-token-ttl-seconds in
|
|
// [1,21600]; anything outside that mints no token and returns 400.
|
|
for _, tc := range []struct{ label, header string }{
|
|
{"missing", `-X PUT`},
|
|
{"zero", `-X PUT -H "X-aws-ec2-metadata-token-ttl-seconds: 0"`},
|
|
{"over-max", `-X PUT -H "X-aws-ec2-metadata-token-ttl-seconds: 21601"`},
|
|
{"non-numeric", `-X PUT -H "X-aws-ec2-metadata-token-ttl-seconds: abc"`},
|
|
} {
|
|
harness.Step(t, "PUT token with %s TTL → 400", tc.label)
|
|
require.Equalf(t, "400", imdsCode(tgtX, tc.header, "/latest/api/token"),
|
|
"PUT token with %s TTL must be 400", tc.label)
|
|
}
|
|
|
|
// --- Method-not-allowed: token is PUT-only, metadata is GET-only → 405 ---
|
|
// The method check precedes token validation, so neither probe needs a token.
|
|
harness.Step(t, "GET /latest/api/token → 405; PUT metadata path → 405")
|
|
require.Equal(t, "405", imdsCode(tgtX, "", "/latest/api/token"),
|
|
"GET on the PUT-only token endpoint must be 405")
|
|
require.Equal(t, "405", imdsCode(tgtX, `-X PUT`, "/latest/meta-data/instance-id"),
|
|
"PUT on a GET-only metadata path must be 405")
|
|
|
|
// --- Metadata surface ----------------------------------------------------
|
|
harness.Step(t, "GET metadata surface")
|
|
require.Equal(t, idX, imdsGet(t, tgtX, tokenX, "/latest/meta-data/instance-id"),
|
|
"instance-id mismatch")
|
|
require.Equal(t, privX, imdsGet(t, tgtX, tokenX, "/latest/meta-data/local-ipv4"),
|
|
"local-ipv4 must equal the request source IP")
|
|
require.NotEmpty(t, imdsGet(t, tgtX, tokenX, "/latest/meta-data/instance-type"),
|
|
"instance-type must be populated")
|
|
require.NotEmpty(t, imdsGet(t, tgtX, tokenX, "/latest/meta-data/mac"),
|
|
"mac must be populated")
|
|
require.NotEmpty(t, imdsGet(t, tgtX, tokenX, "/latest/meta-data/placement/availability-zone"),
|
|
"placement/availability-zone must be populated")
|
|
|
|
// Cheap one-field/static leaves: a real reservation id, the static
|
|
// instance-life-cycle, and the services subtree.
|
|
require.True(t, strings.HasPrefix(imdsGet(t, tgtX, tokenX, "/latest/meta-data/reservation-id"), "r-"),
|
|
"reservation-id must be served as an r-… identifier")
|
|
require.Equal(t, "on-demand", imdsGet(t, tgtX, tokenX, "/latest/meta-data/instance-life-cycle"),
|
|
"instance-life-cycle must be on-demand (Spot not modelled)")
|
|
require.Equal(t, "aws", imdsGet(t, tgtX, tokenX, "/latest/meta-data/services/partition"),
|
|
"services/partition must be aws")
|
|
require.Equal(t, "amazonaws.com", imdsGet(t, tgtX, tokenX, "/latest/meta-data/services/domain"),
|
|
"services/domain must be amazonaws.com")
|
|
|
|
// public-hostname mirrors public-ipv4 when the instance has one, else 404. In
|
|
// pool mode the probe subnet maps a public IP on launch; in dev_networking it
|
|
// has none, so the two modes exercise the two branches.
|
|
if fix.PoolMode {
|
|
pubIP := imdsGet(t, tgtX, tokenX, "/latest/meta-data/public-ipv4")
|
|
require.NotEmpty(t, pubIP, "pool-mode VM must have a public IP")
|
|
require.Equal(t, pubIP, imdsGet(t, tgtX, tokenX, "/latest/meta-data/public-hostname"),
|
|
"public-hostname must mirror public-ipv4")
|
|
} else {
|
|
require.Equal(t, "404",
|
|
imdsCode(tgtX, fmt.Sprintf(`-H "X-aws-ec2-metadata-token: %s"`, tokenX),
|
|
"/latest/meta-data/public-hostname"),
|
|
"no public IP → public-hostname must 404")
|
|
}
|
|
|
|
// iam/info → InstanceProfileArn ends with the bound profile.
|
|
iamInfo := imdsGet(t, tgtX, tokenX, "/latest/meta-data/iam/info")
|
|
require.Containsf(t, iamInfo, ":instance-profile/"+imdsProfileName,
|
|
"iam/info must carry the bound InstanceProfileArn (got %q)", iamInfo)
|
|
// security-credentials/ lists the role name (not the profile name).
|
|
require.Equal(t, imdsRoleName, imdsGet(t, tgtX, tokenX, "/latest/meta-data/iam/security-credentials/"),
|
|
"security-credentials/ must list the resolved role name")
|
|
|
|
// --- public-keys ---------------------------------------------------------
|
|
// The launch key injection path cloud-init's Ec2 datasource queries: the two
|
|
// directory listings plus the material leaf, served live from the key store.
|
|
harness.Step(t, "GET /latest/meta-data/public-keys/ subtree")
|
|
require.Equal(t, "0="+keyName, imdsGet(t, tgtX, tokenX, "/latest/meta-data/public-keys/"),
|
|
"public-keys/ must list 0=<keyName>")
|
|
require.Equal(t, "openssh-key", imdsGet(t, tgtX, tokenX, "/latest/meta-data/public-keys/0/"),
|
|
"public-keys/0/ must list the openssh-key format")
|
|
gotKey := imdsGet(t, tgtX, tokenX, "/latest/meta-data/public-keys/0/openssh-key")
|
|
wantType, wantB64 := imdsExpectedPubKey(t, keyPath)
|
|
gotFields := strings.Fields(gotKey)
|
|
require.GreaterOrEqualf(t, len(gotFields), 2,
|
|
"public-keys/0/openssh-key must serve <type> <base64> (got %q)", gotKey)
|
|
require.Equal(t, wantType, gotFields[0], "public key type mismatch")
|
|
require.Equal(t, wantB64, gotFields[1],
|
|
"public-keys/0/openssh-key material must match the launch key pair")
|
|
|
|
// --- user-data round-trip ------------------------------------------------
|
|
harness.Step(t, "GET /latest/user-data round-trips launch user-data")
|
|
require.Contains(t, imdsGet(t, tgtX, tokenX, "/latest/user-data"), imdsUDMarker,
|
|
"user-data must round-trip the launch user-data")
|
|
|
|
// --- Version discovery + dated-version alias (cloud-init parity) ----------
|
|
// cloud-init's EC2 datasource probes its OWN hardcoded dated versions
|
|
// (e.g. 2021-03-23), not the GET / listing, so any dated prefix must alias to
|
|
// /latest. Prove that aliasing resolves in-guest.
|
|
harness.Step(t, "version discovery: GET /, GET /latest, dated-version alias")
|
|
require.Contains(t, imdsGet(t, tgtX, tokenX, "/"), "latest",
|
|
"GET / must advertise the supported version list")
|
|
require.Equal(t, "dynamic\nmeta-data\nuser-data", imdsGet(t, tgtX, tokenX, "/latest"),
|
|
"GET /latest must list the top-level tree")
|
|
require.Equal(t, idX, imdsGet(t, tgtX, tokenX, "/2021-03-23/meta-data/instance-id"),
|
|
"a dated API version must alias to /latest (cloud-init parity)")
|
|
|
|
// --- Dynamic instance-identity document ----------------------------------
|
|
// The unsigned document is consumed by real SDKs in-guest; assert the fields
|
|
// resolved from ENI + instance facts, including the launch-time architecture.
|
|
harness.Step(t, "GET /latest/dynamic/instance-identity/document")
|
|
_, wantArch := needInstanceTypeArch(t, fix)
|
|
docBody := imdsGet(t, tgtX, tokenX, "/latest/dynamic/instance-identity/document")
|
|
var idDoc struct {
|
|
InstanceID string `json:"instanceId"`
|
|
AccountID string `json:"accountId"`
|
|
Region string `json:"region"`
|
|
Architecture string `json:"architecture"`
|
|
}
|
|
require.NoError(t, json.Unmarshal([]byte(docBody), &idDoc),
|
|
"identity document must be valid JSON: %s", docBody)
|
|
require.Equal(t, idX, idDoc.InstanceID, "identity document instanceId mismatch")
|
|
require.Equal(t, adminAccount, idDoc.AccountID, "identity document accountId mismatch")
|
|
require.NotEmpty(t, idDoc.Region, "identity document region must be populated")
|
|
require.Equal(t, wantArch, idDoc.Architecture,
|
|
"identity document architecture must match the launch instance type")
|
|
|
|
// --- Instance-role credentials + wire round-trip -------------------------
|
|
harness.Step(t, "GET security-credentials/%s → ASIA creds", imdsRoleName)
|
|
credBody := imdsGet(t, tgtX, tokenX, "/latest/meta-data/iam/security-credentials/"+imdsRoleName)
|
|
var cred imdsCredDoc
|
|
require.NoError(t, json.Unmarshal([]byte(credBody), &cred),
|
|
"credential body must be valid JSON: %s", credBody)
|
|
require.Equal(t, "Success", cred.Code, "credential Code must be Success (body=%s)", credBody)
|
|
require.True(t, strings.HasPrefix(cred.AccessKeyId, "ASIA"),
|
|
"instance-role AKID must start with ASIA, got %q", cred.AccessKeyId)
|
|
require.NotEmpty(t, cred.SecretAccessKey, "empty SecretAccessKey")
|
|
require.NotEmpty(t, cred.Token, "empty session Token")
|
|
require.Equal(t, adminAccount, cred.AccountId, "credential AccountId mismatch")
|
|
harness.Detail(t, "cred_akid", cred.AccessKeyId)
|
|
|
|
// Prove the minted creds verify on the gateway and resolve to the instance-
|
|
// bound assumed-role identity. The session name is the instance ID, so the
|
|
// ARN is .../assumed-role/<role>/<instance-id>.
|
|
harness.Step(t, "get-caller-identity with the IMDS-minted creds (ASIA path)")
|
|
sessionCli := harness.NewAWSClientWithSessionCreds(t, fix.Env,
|
|
cred.AccessKeyId, cred.SecretAccessKey, cred.Token)
|
|
who, err := sessionCli.STS.GetCallerIdentity(&sts.GetCallerIdentityInput{})
|
|
require.NoError(t, err, "get-caller-identity with instance-role creds")
|
|
expectedARN := "arn:aws:sts::" + adminAccount + ":assumed-role/" + imdsRoleName + "/" + idX
|
|
require.Equal(t, expectedARN, aws.StringValue(who.Arn),
|
|
"IMDS-minted creds must resolve to the instance assumed-role ARN")
|
|
|
|
// --- Cross-VPC isolation -------------------------------------------------
|
|
// VM X and VM Y query 169.254.169.254 from the IDENTICAL source IP, in
|
|
// different VPCs (hence different subnets). Each must get its own instance-id;
|
|
// a leak here means the per-subnet SO_BINDTODEVICE veth / (VPC-ID, source-IP) →
|
|
// ENI mapping is broken.
|
|
harness.Step(t, "cross-VPC isolation: shared IP %s, distinct identities", privX)
|
|
tokenY := imdsAwaitToken(t, fix, tgtY, subY, privY)
|
|
gotY := imdsGet(t, tgtY, tokenY, "/latest/meta-data/instance-id")
|
|
require.Equalf(t, idY, gotY, "VM Y IMDS must return VM Y's instance-id (got %q)", gotY)
|
|
require.NotEqualf(t, idX, gotY,
|
|
"cross-VPC leak: VM Y (source IP %s) resolved to VM X — the "+
|
|
"(VPC-ID, source-IP) → ENI boundary is broken", privX)
|
|
// And VM X still resolves to itself (not Y) after Y came up on the same IP.
|
|
require.Equal(t, idX, imdsGet(t, tgtX, tokenX, "/latest/meta-data/instance-id"),
|
|
"VM X IMDS must still return VM X's instance-id")
|
|
harness.Detail(t, "isolation", "ok")
|
|
|
|
// --- Cross-instance token binding + no-profile surface -------------------
|
|
// Re-mint a fresh VM X token so the binding rejection below is unambiguously
|
|
// about ENI binding rather than TTL expiry of the token minted early above.
|
|
freshTokenX := imdsAwaitToken(t, fix, tgtX, subX, privX)
|
|
|
|
harness.Step(t, "VM Y presents VM X's token → 401 (ENI-bound)")
|
|
require.Equal(t, "401",
|
|
imdsCode(tgtY, fmt.Sprintf(`-H "X-aws-ec2-metadata-token: %s"`, freshTokenX),
|
|
"/latest/meta-data/instance-id"),
|
|
"a token bound to VM X's ENI must not authorise VM Y")
|
|
|
|
// VM Y has no instance profile: iam/info is 404 and the credential listing is
|
|
// an empty 200 (absence is not an error, matching AWS).
|
|
harness.Step(t, "VM Y iam/info → 404; security-credentials/ → empty 200")
|
|
require.Equal(t, "404",
|
|
imdsCode(tgtY, fmt.Sprintf(`-H "X-aws-ec2-metadata-token: %s"`, tokenY),
|
|
"/latest/meta-data/iam/info"),
|
|
"an instance with no profile must 404 on iam/info")
|
|
require.Equal(t, "200",
|
|
imdsCode(tgtY, fmt.Sprintf(`-H "X-aws-ec2-metadata-token: %s"`, tokenY),
|
|
"/latest/meta-data/iam/security-credentials/"),
|
|
"no-profile security-credentials/ listing must be an empty 200")
|
|
require.Empty(t, imdsGet(t, tgtY, tokenY, "/latest/meta-data/iam/security-credentials/"),
|
|
"no-profile security-credentials/ body must be empty")
|
|
|
|
// VM X is profile-bound: a credential request for a role it isn't bound to
|
|
// is a 404, not a leak of the bound role's creds.
|
|
harness.Step(t, "VM X credential request for an unbound role name → 404")
|
|
require.Equal(t, "404",
|
|
imdsCode(tgtX, fmt.Sprintf(`-H "X-aws-ec2-metadata-token: %s"`, freshTokenX),
|
|
"/latest/meta-data/iam/security-credentials/wrong-role-name"),
|
|
"a credential request for a role the instance isn't bound to must 404")
|
|
}
|
|
|
|
// imdsVMSpec parameterises a launch for the IMDS suite. profileName / userData
|
|
// are optional.
|
|
type imdsVMSpec struct {
|
|
subnetID string
|
|
sgID string
|
|
profileName string
|
|
userData string
|
|
}
|
|
|
|
// imdsProbe launches a VM per spec, waits for it to run + become SSH-reachable,
|
|
// and returns (instanceID, privateIP, eniID, sshTarget). eniID names the guest
|
|
// LSP (port-<eni>) for the ovn-trace datapath assertion. Registers terminate
|
|
// cleanup so the VM is torn down before its VPC/subnet are deleted (LIFO).
|
|
func imdsProbe(t *testing.T, fix *Fixture, keyPath string, spec imdsVMSpec) (string, string, string, harness.SSHTarget) {
|
|
t.Helper()
|
|
id := imdsRunVM(t, fix, spec)
|
|
t.Cleanup(func() {
|
|
_, _ = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
|
InstanceIds: []*string{aws.String(id)},
|
|
})
|
|
_ = waitForInstanceStateSoft(fix.AWS, id, "terminated", 5*time.Minute)
|
|
})
|
|
|
|
inst := harness.WaitForInstanceState(t, fix.AWS, id, "running")
|
|
priv := aws.StringValue(inst.PrivateIpAddress)
|
|
require.NotEmptyf(t, priv, "instance %s has no PrivateIpAddress", id)
|
|
eni := primaryENI(t, inst)
|
|
|
|
host, port := harness.InstancePublicSSHHost(t, inst)
|
|
harness.Step(t, "wait for %s SSH at %s:%d", id, host, port)
|
|
waitForSSHHandshake(t, host, port, keyPath)
|
|
return id, priv, eni, harness.SSHTarget{User: "ec2-user", Host: host, Port: port, KeyPath: keyPath}
|
|
}
|
|
|
|
// imdsAssertL2RoundTrip ovn-traces the IMDS request and its established reply on
|
|
// the guest's subnet switch and asserts both resolve over a single L2 hop with no
|
|
// logical-router stage. This promotes the Phase-0 gate to a standing assertion:
|
|
// the L2 datapath's defining property is that IMDS never enters the VPC LR, so an
|
|
// lr_in_*/lr_out_* stage appearing in either direction means the relocation
|
|
// regressed back toward the v1 router pipeline.
|
|
func imdsAssertL2RoundTrip(t *testing.T, subnetID, eniID, guestIP string) {
|
|
t.Helper()
|
|
subnetSwitch := "subnet-" + subnetID // topology.SubnetSwitch
|
|
imdsLSP := "imds-port-" + subnetID // topology.IMDSPort
|
|
guestLSP := "port-" + eniID // topology.Port
|
|
|
|
// MACs come from NB, the datapath's own source of truth: the localport and the
|
|
// guest LSP each advertise "<mac> <ip>" in their addresses column.
|
|
imdsMAC := imdsPortMAC(t, imdsLSP)
|
|
guestMAC := imdsPortMAC(t, guestLSP)
|
|
|
|
// Request: a NEW guest -> 169.254.169.254 SYN must be delivered L2 to the
|
|
// localport, with no router stage.
|
|
harness.Step(t, "ovn-trace request %s -> 169.254.169.254 lands L2 on %s", guestIP, imdsLSP)
|
|
reqFlow := fmt.Sprintf(
|
|
`inport=="%s" && eth.src==%s && eth.dst==%s && ip4.src==%s && ip4.dst==169.254.169.254 && ip.ttl==64 && tcp && tcp.src==40000 && tcp.dst==80`,
|
|
guestLSP, guestMAC, imdsMAC, guestIP)
|
|
reqTrace := harness.OvnTrace(t, "--no-leader-only", subnetSwitch, reqFlow)
|
|
imdsAssertNoRouterStage(t, "request", reqTrace)
|
|
require.Containsf(t, reqTrace, `output to "`+imdsLSP+`"`,
|
|
"IMDS request must be delivered L2 to the localport %s; trace:\n%s", imdsLSP, reqTrace)
|
|
|
|
// Reply: 169.254.169.254 -> guest is the established conntrack reply. Supply
|
|
// the reply ct-state to BOTH ct_next stages (ingress + egress) — a single --ct
|
|
// leaves the egress ct_next defaulting back to `est` without `rpl` and prints a
|
|
// spurious ct_mark.blocked drop (the Phase-0 false alarm). The reply landing on
|
|
// the guest also proves the guest's own SG ACLs never match the localport's
|
|
// frames.
|
|
harness.Step(t, "ovn-trace reply 169.254.169.254 -> %s lands L2 on %s", guestIP, guestLSP)
|
|
replyFlow := fmt.Sprintf(
|
|
`inport=="%s" && eth.src==%s && eth.dst==%s && ip4.src==169.254.169.254 && ip4.dst==%s && ip.ttl==64 && tcp && tcp.src==80 && tcp.dst==40000`,
|
|
imdsLSP, imdsMAC, guestMAC, guestIP)
|
|
replyTrace := harness.OvnTrace(t, "--no-leader-only", "--ct=est,rpl", "--ct=est,rpl", subnetSwitch, replyFlow)
|
|
imdsAssertNoRouterStage(t, "reply", replyTrace)
|
|
require.Containsf(t, replyTrace, `output to "`+guestLSP+`"`,
|
|
"IMDS established reply must be delivered L2 to the guest %s (the guest's own SG "+
|
|
"must not drop the localport's reply); trace:\n%s", guestLSP, replyTrace)
|
|
}
|
|
|
|
// imdsAssertNoRouterStage fails if an ovn-trace shows the packet entering a
|
|
// logical router (any lr_in_*/lr_out_* stage). The L2 IMDS datapath must answer
|
|
// 169.254.169.254 on the subnet switch and never transit the VPC LR.
|
|
func imdsAssertNoRouterStage(t *testing.T, dir, trace string) {
|
|
t.Helper()
|
|
for _, stage := range []string{"lr_in_", "lr_out_"} {
|
|
require.NotContainsf(t, trace, stage,
|
|
"IMDS %s traversed a logical router (%s stage present) — the L2 datapath must "+
|
|
"answer 169.254.169.254 on the subnet switch and never enter the VPC LR; trace:\n%s",
|
|
dir, stage, trace)
|
|
}
|
|
}
|
|
|
|
// imdsPortMAC returns the MAC an LSP advertises in its addresses column
|
|
// ("<mac> <ip>"). Fatal if the LSP is missing or carries no MAC, since the
|
|
// ovn-trace microflow cannot be built without it.
|
|
func imdsPortMAC(t *testing.T, lsp string) string {
|
|
t.Helper()
|
|
addrs := harness.OvnNbctl(t, "--no-leader-only", "--bare", "--columns=addresses",
|
|
"find", "logical_switch_port", "name="+lsp)
|
|
fields := strings.Fields(addrs)
|
|
require.NotEmptyf(t, fields, "LSP %s has no addresses (got %q); cannot build ovn-trace microflow", lsp, addrs)
|
|
return fields[0]
|
|
}
|
|
|
|
// imdsRunVM launches a single instance per spec and returns its ID. AMI / type /
|
|
// key come from the suite discovery helpers.
|
|
func imdsRunVM(t *testing.T, fix *Fixture, spec imdsVMSpec) string {
|
|
t.Helper()
|
|
amiID := needAMI(t, fix)
|
|
instType, _ := needInstanceTypeArch(t, fix)
|
|
keyName, _ := needKeyPair(t, fix)
|
|
|
|
in := &ec2.RunInstancesInput{
|
|
ImageId: aws.String(amiID),
|
|
InstanceType: aws.String(instType),
|
|
KeyName: aws.String(keyName),
|
|
SubnetId: aws.String(spec.subnetID),
|
|
SecurityGroupIds: []*string{aws.String(spec.sgID)},
|
|
MinCount: aws.Int64(1),
|
|
MaxCount: aws.Int64(1),
|
|
}
|
|
if spec.profileName != "" {
|
|
in.IamInstanceProfile = &ec2.IamInstanceProfileSpecification{Name: aws.String(spec.profileName)}
|
|
}
|
|
if spec.userData != "" {
|
|
in.UserData = aws.String(base64.StdEncoding.EncodeToString([]byte(spec.userData)))
|
|
}
|
|
out, err := fix.AWS.EC2.RunInstances(in)
|
|
require.NoError(t, err, "run-instances subnet=%s", spec.subnetID)
|
|
require.NotEmpty(t, out.Instances, "run-instances returned no Instances")
|
|
id := aws.StringValue(out.Instances[0].InstanceId)
|
|
require.True(t, strings.HasPrefix(id, "i-"), "unexpected InstanceId %q", id)
|
|
return id
|
|
}
|
|
|
|
// imdsEnsureProbeVPC creates a VPC + subnet wired for SSH reachability and
|
|
// returns (vpcID, subnetID, sgID). In pool mode it adds the public-IP path
|
|
// (MapPublicIpOnLaunch + IGW + 0.0.0.0/0 route) so the runner reaches the guest
|
|
// by public IP; in dev_networking mode SSH lands via the per-VM qemu hostfwd, so
|
|
// the IGW plumbing is skipped. All resources are torn down LIFO via t.Cleanup.
|
|
func imdsEnsureProbeVPC(t *testing.T, fix *Fixture, vpcCIDR, subnetCIDR string) (string, string, string) {
|
|
t.Helper()
|
|
c := fix.AWS
|
|
|
|
vpcOut, err := c.EC2.CreateVpc(&ec2.CreateVpcInput{CidrBlock: aws.String(vpcCIDR)})
|
|
require.NoError(t, err, "create-vpc %s", vpcCIDR)
|
|
vpcID := aws.StringValue(vpcOut.Vpc.VpcId)
|
|
require.NotEmpty(t, vpcID, "VpcId empty")
|
|
t.Cleanup(func() { _, _ = c.EC2.DeleteVpc(&ec2.DeleteVpcInput{VpcId: aws.String(vpcID)}) })
|
|
|
|
subOut, err := c.EC2.CreateSubnet(&ec2.CreateSubnetInput{
|
|
VpcId: aws.String(vpcID),
|
|
CidrBlock: aws.String(subnetCIDR),
|
|
})
|
|
require.NoError(t, err, "create-subnet %s", subnetCIDR)
|
|
subnetID := aws.StringValue(subOut.Subnet.SubnetId)
|
|
require.NotEmpty(t, subnetID, "SubnetId empty")
|
|
t.Cleanup(func() { _, _ = c.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{SubnetId: aws.String(subnetID)}) })
|
|
|
|
if fix.PoolMode {
|
|
imdsAttachInternet(t, c, vpcID, subnetID)
|
|
}
|
|
|
|
sgID := imdsDefaultSG(t, c, vpcID)
|
|
harness.AuthorizeSSHIngress(t, c, sgID)
|
|
harness.Detail(t, "probe_vpc", vpcID, "subnet", subnetID, "sg", sgID)
|
|
return vpcID, subnetID, sgID
|
|
}
|
|
|
|
// imdsAttachInternet gives subnetID a public-IP egress path (pool mode only):
|
|
// MapPublicIpOnLaunch + a fresh IGW + a 0.0.0.0/0 route table. Cleanups unwind
|
|
// LIFO before the subnet/VPC are deleted by the caller's earlier-registered
|
|
// cleanups.
|
|
func imdsAttachInternet(t *testing.T, c *harness.AWSClient, vpcID, subnetID string) {
|
|
t.Helper()
|
|
_, err := c.EC2.ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{
|
|
SubnetId: aws.String(subnetID),
|
|
MapPublicIpOnLaunch: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
|
|
})
|
|
require.NoError(t, err, "modify-subnet-attribute MapPublicIpOnLaunch")
|
|
|
|
igwOut, err := c.EC2.CreateInternetGateway(&ec2.CreateInternetGatewayInput{})
|
|
require.NoError(t, err, "create-internet-gateway")
|
|
igwID := aws.StringValue(igwOut.InternetGateway.InternetGatewayId)
|
|
require.NotEmpty(t, igwID, "InternetGatewayId empty")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(igwID), VpcId: aws.String(vpcID),
|
|
})
|
|
_, _ = c.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{InternetGatewayId: aws.String(igwID)})
|
|
})
|
|
_, err = c.EC2.AttachInternetGateway(&ec2.AttachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(igwID), VpcId: aws.String(vpcID),
|
|
})
|
|
require.NoError(t, err, "attach-internet-gateway")
|
|
|
|
rtOut, err := c.EC2.CreateRouteTable(&ec2.CreateRouteTableInput{VpcId: aws.String(vpcID)})
|
|
require.NoError(t, err, "create-route-table")
|
|
rtbID := aws.StringValue(rtOut.RouteTable.RouteTableId)
|
|
require.NotEmpty(t, rtbID, "RouteTableId empty")
|
|
t.Cleanup(func() { _, _ = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{RouteTableId: aws.String(rtbID)}) })
|
|
|
|
assocOut, err := c.EC2.AssociateRouteTable(&ec2.AssociateRouteTableInput{
|
|
RouteTableId: aws.String(rtbID), SubnetId: aws.String(subnetID),
|
|
})
|
|
require.NoError(t, err, "associate-route-table")
|
|
assocID := aws.StringValue(assocOut.AssociationId)
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{AssociationId: aws.String(assocID)})
|
|
})
|
|
|
|
_, err = c.EC2.CreateRoute(&ec2.CreateRouteInput{
|
|
RouteTableId: aws.String(rtbID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
GatewayId: aws.String(igwID),
|
|
})
|
|
require.NoError(t, err, "create-route 0.0.0.0/0 -> IGW")
|
|
}
|
|
|
|
// imdsEnsureRoleProfile creates the EC2-trusted role + instance profile the
|
|
// IMDS credential path resolves, attaching AdministratorAccess so the minted
|
|
// session creds round-trip a real call. Registers fixture-teardown cleanup and
|
|
// sweeps any residue from a prior run first.
|
|
func imdsEnsureRoleProfile(t *testing.T, fix *Fixture, adminAccount string) {
|
|
t.Helper()
|
|
adminPolicyARN := harness.IAMPolicyARN(adminAccount, imdsAdminPolicyName)
|
|
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, imdsRoleName, []string{imdsProfileName}, adminPolicyARN)
|
|
fix.Harness.RegisterCleanup(func() {
|
|
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, imdsRoleName, []string{imdsProfileName}, adminPolicyARN)
|
|
})
|
|
|
|
harness.Step(t, "create-role %q (trust=ec2.amazonaws.com) + profile %q", imdsRoleName, imdsProfileName)
|
|
_, err := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{
|
|
RoleName: aws.String(imdsRoleName),
|
|
AssumeRolePolicyDocument: aws.String(imdsTrustPolicyEC2),
|
|
Description: aws.String("E2E IMDS instance-role credentials"),
|
|
})
|
|
require.NoError(t, err, "create-role")
|
|
|
|
_, err = fix.AWS.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{
|
|
RoleName: aws.String(imdsRoleName),
|
|
PolicyArn: aws.String(adminPolicyARN),
|
|
})
|
|
require.NoError(t, err, "attach AdministratorAccess to role")
|
|
|
|
_, err = fix.AWS.IAM.CreateInstanceProfile(&iam.CreateInstanceProfileInput{
|
|
InstanceProfileName: aws.String(imdsProfileName),
|
|
})
|
|
require.NoError(t, err, "create-instance-profile")
|
|
_, err = fix.AWS.IAM.AddRoleToInstanceProfile(&iam.AddRoleToInstanceProfileInput{
|
|
InstanceProfileName: aws.String(imdsProfileName),
|
|
RoleName: aws.String(imdsRoleName),
|
|
})
|
|
require.NoError(t, err, "add-role-to-instance-profile")
|
|
}
|
|
|
|
// imdsAwaitToken PUTs an IMDSv2 token from inside the guest, retrying to ride
|
|
// out the cold-start window before BindManager.Sync + the ENI reverse-index
|
|
// land. Returns the token. On timeout it dumps the per-subnet IMDS datapath
|
|
// (OVN realisation + host veth route/neigh + conntrack) before failing, so a
|
|
// reachability timeout (exit 28) is triaged as request-path vs reply-path
|
|
// rather than a bare "condition not met".
|
|
func imdsAwaitToken(t *testing.T, fix *Fixture, tgt harness.SSHTarget, subnetID, guestIP string) string {
|
|
t.Helper()
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
var lastErr error
|
|
for {
|
|
cmd := fmt.Sprintf(
|
|
`curl -sf -X PUT "%s/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 120"`, metaURL)
|
|
out, err := runSSHCombined(tgt, cmd)
|
|
switch {
|
|
case err != nil:
|
|
lastErr = fmt.Errorf("token PUT failed: %w (out=%q)", err, out)
|
|
case strings.TrimSpace(out) == "":
|
|
lastErr = fmt.Errorf("token PUT returned empty body")
|
|
default:
|
|
return strings.TrimSpace(out)
|
|
}
|
|
if time.Now().After(deadline) {
|
|
harness.DumpIMDSDatapathDiagnostics(t, subnetID, guestIP, fix.ArtifactDir(t))
|
|
t.Fatalf("imdsAwaitToken: token never minted within 90s (subnet=%s guest=%s): %v "+
|
|
"(see IMDS datapath diagnostics above)", subnetID, guestIP, lastErr)
|
|
}
|
|
time.Sleep(3 * time.Second)
|
|
}
|
|
}
|
|
|
|
// imdsGet fetches a token-gated path from inside the guest and returns the
|
|
// trimmed body. -sf turns any HTTP >= 400 into a non-zero exit, so a 4xx/5xx
|
|
// fails the test loudly with the server status visible via runSSH's stderr.
|
|
func imdsGet(t *testing.T, tgt harness.SSHTarget, token, path string) string {
|
|
t.Helper()
|
|
cmd := fmt.Sprintf(`curl -sf -H "X-aws-ec2-metadata-token: %s" %s%s`, token, metaURL, path)
|
|
return strings.TrimSpace(runSSH(t, tgt, cmd))
|
|
}
|
|
|
|
// imdsExpectedPubKey derives the OpenSSH public key (type + base64) the IMDS
|
|
// material path must serve, from the launch key pair's PEM. CreateKeyPair returns
|
|
// only the private PEM, so the public key is recomputed from it via ssh.Signer.
|
|
// The stored key carries an empty comment (ssh-keygen -C ""), so callers compare
|
|
// on the type + base64 fields, not the full line.
|
|
func imdsExpectedPubKey(t *testing.T, pemPath string) (keyType, base64Key string) {
|
|
t.Helper()
|
|
pem, err := os.ReadFile(pemPath)
|
|
require.NoErrorf(t, err, "read key PEM %s", pemPath)
|
|
signer, err := ssh.ParsePrivateKey(pem)
|
|
require.NoError(t, err, "parse key PEM")
|
|
authorized := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey())))
|
|
fields := strings.Fields(authorized)
|
|
require.GreaterOrEqualf(t, len(fields), 2, "derived public key malformed: %q", authorized)
|
|
return fields[0], fields[1]
|
|
}
|
|
|
|
// imdsCode returns the HTTP status code (as a string) the guest sees for a GET,
|
|
// without failing on non-2xx. headerArg is an optional pre-formatted curl -H
|
|
// argument (empty for the tokenless probe).
|
|
func imdsCode(tgt harness.SSHTarget, headerArg, path string) string {
|
|
cmd := fmt.Sprintf(`curl -s -o /dev/null -w '%%{http_code}' %s %s%s`, headerArg, metaURL, path)
|
|
out, _ := runSSHCombined(tgt, cmd)
|
|
return strings.TrimSpace(out)
|
|
}
|
|
|
|
// imdsDefaultSG returns the auto-created default security group ID for a VPC.
|
|
func imdsDefaultSG(t *testing.T, c *harness.AWSClient, vpcID string) string {
|
|
t.Helper()
|
|
out, err := c.EC2.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
|
|
Filters: []*ec2.Filter{
|
|
{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpcID)}},
|
|
{Name: aws.String("group-name"), Values: []*string{aws.String("default")}},
|
|
},
|
|
})
|
|
require.NoError(t, err, "describe-security-groups default (vpc=%s)", vpcID)
|
|
require.NotEmpty(t, out.SecurityGroups, "no default SG for vpc=%s", vpcID)
|
|
id := aws.StringValue(out.SecurityGroups[0].GroupId)
|
|
require.NotEmpty(t, id, "default SG GroupId empty (vpc=%s)", vpcID)
|
|
return id
|
|
}
|