nixos/virtualization/ec2: add IPv6 IMDS fetch capability (#476897)

This commit is contained in:
Philip Taron
2026-03-25 03:18:27 +00:00
committed by GitHub
9 changed files with 226 additions and 25 deletions
@@ -31,7 +31,7 @@ in
;
inherit (config.image) baseName;
additionalSpace = "1024M";
pkgs = import ../../../.. { inherit (pkgs) system; }; # ensure we use the regular qemu-kvm package
pkgs = import ../../../.. { inherit (pkgs.stdenv.hostPlatform) system; }; # ensure we use the regular qemu-kvm package
configFile = pkgs.writeText "configuration.nix" ''
{
imports = [ <nixpkgs/nixos/modules/virtualisation/openstack-config.nix> ];
@@ -3,7 +3,12 @@ mkdir -p "$metaDir"
chmod 0755 "$metaDir"
rm -f "$metaDir/*"
IMDS_ENDPOINTS="http://169.254.169.254 http://[fd00:ec2::254]"
IMDS_BASE_URL="http://169.254.169.254"
IMDS_TOKEN=""
get_imds_token() {
local endpoint=$1
# retry-delay of 1 selected to give the system a second to get going,
# but not add a lot to the bootup time
curl \
@@ -15,10 +20,11 @@ get_imds_token() {
-X PUT \
--connect-timeout 1 \
-H "X-aws-ec2-metadata-token-ttl-seconds: 600" \
http://169.254.169.254/latest/api/token
"$endpoint/latest/api/token"
}
preflight_imds_token() {
local endpoint=$1
# retry-delay of 1 selected to give the system a second to get going,
# but not add a lot to the bootup time
curl \
@@ -30,13 +36,18 @@ preflight_imds_token() {
--connect-timeout 1 \
-H "X-aws-ec2-metadata-token: $IMDS_TOKEN" \
-o /dev/null \
http://169.254.169.254/1.0/meta-data/instance-id
"$endpoint/1.0/meta-data/instance-id"
}
try=1
while [ $try -le 3 ]; do
echo "(attempt $try/3) getting an EC2 instance metadata service v2 token..."
IMDS_TOKEN=$(get_imds_token) && break
for endpoint in $IMDS_ENDPOINTS; do
IMDS_TOKEN=$(get_imds_token "$endpoint") && IMDS_BASE_URL=$endpoint && break
done
if [ -n "$IMDS_TOKEN" ]; then
break
fi
try=$((try + 1))
sleep 1
done
@@ -48,7 +59,7 @@ fi
try=1
while [ $try -le 10 ]; do
echo "(attempt $try/10) validating the EC2 instance metadata service v2 token..."
preflight_imds_token && break
preflight_imds_token "$IMDS_BASE_URL" && break
try=$((try + 1))
sleep 1
done
@@ -85,7 +96,7 @@ try_decompress() {
fi
}
get_imds -o "$metaDir/ami-manifest-path" http://169.254.169.254/1.0/meta-data/ami-manifest-path
(umask 077 && get_imds -o "$metaDir/user-data" http://169.254.169.254/1.0/user-data && try_decompress "$metaDir/user-data")
get_imds -o "$metaDir/hostname" http://169.254.169.254/1.0/meta-data/hostname
get_imds -o "$metaDir/public-keys-0-openssh-key" http://169.254.169.254/1.0/meta-data/public-keys/0/openssh-key
get_imds -o "$metaDir/ami-manifest-path" "$IMDS_BASE_URL/1.0/meta-data/ami-manifest-path"
(umask 077 && get_imds -o "$metaDir/user-data" "$IMDS_BASE_URL/1.0/user-data" && try_decompress "$metaDir/user-data")
get_imds -o "$metaDir/hostname" "$IMDS_BASE_URL/1.0/meta-data/hostname"
get_imds -o "$metaDir/public-keys-0-openssh-key" "$IMDS_BASE_URL/1.0/meta-data/public-keys/0/openssh-key"
+12 -3
View File
@@ -2,12 +2,17 @@
with pkgs.lib;
let
imdsServer = import ./imds-server.nix { inherit pkgs; };
in
{
inherit imdsServer;
makeEc2Test =
{
name,
image,
userData,
userData ? null,
script,
hostname ? "ec2-instance",
sshPublicKey ? null,
@@ -18,9 +23,13 @@ with pkgs.lib;
name = "metadata";
buildCommand = ''
mkdir -p $out/1.0/meta-data
ln -s ${pkgs.writeText "userData" userData} $out/1.0/user-data
${optionalString (
userData != null
) "ln -s ${pkgs.writeText "userData" userData} $out/1.0/user-data"}
${optionalString (userData == null) "touch $out/1.0/user-data"}
echo "${hostname}" > $out/1.0/meta-data/hostname
echo "(unknown)" > $out/1.0/meta-data/ami-manifest-path
echo "i-1234567890abcdef0" > $out/1.0/meta-data/instance-id
''
+ optionalString (sshPublicKey != null) ''
mkdir -p $out/1.0/meta-data/public-keys/0
@@ -67,7 +76,7 @@ with pkgs.lib;
start_command = (
"qemu-kvm -m 1024"
+ " -device virtio-net-pci,netdev=vlan0"
+ " -netdev 'user,id=vlan0,net=169.0.0.0/8,guestfwd=tcp:169.254.169.254:80-cmd:${pkgs.micro-httpd}/bin/micro_httpd ${metaData}'"
+ " -netdev 'user,id=vlan0,net=169.0.0.0/8,guestfwd=tcp:169.254.169.254:80-cmd:${getExe imdsServer} ${metaData}'"
+ f" -drive file={disk_image},if=virtio,werror=report"
+ " $QEMU_OPTS"
)
+4
View File
@@ -0,0 +1,4 @@
# Minimal IMDSv2-compatible metadata server for NixOS EC2 tests.
# Runs in inetd mode (stdin/stdout), drop-in for micro_httpd in
# QEMU guestfwd and socat contexts.
{ pkgs }: pkgs.writers.writePython3Bin "imds-server" { } (builtins.readFile ./imds-server.py)
+102
View File
@@ -0,0 +1,102 @@
"""Minimal IMDSv2-compatible metadata server for NixOS EC2 tests.
Runs in inetd mode: reads one HTTP request from stdin, writes the
response to stdout. Drop-in replacement for micro_httpd in QEMU
guestfwd and socat contexts.
Usage: imds-server <metadata-directory>
The metadata directory should contain:
latest/api/token - Token value (returned on PUT)
1.0/meta-data/hostname - Instance hostname
1.0/meta-data/ami-manifest-path - AMI manifest path
1.0/meta-data/instance-id - Instance ID
1.0/meta-data/public-keys/0/openssh-key - SSH public key
1.0/user-data - User data
"""
import os
import sys
def read_request():
"""Read and parse one HTTP request from stdin (inetd mode)."""
request_line = sys.stdin.readline()
if not request_line:
sys.exit(0)
parts = request_line.strip().split()
method = parts[0] if parts else ""
path = parts[1] if len(parts) > 1 else "/"
headers = {}
while True:
line = sys.stdin.readline()
if not line or line.strip() == "":
break
if ":" in line:
key, _, value = line.partition(":")
headers[key.strip().lower()] = value.strip()
return method, path, headers
def respond(status, body):
"""Write an HTTP response to stdout."""
if isinstance(body, str):
body = body.encode()
header = (
f"HTTP/1.1 {status}\r\n"
f"Content-Type: text/plain\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n"
f"\r\n"
).encode()
sys.stdout.buffer.write(header + body)
sys.stdout.buffer.flush()
def main():
base_dir = sys.argv[1] if len(sys.argv) > 1 else "."
# Load expected token from file. If no token file exists, IMDSv2
# authentication is disabled — requests are served without tokens.
# This supports both EC2 (IMDSv2 with tokens) and OpenStack (plain GET)
# metadata fetchers.
token_path = os.path.join(base_dir, "latest", "api", "token")
if os.path.isfile(token_path):
with open(token_path) as f:
expected_token = f.read().strip()
else:
expected_token = None
method, path, headers = read_request()
rel_path = path.lstrip("/")
# PUT /latest/api/token — IMDSv2 token acquisition
if method == "PUT" and rel_path == "latest/api/token":
if expected_token is not None:
respond("200 OK", expected_token)
else:
respond("404 Not Found", "IMDSv2 token endpoint not configured\n")
return
# Token validation (only when a token file is present)
if expected_token is not None:
request_token = headers.get("x-aws-ec2-metadata-token", "")
if request_token != expected_token:
respond("401 Unauthorized", "Invalid or missing IMDSv2 token\n")
return
# Serve file from the metadata directory
file_path = os.path.join(base_dir, rel_path)
if os.path.isfile(file_path):
with open(file_path, "rb") as f:
content = f.read()
respond("200 OK", content)
else:
respond("404 Not Found", f"Not found: {path}\n")
if __name__ == "__main__":
main()
+83 -8
View File
@@ -13,6 +13,8 @@ let
inherit (lib) mkAfter mkForce;
pkgs = config.node.pkgs;
imdsServer = import ./common/imds-server.nix { inherit pkgs; };
# Build an EC2 image configuration
imageCfg =
(import ../lib/eval-config.nix {
@@ -44,6 +46,13 @@ let
];
};
# Packages needed for IPv6 IMDS fallback test
environment.systemPackages = [
pkgs.socat
imdsServer
pkgs.iptables
];
nixpkgs.pkgs = pkgs;
}
];
@@ -87,11 +96,8 @@ in
# Instance Metadata Service (IMDSv2 with 1.0 metadata version)
# TODO: Use 'latest' metadata version instead of '1.0'
# - Consider https://github.com/aws/amazon-ec2-metadata-mock
# - Blocked on https://github.com/aws/amazon-ec2-metadata-mock/issues/234
# - Consider https://github.com/purpleclay/imds-mock
# - [Test matrix] also test providing the host key through IMDS
# - i.e. a test module argument to select between writing or reading the host key
# TODO: [Test matrix] also test providing the host key through IMDS
# - i.e. a test module argument to select between writing or reading the host key
def create_ec2_metadata_dir(temp_dir, client_pubkey):
"""Create fake EC2 metadata directory structure with mock data"""
metadata_dir = os.path.join(temp_dir.name, "ec2-metadata")
@@ -171,7 +177,7 @@ in
)
metadata_net = (
" -device virtio-net-pci,netdev=ec2meta"
+ f" -netdev 'user,id=ec2meta,net=169.0.0.0/8,guestfwd=tcp:169.254.169.254:80-cmd:${pkgs.micro-httpd}/bin/micro_httpd {metadata_dir}'"
+ f" -netdev 'user,id=ec2meta,net=169.0.0.0/8,guestfwd=tcp:169.254.169.254:80-cmd:${lib.getExe imdsServer} {metadata_dir}'"
)
start_command = (
@@ -189,7 +195,7 @@ in
test_marker = f"{format_name}-decompression-test"
with open(user_data_path, "wb") as f:
f.write(compressed_data)
machine.succeed("systemctl restart fetch-ec2-metadata")
machine.succeed("systemctl reset-failed fetch-ec2-metadata; systemctl restart fetch-ec2-metadata")
result = machine.succeed("cat /etc/ec2-metadata/user-data")
assert test_marker in result, f"Expected '{test_marker}' in decompressed {format_name} content, got: {result}"
journal = machine.succeed("journalctl -u fetch-ec2-metadata --no-pager -b")
@@ -220,7 +226,16 @@ in
machine_ip = "${config.nodes.machine.networking.primaryIPAddress}"
with subtest("EC2 metadata service connectivity"):
hostname_response = machine.succeed("curl --fail -s http://169.254.169.254/1.0/meta-data/hostname")
# Obtain an IMDSv2 token, then use it to fetch metadata
imds_token = machine.succeed(
"curl -sf -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 600'"
" http://169.254.169.254/latest/api/token"
).strip()
assert imds_token, "Failed to obtain IMDSv2 token"
hostname_response = machine.succeed(
f"curl -sf -H 'X-aws-ec2-metadata-token: {imds_token}'"
" http://169.254.169.254/1.0/meta-data/hostname"
)
assert "test-instance" in hostname_response, f"Expected 'test-instance', got: {hostname_response}"
with subtest("SSH host key extraction from console"):
@@ -299,6 +314,66 @@ in
)
test_userdata_decompression(machine, user_data_path, proc.stdout, "lzip")
with subtest("IPv6 IMDS fallback"):
# Save hostname fetched via IPv4 for later comparison
original_hostname = machine.succeed("cat /etc/ec2-metadata/hostname").strip()
# Assign the EC2 IPv6 IMDS address to loopback
machine.succeed("ip -6 addr add fd00:ec2::254/128 dev lo")
# Create metadata directory structure for the IPv6 endpoint
machine.succeed(
"mkdir -p /tmp/ipv6-metadata/1.0/meta-data/public-keys/0"
" && mkdir -p /tmp/ipv6-metadata/latest/api"
" && cp /etc/ec2-metadata/hostname /tmp/ipv6-metadata/1.0/meta-data/hostname"
" && cp /etc/ec2-metadata/ami-manifest-path /tmp/ipv6-metadata/1.0/meta-data/ami-manifest-path"
" && echo i-1234567890abcdef0 > /tmp/ipv6-metadata/1.0/meta-data/instance-id"
" && echo ipv6-test-token > /tmp/ipv6-metadata/latest/api/token"
" && touch /tmp/ipv6-metadata/1.0/user-data"
)
machine.execute(
"test -f /etc/ec2-metadata/public-keys-0-openssh-key"
" && cp /etc/ec2-metadata/public-keys-0-openssh-key"
" /tmp/ipv6-metadata/1.0/meta-data/public-keys/0/openssh-key"
)
# Serve metadata on the IPv6 IMDS address via socat + imds-server (inetd-style)
machine.succeed(
"systemd-run --unit=ipv6-imds --"
" socat TCP6-LISTEN:80,bind=[fd00:ec2::254],fork,reuseaddr"
" SYSTEM:'${lib.getExe imdsServer} /tmp/ipv6-metadata'"
)
# Wait for IPv6 IMDS to become reachable (token endpoint doesn't require auth)
machine.wait_until_succeeds(
"curl -sf -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 600'"
" http://[fd00:ec2::254]/latest/api/token"
)
# Block IPv4 IMDS to force fallback to IPv6
machine.succeed(
"iptables -I OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j REJECT"
)
# Verify IPv4 IMDS is now unreachable
machine.fail(
"curl -sf --connect-timeout 2 http://169.254.169.254/1.0/meta-data/hostname"
)
# Clear fetched metadata and re-run the fetcher
machine.succeed("rm -f /etc/ec2-metadata/*")
machine.succeed("systemctl restart fetch-ec2-metadata")
# Verify metadata was successfully re-fetched via IPv6
hostname = machine.succeed("cat /etc/ec2-metadata/hostname").strip()
assert hostname == original_hostname, f"Expected '{original_hostname}', got '{hostname}'"
# Clean up: restore IPv4 IMDS access
machine.succeed(
"iptables -D OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j REJECT"
)
machine.succeed("systemctl stop ipv6-imds")
finally:
machine.shutdown()
temp_dir.cleanup()
+1 -1
View File
@@ -59,7 +59,7 @@ let
}
];
}).config;
image = "${imageCfg.system.build.amazonImage}/${imageCfg.image.imageFile}";
image = "${imageCfg.system.build.amazonImage}/${imageCfg.image.fileName}";
sshKeys = import ./ssh-keys.nix pkgs;
snakeOilPrivateKey = sshKeys.snakeOilPrivateKey.text;
-1
View File
@@ -51,7 +51,6 @@ in
makeEc2Test {
name = "image-contents";
inherit image;
userData = null;
script = ''
machine.start()
# Test that if contents includes a file, it is copied to the target.
+4 -3
View File
@@ -10,7 +10,7 @@ with pkgs.lib;
with import common/ec2.nix { inherit makeTest pkgs; };
let
image =
imageCfg =
(import ../lib/eval-config.nix {
system = null;
modules = [
@@ -26,8 +26,8 @@ let
nixpkgs.pkgs = pkgs;
}
];
}).config.system.build.openstackImage
+ "/nixos.qcow2";
}).config;
image = "${imageCfg.system.build.openstackImage}/${imageCfg.image.fileName}";
sshKeys = import ./ssh-keys.nix pkgs;
snakeOilPrivateKey = sshKeys.snakeOilPrivateKey.text;
@@ -80,6 +80,7 @@ in
userdata = makeEc2Test {
name = "openstack-ec2-metadata";
meta.broken = true; # amazon-init wants to download from the internet while building the system
inherit image;
sshPublicKey = snakeOilPublicKey;
userData = ''