feat(imds): serve network/interfaces/macs/<mac> subtree (single-NIC) (#410)

This commit is contained in:
Julian Sommer
2026-06-24 09:27:40 +10:00
committed by GitHub
parent e11437cec9
commit 98db2c4e0c
5 changed files with 365 additions and 17 deletions
+1 -1
View File
@@ -628,7 +628,7 @@ curl -i http://169.254.169.254/latest/meta-data/instance-id
| `/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/network/interfaces/macs/<mac>/...` | GET | Primary ENI subtree: `mac`, `device-number`, `interface-id`, `owner-id`, `subnet-id`, `vpc-id`, `local-ipv4s`, `local-hostname`, `security-group-ids`, `security-groups`, `subnet-ipv4-cidr-block`, `vpc-ipv4-cidr-block(s)`, and `public-ipv4s`/`public-hostname` when an EIP is attached | **DONE** (single-NIC; multi-ENI deferred) |
| `/latest/meta-data/tags/instance/<key>` | GET | Instance-tag metadata; gated on `InstanceMetadataTags` enablement | **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) |
+119 -1
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -25,6 +26,7 @@ const (
pathSecurityCredsDir = "/latest/meta-data/iam/security-credentials" //nolint:gosec // URL path, not a credential
prefixPublicKeys = "/latest/meta-data/public-keys/"
pathPublicKeysDir = "/latest/meta-data/public-keys"
prefixNetworkMacs = "/latest/meta-data/network/interfaces/macs/"
prefixDynamic = "/latest/dynamic"
pathIdentityDir = "/latest/dynamic/instance-identity"
@@ -148,6 +150,11 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni *
return
}
if sub, ok := strings.CutPrefix(path, prefixNetworkMacs); ok {
s.serveNetworkInterface(w, eni, sub) // sub: "", "<mac>", "<mac>/", "<mac>/<key>"
return
}
switch path {
case "/":
writeText(w, strings.Join(supportedVersions, "\n"))
@@ -160,7 +167,7 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni *
case pathIdentityDocument:
s.serveInstanceIdentityDocument(w, eni)
case pathMetaDataRoot, prefixMetaData:
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/")
writeText(w, "ami-id\nami-launch-index\nhostname\niam/\ninstance-id\ninstance-life-cycle\ninstance-type\nlocal-hostname\nlocal-ipv4\nmac\nnetwork/\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":
@@ -177,6 +184,12 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni *
writeText(w, eni.publicIP) // mirror public-ipv4 until public DNS exists
case prefixMetaData + "mac":
writeText(w, eni.mac)
case prefixMetaData + "network", prefixMetaData + "network/":
writeText(w, "interfaces/")
case prefixMetaData + "network/interfaces", prefixMetaData + "network/interfaces/":
writeText(w, "macs/")
case prefixMetaData + "network/interfaces/macs":
writeText(w, eni.mac+"/")
case prefixMetaData + "security-groups":
writeText(w, strings.Join(s.resolver.resolveSGNames(eni.accountID, eni.securityGroupIDs), "\n"))
case prefixMetaData + "hostname", prefixMetaData + "local-hostname":
@@ -388,6 +401,111 @@ func (s *IMDSServiceImpl) servePublicKeys(w http.ResponseWriter, eni *eniFacts,
}
}
// serveNetworkInterface serves the single primary ENI's subtree under
// /network/interfaces/macs/. sub is the path after the macs/ prefix. An empty sub
// is the macs/ directory listing; a MAC that is not the caller's is 404, since the
// per-tap responder only ever resolves its own ENI (single-NIC; multi-ENI deferred).
func (s *IMDSServiceImpl) serveNetworkInterface(w http.ResponseWriter, eni *eniFacts, sub string) {
if sub == "" {
writeText(w, eni.mac+"/")
return
}
mac, key, _ := strings.Cut(sub, "/")
if mac != eni.mac {
w.WriteHeader(http.StatusNotFound)
return
}
switch key {
case "": // "<mac>" or "<mac>/" — the per-interface key listing
keys, err := s.macKeys(eni)
if err != nil {
slog.Error("IMDS: network-interface listing failed", "account_id", eni.accountID, "err", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
writeText(w, strings.Join(keys, "\n"))
case "mac":
writeText(w, eni.mac)
case "device-number":
writeText(w, "0") // primary ENI
case "interface-id":
writeText(w, eni.eniID)
case "owner-id":
writeText(w, eni.accountID)
case "subnet-id":
writeText(w, eni.subnetID)
case "vpc-id":
writeText(w, eni.vpcID)
case "local-ipv4s":
writeText(w, eni.privateIP)
case "local-hostname":
writeText(w, synthHostname(eni.privateIP, regionFromAZ(eni.availabilityZone)))
case "security-group-ids":
writeText(w, strings.Join(eni.securityGroupIDs, "\n"))
case "security-groups":
writeText(w, strings.Join(s.resolver.resolveSGNames(eni.accountID, eni.securityGroupIDs), "\n"))
case "subnet-ipv4-cidr-block":
s.serveCIDR(w, eni.accountID, eni.subnetID, s.resolver.resolveSubnetCIDR)
case "vpc-ipv4-cidr-block", "vpc-ipv4-cidr-blocks":
s.serveCIDR(w, eni.accountID, eni.vpcID, s.resolver.resolveVPCCIDR)
case "public-ipv4s", "public-hostname":
if eni.publicIP == "" {
w.WriteHeader(http.StatusNotFound)
return
}
writeText(w, eni.publicIP)
default:
w.WriteHeader(http.StatusNotFound)
}
}
// macKeys lists exactly the leaf keys served under macs/<mac>/ so cloud-init's
// recursive crawl never lists a key that 404s: CIDR keys appear only when the CIDR
// resolves, public keys only with a public IP. A resolver fault is propagated so the
// listing 500s like the leaf, never silently dropping a key on a transient KV blip.
func (s *IMDSServiceImpl) macKeys(eni *eniFacts) ([]string, error) {
keys := []string{
"device-number", "interface-id", "local-hostname", "local-ipv4s",
"mac", "owner-id", "security-group-ids", "security-groups",
"subnet-id", "vpc-id",
}
subnetCIDR, err := s.resolver.resolveSubnetCIDR(eni.accountID, eni.subnetID)
if err != nil {
return nil, err
}
if subnetCIDR != "" {
keys = append(keys, "subnet-ipv4-cidr-block")
}
vpcCIDR, err := s.resolver.resolveVPCCIDR(eni.accountID, eni.vpcID)
if err != nil {
return nil, err
}
if vpcCIDR != "" {
keys = append(keys, "vpc-ipv4-cidr-block", "vpc-ipv4-cidr-blocks")
}
if eni.publicIP != "" {
keys = append(keys, "public-hostname", "public-ipv4s")
}
sort.Strings(keys)
return keys, nil
}
// serveCIDR resolves and writes a subnet/VPC CIDR: 404 on miss, 500 on a backend
// fault, so a guest never renders network config from an empty CIDR.
func (s *IMDSServiceImpl) serveCIDR(w http.ResponseWriter, accountID, id string, resolve func(string, string) (string, error)) {
cidr, err := resolve(accountID, id)
if err != nil {
slog.Error("IMDS: network-interface CIDR resolution failed", "account_id", accountID, "id", id, "err", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if cidr == "" {
w.WriteHeader(http.StatusNotFound)
return
}
writeText(w, cidr)
}
// instanceFor resolves the instance record for an ENI, writing the appropriate
// HTTP error and returning nil on miss/failure so callers can early-return.
func (s *IMDSServiceImpl) instanceFor(w http.ResponseWriter, eni *eniFacts) *instanceFacts {
+184 -9
View File
@@ -22,11 +22,15 @@ import (
// ----- fakes -------------------------------------------------------------
type fakeResolver struct {
eni *eniFacts
eniErr error
inst *instanceFacts
instErr error
sgNames map[string]string // sg-id → group name; absent IDs fall back to the ID
eni *eniFacts
eniErr error
inst *instanceFacts
instErr error
sgNames map[string]string // sg-id → group name; absent IDs fall back to the ID
subnetCIDR string
subnetErr error
vpcCIDR string
vpcErr error
}
func (f *fakeResolver) resolveENIByID(_ string) (*eniFacts, error) { return f.eni, f.eniErr }
@@ -46,6 +50,11 @@ func (f *fakeResolver) resolveSGNames(_ string, sgIDs []string) []string {
return out
}
func (f *fakeResolver) resolveSubnetCIDR(_, _ string) (string, error) {
return f.subnetCIDR, f.subnetErr
}
func (f *fakeResolver) resolveVPCCIDR(_, _ string) (string, error) { return f.vpcCIDR, f.vpcErr }
type fakeIAM struct {
profile *handlers_iam.InstanceProfile
profileErr error
@@ -445,6 +454,7 @@ func TestHTTP_DirectoryListing(t *testing.T) {
"local-hostname",
"local-ipv4",
"mac",
"network/",
"placement/",
"public-hostname",
"public-ipv4",
@@ -471,10 +481,12 @@ func TestHTTP_OutOfScopePaths404(t *testing.T) {
h := withTapENI(svc.httpHandler(), testENI())
token := issueToken(t, h)
for _, p := range []string{
pathIdentityDir + "/pkcs7", // signed forms stay deferred until the signing key lands
pathIdentityDir + "/rsa2048", //
pathIdentityDir + "/signature", //
prefixMetaData + "network/interfaces/macs",
pathIdentityDir + "/pkcs7", // signed forms stay deferred until the signing key lands
pathIdentityDir + "/rsa2048", //
pathIdentityDir + "/signature", //
prefixNetworkMacs + testENI().mac + "/ipv4-associations", // IPv6/associations stay deferred
prefixNetworkMacs + testENI().mac + "/ipv6s", //
prefixNetworkMacs + "02:00:00:00:00:00/subnet-id", // a MAC that is not the caller's
prefixMetaData + "nonsense",
} {
rec := get(t, h, p, token)
@@ -482,6 +494,169 @@ func TestHTTP_OutOfScopePaths404(t *testing.T) {
}
}
// ----- network interfaces ------------------------------------------------
const (
testSubnetCIDR = "10.0.1.0/24"
testVPCCIDR = "10.0.0.0/16"
)
// macPath builds a /network/interfaces/macs/<mac>[/<key>] request path.
func macPath(mac, key string) string {
p := prefixNetworkMacs + mac
if key != "" {
p += "/" + key
}
return p
}
// The intermediate directory listings walk down to the caller's single MAC.
func TestHTTP_NetworkInterfacesListings(t *testing.T) {
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
h := withTapENI(svc.httpHandler(), testENI())
token := issueToken(t, h)
mac := testENI().mac
cases := []struct{ path, want string }{
{prefixMetaData + "network", "interfaces/"},
{prefixMetaData + "network/", "interfaces/"},
{prefixMetaData + "network/interfaces", "macs/"},
{prefixMetaData + "network/interfaces/", "macs/"},
{prefixMetaData + "network/interfaces/macs", mac + "/"},
{prefixMetaData + "network/interfaces/macs/", mac + "/"},
}
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)
}
}
// macs/<mac> and macs/<mac>/ list exactly the leaves served — CIDR and public keys
// included only when they resolve, so cloud-init's crawl never lists a key that 404s.
func TestHTTP_NetworkInterfaceMacDirListing(t *testing.T) {
res := &fakeResolver{eni: testENI(), subnetCIDR: testSubnetCIDR, vpcCIDR: testVPCCIDR}
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
h := withTapENI(svc.httpHandler(), testENI())
token := issueToken(t, h)
want := strings.Join([]string{
"device-number",
"interface-id",
"local-hostname",
"local-ipv4s",
"mac",
"owner-id",
"public-hostname",
"public-ipv4s",
"security-group-ids",
"security-groups",
"subnet-id",
"subnet-ipv4-cidr-block",
"vpc-id",
"vpc-ipv4-cidr-block",
"vpc-ipv4-cidr-blocks",
}, "\n")
mac := testENI().mac
for _, p := range []string{macPath(mac, ""), macPath(mac, "") + "/"} {
rec := get(t, h, p, token)
assert.Equal(t, http.StatusOK, rec.Code, "path=%s", p)
assert.Equal(t, want, rec.Body.String(), "path=%s", p)
}
}
// Every served leaf resolves from eni + resolver facts.
func TestHTTP_NetworkInterfaceLeaves(t *testing.T) {
res := &fakeResolver{
eni: testENI(),
sgNames: map[string]string{"sg-1": "web-sg", "sg-2": "db-sg"},
subnetCIDR: testSubnetCIDR,
vpcCIDR: testVPCCIDR,
}
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
h := withTapENI(svc.httpHandler(), testENI())
token := issueToken(t, h)
mac := testENI().mac
cases := []struct{ key, want string }{
{"mac", mac},
{"device-number", "0"},
{"interface-id", "eni-aaa"},
{"owner-id", "111122223333"},
{"subnet-id", "subnet-1"},
{"vpc-id", testVPC},
{"local-ipv4s", testIP},
{"local-hostname", "ip-10-0-1-5.ap-southeast-2.compute.internal"},
{"security-group-ids", "sg-1\nsg-2"},
{"security-groups", "web-sg\ndb-sg"},
{"subnet-ipv4-cidr-block", testSubnetCIDR},
{"vpc-ipv4-cidr-block", testVPCCIDR},
{"vpc-ipv4-cidr-blocks", testVPCCIDR},
{"public-ipv4s", "203.0.113.7"},
{"public-hostname", "203.0.113.7"},
}
for _, c := range cases {
rec := get(t, h, macPath(mac, c.key), token)
assert.Equal(t, http.StatusOK, rec.Code, "key=%s", c.key)
assert.Equal(t, c.want, rec.Body.String(), "key=%s", c.key)
}
}
// With no public IP, public-ipv4s and public-hostname 404 and drop from the listing.
func TestHTTP_NetworkInterfacePublicAbsent(t *testing.T) {
eni := testENI()
eni.publicIP = ""
res := &fakeResolver{eni: eni, subnetCIDR: testSubnetCIDR, vpcCIDR: testVPCCIDR}
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
h := withTapENI(svc.httpHandler(), eni)
token := issueToken(t, h)
listing := get(t, h, macPath(eni.mac, ""), token).Body.String()
assert.NotContains(t, listing, "public-ipv4s")
assert.NotContains(t, listing, "public-hostname")
for _, key := range []string{"public-ipv4s", "public-hostname"} {
rec := get(t, h, macPath(eni.mac, key), token)
assert.Equal(t, http.StatusNotFound, rec.Code, "key=%s", key)
}
}
// On a CIDR miss the CIDR leaves 404 and drop from the listing; a resolver error
// is a 500 on both the leaf and the listing, never an empty/dropped CIDR a guest
// would mis-render into its network config.
func TestHTTP_NetworkInterfaceCidrMiss(t *testing.T) {
res := &fakeResolver{eni: testENI()} // no canned CIDRs → miss
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
h := withTapENI(svc.httpHandler(), testENI())
token := issueToken(t, h)
mac := testENI().mac
listing := get(t, h, macPath(mac, ""), token).Body.String()
assert.NotContains(t, listing, "subnet-ipv4-cidr-block")
assert.NotContains(t, listing, "vpc-ipv4-cidr-block")
for _, key := range []string{"subnet-ipv4-cidr-block", "vpc-ipv4-cidr-block", "vpc-ipv4-cidr-blocks"} {
rec := get(t, h, macPath(mac, key), token)
assert.Equal(t, http.StatusNotFound, rec.Code, "key=%s", key)
}
errRes := &fakeResolver{eni: testENI(), subnetErr: errors.New("kv unavailable"), vpcErr: errors.New("kv unavailable")}
errSvc, _ := newTestService(errRes, &fakeIAM{}, &fakeAssumer{})
errH := withTapENI(errSvc.httpHandler(), testENI())
errToken := issueToken(t, errH)
for _, key := range []string{"subnet-ipv4-cidr-block", "vpc-ipv4-cidr-block", "vpc-ipv4-cidr-blocks"} {
rec := get(t, errH, macPath(mac, key), errToken)
assert.Equal(t, http.StatusInternalServerError, rec.Code, "key=%s", key)
}
listingRec := get(t, errH, macPath(mac, ""), errToken)
assert.Equal(t, http.StatusInternalServerError, listingRec.Code, "listing under resolver error")
}
// A MAC that is not the caller's is 404: the per-tap responder only serves its own ENI.
func TestHTTP_NetworkInterfaceForeignMac404(t *testing.T) {
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
h := withTapENI(svc.httpHandler(), testENI())
token := issueToken(t, h)
rec := get(t, h, macPath("02:00:00:00:00:00", "subnet-id"), token)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
// ----- public keys -------------------------------------------------------
func TestHTTP_PublicKeys(t *testing.T) {
+41 -3
View File
@@ -14,6 +14,8 @@ import (
// kvBucketENIs and kvBucketSecurityGroups are duplicated as literals to avoid an import cycle.
const kvBucketENIs = "spinifex-vpc-enis"
const kvBucketSecurityGroups = "spinifex-vpc-security-groups"
const kvBucketSubnets = "spinifex-vpc-subnets"
const kvBucketVPCs = "spinifex-vpc-vpcs"
// eniFacts is the ENI fields served by the IMDS metadata surface, read live from the ENI record.
type eniFacts struct {
@@ -68,9 +70,11 @@ type eniRecord struct {
// metadataResolver resolves a tap's ENI ID to ENI + instance facts.
// Resolution chain: eniID → ENIRecord (account recovered from the bucket key) → instanceFacts.
type metadataResolver struct {
eniKV nats.KeyValue // spinifex-vpc-enis
sgKV nats.KeyValue // spinifex-vpc-security-groups (nil-safe: degrades to IDs)
lookup instanceLookup
eniKV nats.KeyValue // spinifex-vpc-enis
sgKV nats.KeyValue // spinifex-vpc-security-groups (nil-safe: degrades to IDs)
subnetKV nats.KeyValue // spinifex-vpc-subnets (nil-safe: CIDR leaf 404s)
vpcKV nats.KeyValue // spinifex-vpc-vpcs (nil-safe: CIDR leaf 404s)
lookup instanceLookup
}
var _ eniResolver = (*metadataResolver)(nil)
@@ -178,3 +182,37 @@ func (r *metadataResolver) resolveSGNames(accountID string, sgIDs []string) []st
}
return names
}
// cidrRecord is the subnet/VPC record subset the network-interfaces subtree reads.
type cidrRecord struct {
CidrBlock string `json:"cidr_block"`
}
func (r *metadataResolver) resolveSubnetCIDR(accountID, subnetID string) (string, error) {
return cidrFromKV(r.subnetKV, accountID, subnetID)
}
func (r *metadataResolver) resolveVPCCIDR(accountID, vpcID string) (string, error) {
return cidrFromKV(r.vpcKV, accountID, vpcID)
}
// cidrFromKV reads cidr_block from an account-scoped record. ("", nil) on a nil
// bucket or key miss; the error on any other fault so the leaf 500s rather than
// serving an empty CIDR a guest would mis-render into its network config.
func cidrFromKV(kv nats.KeyValue, accountID, id string) (string, error) {
if kv == nil || id == "" {
return "", nil
}
entry, err := kv.Get(accountID + "." + id)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
return "", nil
}
return "", fmt.Errorf("get cidr record %s.%s: %w", accountID, id, err)
}
var rec cidrRecord
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
return "", fmt.Errorf("unmarshal cidr record %s.%s: %w", accountID, id, err)
}
return rec.CidrBlock, nil
}
+20 -3
View File
@@ -49,6 +49,8 @@ type eniResolver interface {
resolveENIByID(eniID string) (*eniFacts, error)
resolveInstance(eni *eniFacts) (*instanceFacts, error)
resolveSGNames(accountID string, sgIDs []string) []string
resolveSubnetCIDR(accountID, subnetID string) (string, error)
resolveVPCCIDR(accountID, vpcID string) (string, error)
}
// IMDSServiceImpl is the in-process IMDS implementation. It runs one per-tap
@@ -101,11 +103,26 @@ func NewIMDSServiceImpl(natsConn *nats.Conn, sts stsAssumer, iamSvc profileLooku
sgKV = nil
}
// Open subnet/VPC buckets best-effort; the network-interfaces CIDR leaves 404
// (and drop from the listing) when a bucket is unavailable, IMDS still starts.
subnetKV, err := js.KeyValue(kvBucketSubnets)
if err != nil {
slog.Warn("IMDS: subnet bucket unavailable, network-interfaces subnet CIDR will 404", "bucket", kvBucketSubnets, "err", err)
subnetKV = nil
}
vpcKV, err := js.KeyValue(kvBucketVPCs)
if err != nil {
slog.Warn("IMDS: vpc bucket unavailable, network-interfaces VPC CIDR will 404", "bucket", kvBucketVPCs, "err", err)
vpcKV = nil
}
svc := &IMDSServiceImpl{
resolver: &metadataResolver{
eniKV: eniKV,
sgKV: sgKV,
lookup: &natsInstanceLookup{nc: natsConn, expectedNodes: expectedNodes},
eniKV: eniKV,
sgKV: sgKV,
subnetKV: subnetKV,
vpcKV: vpcKV,
lookup: &natsInstanceLookup{nc: natsConn, expectedNodes: expectedNodes},
},
tokens: newTokenStore(),
creds: newCredCache(sts),