feat: IMDS version discovery, instance-identity document, and cheap metadata routes (#382)

This commit is contained in:
Julian Sommer
2026-06-18 15:26:14 +10:00
committed by GitHub
parent 3e6dfde7dd
commit 83e538f2f2
11 changed files with 541 additions and 50 deletions
+13 -6
View File
@@ -593,17 +593,25 @@ curl -i http://169.254.169.254/latest/meta-data/instance-id
| Path | Method | Source | Status |
|------|--------|--------|--------|
| `/latest/api/token` | PUT | Issues an ENI-bound IMDSv2 token; `X-aws-ec2-metadata-token-ttl-seconds` ∈ [1, 21600] required | **DONE** |
| `/` | GET | Supported API-version list (`2021-07-15`, `latest`); token-gated | **DONE** |
| `/latest` | GET | Top-level tree listing (`dynamic`, `meta-data`, `user-data`) | **DONE** |
| `/<date>/...` | GET/PUT | Any dated API version aliases to `/latest` (cloud-init parity) | **DONE** |
| `/latest/meta-data/` | GET | Directory listing of supported children | **DONE** |
| `/latest/meta-data/instance-id` | GET | `vm.ID` | **DONE** |
| `/latest/meta-data/instance-type` | GET | `vm.InstanceType` | **DONE** |
| `/latest/meta-data/ami-id` | GET | launch `ImageId` | **DONE** |
| `/latest/meta-data/ami-launch-index` | GET | Per-instance launch index (`0..n-1`), contiguous on partial failure | **DONE** |
| `/latest/meta-data/reservation-id` | GET | `DescribeInstances` `Reservation.ReservationId` | **DONE** |
| `/latest/meta-data/instance-life-cycle` | GET | `on-demand` (Spot not modelled) | **DONE** |
| `/latest/meta-data/local-ipv4` | GET | `ENIRecord.PrivateIpAddress` (== request source IP) | **DONE** |
| `/latest/meta-data/public-ipv4` | GET | EIP, else instance public IP; empty body if none | **DONE** |
| `/latest/meta-data/public-hostname` | GET | Mirrors `public-ipv4`; 404 when no public IP | **DONE** |
| `/latest/meta-data/mac` | GET | `ENIRecord.MacAddress` | **DONE** |
| `/latest/meta-data/security-groups` | GET | `ENIRecord.SecurityGroupIds`, newline-separated | **DONE** |
| `/latest/meta-data/hostname`, `/local-hostname` | GET | Synthesised `ip-<dashed-ip>.<region>.compute.internal` | **DONE** |
| `/latest/meta-data/placement/availability-zone` | GET | `ENIRecord.AvailabilityZone` | **DONE** |
| `/latest/meta-data/placement/region` | GET | Derived from AZ (trailing letter stripped) | **DONE** |
| `/latest/meta-data/services/{domain,partition}` | GET | Static: `amazonaws.com` / `aws` | **DONE** |
| `/latest/meta-data/iam/info` | GET | `{InstanceProfileArn, InstanceProfileId}`; 404 if no profile | **DONE** |
| `/latest/meta-data/iam/security-credentials/` | GET | Role name(s) under the profile, one per line; empty body if none | **DONE** |
| `/latest/meta-data/iam/security-credentials/<role>` | GET | STS `AssumeRoleForInstance` → ASIA-prefixed temporary credential JSON | **DONE** |
@@ -611,16 +619,15 @@ curl -i http://169.254.169.254/latest/meta-data/instance-id
| `/latest/meta-data/public-keys/0/` | GET | `openssh-key` (format list for index 0) | **DONE** |
| `/latest/meta-data/public-keys/0/openssh-key` | GET | Launch SSH public key, live-fetched from the key store; 404 if the key was deleted, 500 on backend fault | **DONE** |
| `/latest/user-data` | GET | `vm.UserData`; 404 if none | **DONE** |
| `/latest/dynamic/instance-identity/document` (+ `signature`, `pkcs7`, `rsa2048`) | GET | Needs a per-cluster signing key | **NOT STARTED** (404; lands with EKS IRSA) |
| `/latest/dynamic` | GET | Lists `instance-identity/` | **DONE** |
| `/latest/dynamic/instance-identity` | GET | Lists `document` (signed forms listed when the signing key lands) | **DONE** |
| `/latest/dynamic/instance-identity/document` | GET | Unsigned identity document from resolved ENI + instance facts | **DONE** |
| `/latest/dynamic/instance-identity/{signature,pkcs7,rsa2048}` | GET | Signed forms; need a per-cluster signing key | **NOT STARTED** (404; lands with EKS IRSA) |
| `/latest/meta-data/network/interfaces/macs/<mac>/...` | GET | Multi-ENI rendering (subnet-id, vpc-id, ipv4s, security-group-ids, …) | **NOT STARTED** (404) |
| `/latest/meta-data/tags/instance/<key>` | GET | Instance-tag metadata; gated on `InstanceMetadataTags` enablement | **NOT STARTED** (404) |
| `/latest/meta-data/public-hostname` | GET | No public DNS today; would mirror `public-ipv4` | **NOT STARTED** (404) |
| `/latest/meta-data/reservation-id` | GET | Available on the stored `Reservation`; cheap, not yet surfaced | **NOT STARTED** (404) |
| `/latest/meta-data/ami-launch-index` | GET | `0` for single-instance launches; trivial once multi-count is exercised | **NOT STARTED** (404) |
| `/latest/meta-data/block-device-mapping/...` | GET | `ami`/`root`/`ebsN`/`ephemeralN` device map | **NOT STARTED** (404) |
| `/latest/meta-data/placement/{group-name,partition-number,availability-zone-id,host-id}` | GET | Placement extras beyond `availability-zone`/`region` | **NOT STARTED** (404) |
| `/latest/meta-data/instance-life-cycle`, `/instance-action` | GET | `on-demand`/`spot`; `none` unless interruptible instances ship | **NOT STARTED** (404) |
| `/latest/meta-data/services/{domain,partition}` | GET | Static per-deployment values | **NOT STARTED** (404) |
| `/latest/meta-data/instance-action` | GET | `none` unless interruptible instances ship | **NOT STARTED** (404) |
| `/latest/meta-data/spot/{instance-action,termination-time}` | GET | Only meaningful once Spot is modelled | **NOT STARTED** (404) |
---
@@ -507,6 +507,12 @@ func (s *InstanceServiceImpl) RunInstance(input *ec2.RunInstancesInput) (*vm.VM,
ec2Instance.State.SetCode(0)
ec2Instance.State.SetName("pending")
// Project the instance type's architecture onto the customer instance so it
// flows to DescribeInstances and the IMDS identity document for free.
if arch := instanceArchitecture(s.instanceTypes[*input.InstanceType]); arch != "" {
ec2Instance.SetArchitecture(arch)
}
// IAM instance profile attached at launch: gateway has already resolved
// the reference to a canonical ARN and enforced iam:PassRole; here we
// just persist it on the VM and generate the association ID. Id is left
@@ -680,6 +686,7 @@ func (s *InstanceServiceImpl) PrepareRunInstances(input *ec2.RunInstancesInput,
s.resourceMgr.Deallocate(instanceType)
continue
}
ec2Instance.SetAmiLaunchIndex(int64(len(allEC2Instances)))
instances = append(instances, instance)
allEC2Instances = append(allEC2Instances, ec2Instance)
continue
@@ -809,6 +816,7 @@ func (s *InstanceServiceImpl) PrepareRunInstances(input *ec2.RunInstancesInput,
}
}
ec2Instance.SetAmiLaunchIndex(int64(len(allEC2Instances)))
instances = append(instances, instance)
allEC2Instances = append(allEC2Instances, ec2Instance)
}
@@ -112,6 +112,33 @@ func TestRunInstance_Success(t *testing.T) {
assert.NotNil(t, ec2Instance.LaunchTime)
}
// Architecture is projected onto the customer instance from the instance type at
// launch, so it flows to DescribeInstances and the IMDS identity document. Guards
// the platform-wide describe-instances change, not just IMDS.
func TestRunInstance_ArchitecturePopulated(t *testing.T) {
instanceTypes := map[string]*ec2.InstanceTypeInfo{
"t3.micro": {
InstanceType: aws.String("t3.micro"),
ProcessorInfo: &ec2.ProcessorInfo{SupportedArchitectures: []*string{aws.String("x86_64")}},
},
"t4g.micro": {
InstanceType: aws.String("t4g.micro"),
ProcessorInfo: &ec2.ProcessorInfo{SupportedArchitectures: []*string{aws.String("arm64")}},
},
}
svc := &InstanceServiceImpl{instanceTypes: instanceTypes}
for typ, wantArch := range map[string]string{"t3.micro": "x86_64", "t4g.micro": "arm64"} {
_, ec2Instance, err := svc.RunInstance(&ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String(typ),
})
require.NoError(t, err)
require.NotNil(t, ec2Instance.Architecture, "type=%s", typ)
assert.Equal(t, wantArch, *ec2Instance.Architecture, "type=%s", typ)
}
}
func TestRunInstance_WithIamInstanceProfile(t *testing.T) {
const profileARN = "arn:aws:iam::111122223333:instance-profile/app-profile"
svc := &InstanceServiceImpl{
@@ -2469,6 +2496,71 @@ func TestPrepareRunInstances_HappyPathNoENI(t *testing.T) {
}
}
// TestPrepareRunInstances_AmiLaunchIndexContiguous pins that ami-launch-index is
// assigned per successful launch (0..n-1) so it flows to DescribeInstances and the
// IMDS identity document. Survivors of a mid-loop failure stay contiguous (no gap).
func TestPrepareRunInstances_AmiLaunchIndexContiguous(t *testing.T) {
t.Run("count_3_no_eni", func(t *testing.T) {
types, _ := defaultPrepareInstanceTypes()
prov := &fakeResourceCapacityProvider{
instanceTypes: types,
canAllocFn: func(_ *ec2.InstanceTypeInfo, count int) int { return count },
}
svc := &InstanceServiceImpl{
config: &config.Config{},
instanceTypes: types,
amiLoader: &fakeAMILoader{byID: map[string]viperblock.AMIMetadata{
"ami-1": {ImageOwnerAlias: "acc"},
}},
resourceMgr: prov,
}
reservation, instances, _, err := svc.PrepareRunInstances(&ec2.RunInstancesInput{
InstanceType: aws.String("t3.micro"),
ImageId: aws.String("ami-1"),
MinCount: aws.Int64(3),
MaxCount: aws.Int64(3),
}, "acc")
require.NoError(t, err)
require.Len(t, instances, 3)
require.Len(t, reservation.Instances, 3)
for i, inst := range reservation.Instances {
assert.Equal(t, int64(i), aws.Int64Value(inst.AmiLaunchIndex))
}
})
t.Run("mid_loop_failure_stays_contiguous", func(t *testing.T) {
// Second of three ENI creates fails, so the second instance never
// appends; the third fills index 1 (not 2), leaving no gap.
eni := &fakeENICreator{
defaultSubnet: &SubnetInfo{SubnetID: "subnet-1", VpcID: "vpc-1"},
createOut: &ec2.CreateNetworkInterfaceOutput{
NetworkInterface: &ec2.NetworkInterface{
NetworkInterfaceId: aws.String("eni-1"),
MacAddress: aws.String("aa:bb:cc:dd:ee:ff"),
PrivateIpAddress: aws.String("10.0.0.10"),
VpcId: aws.String("vpc-1"),
},
},
createErr: errors.New("eni create failed"),
createErrOnCall: 2,
}
svc, _ := prepareSvcWithENI(t, eni, nil)
reservation, instances, _, err := svc.PrepareRunInstances(&ec2.RunInstancesInput{
InstanceType: aws.String("t3.micro"),
ImageId: aws.String("ami-1"),
SubnetId: aws.String("subnet-1"),
MinCount: aws.Int64(2),
MaxCount: aws.Int64(3),
}, "acc")
require.NoError(t, err)
require.Len(t, instances, 2)
require.Len(t, reservation.Instances, 2)
assert.Equal(t, int64(0), aws.Int64Value(reservation.Instances[0].AmiLaunchIndex))
assert.Equal(t, int64(1), aws.Int64Value(reservation.Instances[1].AmiLaunchIndex))
})
}
// TestPrepareRunInstances_BootModePropagated pins that the AMI's BootMode
// flows onto every prepared VM, so the launch path picks UEFI vs BIOS without
// a second AMI lookup. Empty AMI BootMode (legacy) flows through as empty.
@@ -2581,18 +2673,19 @@ func TestStopOrTerminateInstance_TerminationProtection(t *testing.T) {
}
type fakeENICreator struct {
defaultSubnet *SubnetInfo
subnet *SubnetInfo
getENIByID map[string]*ENIInfo
getENIErr error
createOut *ec2.CreateNetworkInterfaceOutput
createCalls int
createErr error
attachErr error
attachCalls int
updateCalls int
clearCalls int // updateCalls where publicIP is ""
detachCalls int
defaultSubnet *SubnetInfo
subnet *SubnetInfo
getENIByID map[string]*ENIInfo
getENIErr error
createOut *ec2.CreateNetworkInterfaceOutput
createCalls int
createErr error
createErrOnCall int // 1-based call index to fail; 0 fails every call when createErr is set
attachErr error
attachCalls int
updateCalls int
clearCalls int // updateCalls where publicIP is ""
detachCalls int
}
func (f *fakeENICreator) GetDefaultSubnet(_ string) (*SubnetInfo, error) {
@@ -2625,7 +2718,7 @@ func (f *fakeENICreator) GetENI(_, eniID string) (*ENIInfo, error) {
func (f *fakeENICreator) CreateNetworkInterface(_ *ec2.CreateNetworkInterfaceInput, _ string) (*ec2.CreateNetworkInterfaceOutput, error) {
f.createCalls++
if f.createErr != nil {
if f.createErr != nil && (f.createErrOnCall == 0 || f.createErrOnCall == f.createCalls) {
return nil, f.createErr
}
return f.createOut, nil
+8
View File
@@ -2,3 +2,11 @@ package handlers_imds
// MetaDataServerIP is the standard EC2 link-local metadata address.
const MetaDataServerIP = "169.254.169.254"
// pinnedVersion is the dated IMDS API version advertised by GET /.
const pinnedVersion = "2021-07-15"
// supportedVersions is the GET / listing. It is advertised only; normalizeVersion
// additionally accepts any dated-version prefix, so we honour more versions than
// we list — harmless because every version maps to the same /latest tree.
var supportedVersions = []string{pinnedVersion, "latest"}
+16 -10
View File
@@ -27,15 +27,19 @@ func (l *natsInstanceLookup) describe(accountID, instanceID string) (*instanceFa
return nil, fmt.Errorf("describe instance %s: %w", instanceID, err)
}
inst := firstInstance(out)
res, inst := firstReservationInstance(out)
if inst == nil {
return nil, nil // terminating or not yet visible; treat as miss
}
facts := &instanceFacts{
instanceType: aws.StringValue(inst.InstanceType),
imageID: aws.StringValue(inst.ImageId),
keyName: aws.StringValue(inst.KeyName),
instanceType: aws.StringValue(inst.InstanceType),
imageID: aws.StringValue(inst.ImageId),
keyName: aws.StringValue(inst.KeyName),
architecture: aws.StringValue(inst.Architecture),
amiLaunchIndex: aws.Int64Value(inst.AmiLaunchIndex),
reservationID: aws.StringValue(res.ReservationId),
pendingTime: aws.TimeValue(inst.LaunchTime),
}
if inst.IamInstanceProfile != nil {
facts.iamInstanceProfileArn = aws.StringValue(inst.IamInstanceProfile.Arn)
@@ -62,11 +66,13 @@ func (l *natsInstanceLookup) userData(accountID, instanceID string) []byte {
return decoded
}
// firstInstance returns the first instance in a DescribeInstances response, or
// nil when the response carries none.
func firstInstance(out *ec2.DescribeInstancesOutput) *ec2.Instance {
// firstReservationInstance returns the first instance in a DescribeInstances
// response along with its owning reservation, or (nil, nil) when none. The
// reservation is always non-nil when the instance is, since an instance is only
// returned from a non-nil reservation that contains it.
func firstReservationInstance(out *ec2.DescribeInstancesOutput) (*ec2.Reservation, *ec2.Instance) {
if out == nil {
return nil
return nil, nil
}
for _, res := range out.Reservations {
if res == nil {
@@ -74,9 +80,9 @@ func firstInstance(out *ec2.DescribeInstancesOutput) *ec2.Instance {
}
for _, inst := range res.Instances {
if inst != nil {
return inst
return res, inst
}
}
}
return nil
return nil, nil
}
+22 -13
View File
@@ -9,25 +9,34 @@ import (
"github.com/stretchr/testify/require"
)
func TestFirstInstance_NilAndEmpty(t *testing.T) {
assert.Nil(t, firstInstance(nil))
assert.Nil(t, firstInstance(&ec2.DescribeInstancesOutput{}))
assert.Nil(t, firstInstance(&ec2.DescribeInstancesOutput{
Reservations: []*ec2.Reservation{nil, {Instances: []*ec2.Instance{nil}}},
}))
func TestFirstReservationInstance_NilAndEmpty(t *testing.T) {
for _, out := range []*ec2.DescribeInstancesOutput{
nil,
{},
{Reservations: []*ec2.Reservation{nil, {Instances: []*ec2.Instance{nil}}}},
} {
res, inst := firstReservationInstance(out)
assert.Nil(t, res)
assert.Nil(t, inst)
}
}
// firstInstance skips nil reservations and nil instance slots and returns the
// first concrete instance it finds.
func TestFirstInstance_SkipsNilsAndReturnsFirst(t *testing.T) {
// firstReservationInstance skips nil reservations and nil instance slots and
// returns the first concrete instance with its owning reservation.
func TestFirstReservationInstance_SkipsNilsAndReturnsFirst(t *testing.T) {
out := &ec2.DescribeInstancesOutput{
Reservations: []*ec2.Reservation{
nil,
{Instances: []*ec2.Instance{nil}},
{Instances: []*ec2.Instance{{InstanceId: aws.String("i-first")}, {InstanceId: aws.String("i-second")}}},
{
ReservationId: aws.String("r-123"),
Instances: []*ec2.Instance{{InstanceId: aws.String("i-first")}, {InstanceId: aws.String("i-second")}},
},
},
}
got := firstInstance(out)
require.NotNil(t, got)
assert.Equal(t, "i-first", aws.StringValue(got.InstanceId))
res, inst := firstReservationInstance(out)
require.NotNil(t, inst)
assert.Equal(t, "i-first", aws.StringValue(inst.InstanceId))
require.NotNil(t, res)
assert.Equal(t, "r-123", aws.StringValue(res.ReservationId))
}
+116 -1
View File
@@ -6,6 +6,7 @@ import (
"io"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"time"
@@ -26,6 +27,14 @@ const (
prefixPublicKeys = "/latest/meta-data/public-keys/"
pathPublicKeysDir = "/latest/meta-data/public-keys"
prefixDynamic = "/latest/dynamic"
pathIdentityDir = "/latest/dynamic/instance-identity"
pathIdentityDocument = "/latest/dynamic/instance-identity/document"
// identityDocSchemaVersion is the identity-document schema version, distinct
// from the IMDS API version (pinnedVersion).
identityDocSchemaVersion = "2017-09-30"
hdrToken = "X-Aws-Ec2-Metadata-Token" //nolint:gosec // HTTP header name, not a credential
hdrTokenTTL = "X-Aws-Ec2-Metadata-Token-Ttl-Seconds" //nolint:gosec // HTTP header name, not a credential
@@ -43,6 +52,26 @@ func rejectForwarded(next http.Handler) http.Handler {
})
}
// dateVersion matches a dated IMDS API-version segment, e.g. 2021-03-23.
var dateVersion = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
// normalizeVersion rewrites any dated API-version prefix to the canonical /latest
// tree so one dispatch table serves every version a client probes — cloud-init
// probes its own hardcoded dated versions, not the GET / listing. latest is left
// untouched; a bare GET / is not date-shaped, so it falls through to the listing.
func normalizeVersion(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seg, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
if dateVersion.MatchString(seg) {
r.URL.Path = "/latest"
if rest != "" {
r.URL.Path += "/" + rest
}
}
next.ServeHTTP(w, r)
})
}
// handleToken serves PUT /latest/api/token, issuing a fresh ENI-bound IMDSv2 token.
func (s *IMDSServiceImpl) handleToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
@@ -136,14 +165,32 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni *
}
switch path {
case "/":
writeText(w, strings.Join(supportedVersions, "\n"))
case "/latest", "/latest/":
writeText(w, "dynamic\nmeta-data\nuser-data")
case prefixDynamic, prefixDynamic + "/":
writeText(w, "instance-identity/")
case pathIdentityDir, pathIdentityDir + "/":
writeText(w, "document") // signed forms (pkcs7/rsa2048/signature) listed when the signing key lands
case pathIdentityDocument:
s.serveInstanceIdentityDocument(w, eni)
case pathMetaDataRoot, prefixMetaData:
writeText(w, "ami-id\nhostname\niam/\ninstance-id\ninstance-type\nlocal-hostname\nlocal-ipv4\nmac\nplacement/\npublic-ipv4\npublic-keys/\nsecurity-groups")
writeText(w, "ami-id\nami-launch-index\nhostname\niam/\ninstance-id\ninstance-life-cycle\ninstance-type\nlocal-hostname\nlocal-ipv4\nmac\nplacement/\npublic-hostname\npublic-ipv4\npublic-keys/\nreservation-id\nsecurity-groups\nservices/")
case prefixMetaData + "instance-id":
writeText(w, eni.instanceID)
case prefixMetaData + "instance-life-cycle":
writeText(w, "on-demand") // Spot is not modelled yet
case prefixMetaData + "local-ipv4":
writeText(w, eni.privateIP)
case prefixMetaData + "public-ipv4":
writeText(w, eni.publicIP)
case prefixMetaData + "public-hostname":
if eni.publicIP == "" {
w.WriteHeader(http.StatusNotFound) // no public IP → no public hostname
return
}
writeText(w, eni.publicIP) // mirror public-ipv4 until public DNS exists
case prefixMetaData + "mac":
writeText(w, eni.mac)
case prefixMetaData + "security-groups":
@@ -160,6 +207,18 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni *
s.serveInstanceField(w, eni, func(i *instanceFacts) string { return i.instanceType })
case prefixMetaData + "ami-id":
s.serveInstanceField(w, eni, func(i *instanceFacts) string { return i.imageID })
case prefixMetaData + "ami-launch-index":
s.serveInstanceField(w, eni, func(i *instanceFacts) string {
return strconv.FormatInt(i.amiLaunchIndex, 10)
})
case prefixMetaData + "reservation-id":
s.serveInstanceField(w, eni, func(i *instanceFacts) string { return i.reservationID })
case prefixMetaData + "services", prefixMetaData + "services/":
writeText(w, "domain\npartition")
case prefixMetaData + "services/domain":
writeText(w, "amazonaws.com")
case prefixMetaData + "services/partition":
writeText(w, "aws")
case prefixMetaData + "iam", prefixMetaData + "iam/":
writeText(w, "info\nsecurity-credentials/")
case prefixMetaData + "iam/info":
@@ -185,6 +244,50 @@ func (s *IMDSServiceImpl) serveInstanceField(w http.ResponseWriter, eni *eniFact
writeText(w, field(inst))
}
// instanceIdentityDocument is the unsigned EC2 instance-identity document. nil
// slices and *string fields marshal to JSON null, matching AWS's billingProducts,
// kernelId, and related fields, which Spinifex does not model.
type instanceIdentityDocument struct {
AccountID string `json:"accountId"`
Architecture string `json:"architecture"`
AvailabilityZone string `json:"availabilityZone"`
BillingProducts []string `json:"billingProducts"`
DevpayProductCodes []string `json:"devpayProductCodes"`
MarketplaceProductCodes []string `json:"marketplaceProductCodes"`
ImageID string `json:"imageId"`
InstanceID string `json:"instanceId"`
InstanceType string `json:"instanceType"`
KernelID *string `json:"kernelId"`
PendingTime string `json:"pendingTime"`
PrivateIP string `json:"privateIp"`
RamdiskID *string `json:"ramdiskId"`
Region string `json:"region"`
Version string `json:"version"`
}
// serveInstanceIdentityDocument writes the unsigned instance-identity document.
// The signed forms (pkcs7/rsa2048/signature) need a per-cluster signing key and
// are deferred. 404s when the instance is no longer visible.
func (s *IMDSServiceImpl) serveInstanceIdentityDocument(w http.ResponseWriter, eni *eniFacts) {
inst := s.instanceFor(w, eni)
if inst == nil {
return
}
doc := instanceIdentityDocument{
AccountID: eni.accountID,
Architecture: inst.architecture,
AvailabilityZone: eni.availabilityZone,
ImageID: inst.imageID,
InstanceID: eni.instanceID,
InstanceType: inst.instanceType,
PendingTime: inst.pendingTime.UTC().Format("2006-01-02T15:04:05Z"),
PrivateIP: eni.privateIP,
Region: regionFromAZ(eni.availabilityZone),
Version: identityDocSchemaVersion,
}
writeIdentityDocument(w, doc)
}
// serveUserData writes the instance's user-data, or 404 if absent.
func (s *IMDSServiceImpl) serveUserData(w http.ResponseWriter, eni *eniFacts) {
inst := s.instanceFor(w, eni)
@@ -386,6 +489,18 @@ func writeText(w http.ResponseWriter, body string) {
_, _ = io.WriteString(w, body)
}
// writeIdentityDocument writes the identity document pretty-printed as text/plain,
// mirroring the AWS IMDS response (2-space indent, nil fields rendered as null).
func writeIdentityDocument(w http.ResponseWriter, doc instanceIdentityDocument) {
data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write(data)
}
func writeJSON(w http.ResponseWriter, v any) {
data, err := json.Marshal(v)
if err != nil {
+188 -6
View File
@@ -2,9 +2,11 @@ package handlers_imds
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -228,12 +230,153 @@ func TestHTTP_ENIMissReturns404(t *testing.T) {
assert.Equal(t, http.StatusNotFound, rec.Code)
}
// ----- version discovery -------------------------------------------------
// GET / returns the advertised version list (token-gated, IMDSv2-only).
func TestHTTP_RootVersionListing(t *testing.T) {
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
h := svc.httpHandler()
token := issueToken(t, h)
rec := get(t, h, "/", token)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "2021-07-15\nlatest", rec.Body.String())
}
// Version discovery is not a tokenless side channel: GET / without a token is 401.
func TestHTTP_RootVersionListingTokenless401(t *testing.T) {
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
h := svc.httpHandler()
rec := get(t, h, "/", "")
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Empty(t, rec.Body.String())
}
// GET /latest lists the top-level tree.
func TestHTTP_LatestListing(t *testing.T) {
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
h := svc.httpHandler()
token := issueToken(t, h)
rec := get(t, h, "/latest", token)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "dynamic\nmeta-data\nuser-data", rec.Body.String())
}
// Any dated API-version prefix aliases to the /latest tree — including a version
// cloud-init probes (2021-03-23) that we do not advertise — while a non-date
// first segment is not rewritten and stays 404. The dated token PUT works too.
func TestHTTP_DatedVersionAlias(t *testing.T) {
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
h := svc.httpHandler()
// PUT /<date>/api/token issues a token like /latest/api/token does.
put := httptest.NewRequest(http.MethodPut, "http://"+MetaDataServerIP+"/2021-07-15/api/token", nil)
put.RemoteAddr = testIP + ":50000"
put = put.WithContext(context.WithValue(put.Context(), ctxKeyVPCID, testVPC))
put.Header.Set(hdrTokenTTL, "60")
putRec := httptest.NewRecorder()
h.ServeHTTP(putRec, put)
require.Equal(t, http.StatusOK, putRec.Code)
token := putRec.Body.String()
require.NotEmpty(t, token)
want := get(t, h, prefixMetaData+"instance-id", token).Body.String()
// Advertised and non-advertised dated versions both resolve to /latest.
for _, date := range []string{"2021-07-15", "2021-03-23"} {
rec := get(t, h, "/"+date+"/meta-data/instance-id", token)
assert.Equal(t, http.StatusOK, rec.Code, "date=%s", date)
assert.Equal(t, want, rec.Body.String(), "date=%s", date)
}
// A non-date first segment is left alone and 404s.
rec := get(t, h, "/bogus/meta-data/instance-id", token)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
// ----- dynamic instance-identity -----------------------------------------
// /latest/dynamic lists instance-identity/; /latest/dynamic/instance-identity
// advertises only document (the signed forms are deferred).
func TestHTTP_DynamicListings(t *testing.T) {
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
h := svc.httpHandler()
token := issueToken(t, h)
cases := []struct{ path, want string }{
{prefixDynamic, "instance-identity/"},
{prefixDynamic + "/", "instance-identity/"},
{pathIdentityDir, "document"},
{pathIdentityDir + "/", "document"},
}
for _, c := range cases {
rec := get(t, h, c.path, token)
assert.Equal(t, http.StatusOK, rec.Code, "path=%s", c.path)
assert.Equal(t, c.want, rec.Body.String(), "path=%s", c.path)
}
}
// The unsigned identity document resolves every field from eni + instance facts;
// fields Spinifex does not model (billingProducts, kernelId, ...) are JSON null.
func TestHTTP_InstanceIdentityDocument(t *testing.T) {
pending := time.Date(2026, 6, 18, 1, 2, 3, 0, time.UTC)
res := &fakeResolver{
eni: testENI(),
inst: &instanceFacts{instanceType: "t3.micro", imageID: "ami-12345", architecture: "x86_64", pendingTime: pending},
}
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
h := svc.httpHandler()
token := issueToken(t, h)
rec := get(t, h, pathIdentityDocument, token)
require.Equal(t, http.StatusOK, rec.Code)
var doc map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &doc))
for k, want := range map[string]any{
"accountId": "111122223333",
"architecture": "x86_64",
"availabilityZone": "ap-southeast-2a",
"region": "ap-southeast-2",
"imageId": "ami-12345",
"instanceId": "i-0123456789",
"instanceType": "t3.micro",
"privateIp": "10.0.1.5",
"pendingTime": "2026-06-18T01:02:03Z",
"version": "2017-09-30",
} {
assert.Equal(t, want, doc[k], "field=%s", k)
}
// Unmodelled fields marshal to JSON null, not absent and not "".
for _, k := range []string{"billingProducts", "devpayProductCodes", "marketplaceProductCodes", "kernelId", "ramdiskId"} {
v, ok := doc[k]
assert.True(t, ok, "field=%s present", k)
assert.Nil(t, v, "field=%s null", k)
}
}
// An instance that is no longer visible (terminating/invisible) is a 404, not a
// document with empty fields.
func TestHTTP_InstanceIdentityDocumentInvisible404(t *testing.T) {
res := &fakeResolver{eni: testENI(), inst: nil}
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
h := svc.httpHandler()
token := issueToken(t, h)
rec := get(t, h, pathIdentityDocument, token)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
// ----- metadata surface --------------------------------------------------
func TestHTTP_MetadataPaths(t *testing.T) {
res := &fakeResolver{
eni: testENI(),
inst: &instanceFacts{instanceType: "t3.micro", imageID: "ami-12345", userData: []byte("#!/bin/sh\necho hi")},
eni: testENI(),
inst: &instanceFacts{
instanceType: "t3.micro",
imageID: "ami-12345",
reservationID: "r-0abc123",
amiLaunchIndex: 3,
userData: []byte("#!/bin/sh\necho hi"),
},
sgNames: map[string]string{"sg-1": "web-sg", "sg-2": "db-sg"},
}
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
@@ -244,14 +387,22 @@ func TestHTTP_MetadataPaths(t *testing.T) {
{prefixMetaData + "instance-id", "i-0123456789"},
{prefixMetaData + "instance-type", "t3.micro"},
{prefixMetaData + "ami-id", "ami-12345"},
{prefixMetaData + "ami-launch-index", "3"},
{prefixMetaData + "reservation-id", "r-0abc123"},
{prefixMetaData + "instance-life-cycle", "on-demand"},
{prefixMetaData + "local-ipv4", "10.0.1.5"},
{prefixMetaData + "public-ipv4", "203.0.113.7"},
{prefixMetaData + "public-hostname", "203.0.113.7"},
{prefixMetaData + "mac", "02:11:22:33:44:55"},
{prefixMetaData + "placement/availability-zone", "ap-southeast-2a"},
{prefixMetaData + "placement/region", "ap-southeast-2"},
{prefixMetaData + "security-groups", "web-sg\ndb-sg"},
{prefixMetaData + "hostname", "ip-10-0-1-5.ap-southeast-2.compute.internal"},
{prefixMetaData + "local-hostname", "ip-10-0-1-5.ap-southeast-2.compute.internal"},
{prefixMetaData + "services", "domain\npartition"},
{prefixMetaData + "services/", "domain\npartition"},
{prefixMetaData + "services/domain", "amazonaws.com"},
{prefixMetaData + "services/partition", "aws"},
{pathUserData, "#!/bin/sh\necho hi"},
}
for _, c := range cases {
@@ -261,15 +412,44 @@ func TestHTTP_MetadataPaths(t *testing.T) {
}
}
// An instance with no public IP has no public hostname: 404, not an empty 200.
func TestHTTP_PublicHostnameAbsent404(t *testing.T) {
eni := testENI()
eni.publicIP = ""
svc, _ := newTestService(&fakeResolver{eni: eni}, &fakeIAM{}, &fakeAssumer{})
h := svc.httpHandler()
token := issueToken(t, h)
rec := get(t, h, prefixMetaData+"public-hostname", token)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
// The meta-data root lists every served child, alphabetically, to match AWS.
func TestHTTP_DirectoryListing(t *testing.T) {
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
h := svc.httpHandler()
token := issueToken(t, h)
rec := get(t, h, pathMetaDataRoot, token)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "instance-id")
assert.Contains(t, rec.Body.String(), "iam/")
assert.Contains(t, rec.Body.String(), "public-keys/")
want := strings.Join([]string{
"ami-id",
"ami-launch-index",
"hostname",
"iam/",
"instance-id",
"instance-life-cycle",
"instance-type",
"local-hostname",
"local-ipv4",
"mac",
"placement/",
"public-hostname",
"public-ipv4",
"public-keys/",
"reservation-id",
"security-groups",
"services/",
}, "\n")
assert.Equal(t, want, rec.Body.String())
}
func TestHTTP_UserDataAbsent404(t *testing.T) {
@@ -287,7 +467,9 @@ func TestHTTP_OutOfScopePaths404(t *testing.T) {
h := svc.httpHandler()
token := issueToken(t, h)
for _, p := range []string{
"/latest/dynamic/instance-identity/document",
pathIdentityDir + "/pkcs7", // signed forms stay deferred until the signing key lands
pathIdentityDir + "/rsa2048", //
pathIdentityDir + "/signature", //
prefixMetaData + "network/interfaces/macs",
prefixMetaData + "nonsense",
} {
+5
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"time"
"github.com/nats-io/nats.go"
)
@@ -33,6 +34,10 @@ type instanceFacts struct {
imageID string
iamInstanceProfileArn string
keyName string
architecture string
reservationID string
amiLaunchIndex int64
pendingTime time.Time
userData []byte
}
+1 -1
View File
@@ -121,7 +121,7 @@ func (s *IMDSServiceImpl) httpHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(pathToken, s.handleToken)
mux.HandleFunc("/", s.handleMetadata)
return rejectForwarded(mux)
return rejectForwarded(normalizeVersion(mux))
}
// Run starts the bind manager (sync + watch) and token-sweep ticker, blocks until ctx is done.
+58
View File
@@ -199,6 +199,32 @@ func runIMDS(t *testing.T, fix *Fixture) {
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,
@@ -229,6 +255,38 @@ func runIMDS(t *testing.T, fix *Fixture) {
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)