summaryrefslogtreecommitdiffstats
path: root/nixos/tests/ec2-image.nix
blob: b711ceadc2d5142b841c99cd7514b26731e7a512 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
# Run with:
#   cd nixpkgs
#   nix-build -A nixosTests.ec2-image

{
  config,
  lib,
  hostPkgs,
  ...
}:

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 {
      system = null;
      modules = [
        ../maintainers/scripts/ec2/amazon-image.nix
        ../modules/testing/test-instrumentation.nix
        ../modules/profiles/qemu-guest.nix
        {
          amazonImage.format = "qcow2";

          # In a NixOS test the serial console is occupied by the "backdoor"
          # (see testing/test-instrumentation.nix) and is incompatible with
          # the configuration in virtualisation/amazon-image.nix.
          systemd.services."serial-getty@ttyS0".enable = mkForce false;

          # Make /dev/console point to serial console for proper capture
          # test-instrumentation.nix adds console=tty0, so we need to add console=ttyS0 AFTER it
          # The last console= parameter takes precedence for /dev/console
          boot.kernelParams = mkAfter [ "console=ttyS0" ];

          # Configure VLAN networking to match test framework setup
          networking.interfaces.eth0 = {
            ipv4.addresses = [
              {
                address = config.nodes.machine.networking.primaryIPAddress;
                prefixLength = 24;
              }
            ];
          };

          # Packages needed for IPv6 IMDS fallback test
          environment.systemPackages = [
            pkgs.socat
            imdsServer
            pkgs.iptables
          ];

          nixpkgs.pkgs = pkgs;
        }
      ];
    }).config;

  image = "${imageCfg.system.build.amazonImage}/${imageCfg.image.fileName}";

in
{
  name = "ec2-image";
  meta = {
    maintainers = with lib.maintainers; [
      roberth
      arianvp
    ];
    timeout = 600;
  };
  nodes = {
    machine = { ... }: { }; # Dummy node for network config - won't be launched
    client =
      { ... }:
      {
        # Configure SSH client for non-interactive, strict authentication
        programs.ssh.extraConfig = ''
          Host *
            PasswordAuthentication no
            ChallengeResponseAuthentication no
            HostbasedAuthentication no
            BatchMode yes
            PubkeyAuthentication yes
            StrictHostKeyChecking yes
        '';
      };
  };

  testScript =
    { nodes, ... }:
    ''
      import os
      import re
      import subprocess
      import tempfile

      # Instance Metadata Service (IMDSv2 with 1.0 metadata version)
      # TODO: Use 'latest' metadata version instead of '1.0'
      # 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")

          # Create directory structure
          os.makedirs(os.path.join(metadata_dir, "1.0", "meta-data", "public-keys", "0"), exist_ok=True)
          os.makedirs(os.path.join(metadata_dir, "latest", "api"), exist_ok=True)

          # Metadata version 1.0 endpoints (what fetch-ec2-metadata.sh actually fetches)
          with open(os.path.join(metadata_dir, "1.0", "meta-data", "hostname"), "w") as f:
              f.write("test-instance")
          with open(os.path.join(metadata_dir, "1.0", "meta-data", "ami-manifest-path"), "w") as f:
              f.write("(test)")
          with open(os.path.join(metadata_dir, "1.0", "meta-data", "instance-id"), "w") as f:
              f.write("i-1234567890abcdef0")
          with open(os.path.join(metadata_dir, "1.0", "user-data"), "w") as f:
              f.write("")
          with open(os.path.join(metadata_dir, "1.0", "meta-data", "public-keys", "0", "openssh-key"), "w") as f:
              f.write(client_pubkey)

          # IMDSv2 token endpoint - return a fake token
          with open(os.path.join(metadata_dir, "latest", "api", "token"), "w") as f:
              f.write("test-token-12345")

          return metadata_dir

      def generate_client_ssh_key():
          """Generate SSH key pair on VM host for client authentication"""
          # Use temporary directory for key generation
          import tempfile
          with tempfile.TemporaryDirectory() as key_dir:
              private_key = os.path.join(key_dir, "id_ed25519")
              public_key = os.path.join(key_dir, "id_ed25519.pub")

              # Generate key pair using host SSH tools
              ret = subprocess.run(
                  [
                      "${hostPkgs.openssh}/bin/ssh-keygen",
                      "-t",
                      "ed25519",
                      "-f",
                      private_key,
                      "-N",
                      ""
                  ]
              ).returncode
              if ret != 0:
                  raise Exception("Failed to generate SSH key pair")

              # Read the generated public key
              with open(public_key, "r") as f:
                  client_pubkey = f.read().strip()

              # Read the private key
              with open(private_key, "r") as f:
                  client_private_key = f.read()

              return client_pubkey, client_private_key

      def setup_client_ssh_key(client, client_private_key):
          """Install the pre-generated SSH private key on client"""
          client.succeed("mkdir -p /root/.ssh")
          client.succeed(f"cat > /root/.ssh/id_ed25519 << 'EOF'\n{client_private_key}\nEOF")
          client.succeed("chmod 600 /root/.ssh/id_ed25519")

      def setup_machine(temp_dir, client_pubkey):
          """Initialize EC2 machine with disk image, metadata server, and networking"""
          # Set up disk image
          image_dir = os.path.join(
              os.environ.get("TMPDIR", tempfile.gettempdir()), "tmp", "vm-state-machine"
          )
          os.makedirs(image_dir, mode=0o700, exist_ok=True)
          disk_image = os.path.join(image_dir, "machine.qcow2")
          QEMU = "${nodes.machine.virtualisation.qemu.package}"
          QEMU_IMG = f"{QEMU}/bin/qemu-img"
          subprocess.check_call([
              QEMU_IMG, "create", "-f", "qcow2", "-F", "qcow2",
              "-o", "backing_file=${image}", disk_image
          ])
          subprocess.check_call([QEMU_IMG, "resize", disk_image, "10G"])

          # Create fake EC2 metadata in temporary directory with client's public key
          metadata_dir = create_ec2_metadata_dir(temp_dir, client_pubkey)

          # Add both VLAN networking (matching test framework) and EC2 metadata server
          vlan_net = (
              " -device virtio-net-pci,netdev=vlan1,mac=52:54:00:12:01:02"
              + ' -netdev vde,id=vlan1,sock="$QEMU_VDE_SOCKET_1"'
          )
          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:${lib.getExe imdsServer} {metadata_dir}'"
          )

          start_command = (
              f"{QEMU}/bin/qemu-kvm -m 1024"
              + f" -drive file={disk_image},if=virtio,werror=report"
              + vlan_net
              + metadata_net
              + " $QEMU_OPTS"
          )

          return create_machine(start_command), metadata_dir

      def test_userdata_decompression(machine, user_data_path, compressed_data, format_name):
          """Test that compressed user-data is decompressed by fetch-ec2-metadata"""
          test_marker = f"{format_name}-decompression-test"
          with open(user_data_path, "wb") as f:
              f.write(compressed_data)
          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")
          assert "decompressing:" in journal, f"Expected decompression log in journal for {format_name}"

      # Create temporary directory for metadata (scoped for cleanup)
      temp_dir = tempfile.TemporaryDirectory()

      # Start client first (but don't wait for it to boot)
      client.start()

      # Generate SSH key pair on VM host before starting machine
      client_pubkey, client_private_key = generate_client_ssh_key()

      # Set up machine with client's public key in metadata service
      machine, metadata_dir = setup_machine(temp_dir, client_pubkey)
      user_data_path = os.path.join(metadata_dir, "1.0", "user-data")

      try:
          machine.start()

          # Wait for services to be ready
          machine.wait_for_unit("sshd.service")
          machine.wait_for_unit("print-host-key.service")
          machine.wait_for_unit("apply-ec2-data.service")

          # Extract shared variables outside subtests
          machine_ip = "${config.nodes.machine.networking.primaryIPAddress}"

          with subtest("EC2 metadata service connectivity"):
              # 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"):
              console_log = machine.get_console_log()
              assert "-----BEGIN SSH HOST KEY FINGERPRINTS-----" in console_log
              assert "-----END SSH HOST KEY FINGERPRINTS-----" in console_log
              assert "-----BEGIN SSH HOST KEY KEYS-----" in console_log
              assert "-----END SSH HOST KEY KEYS-----" in console_log

              keys_pattern = r"-----BEGIN SSH HOST KEY KEYS-----(.*?)-----END SSH HOST KEY KEYS-----"
              keys_match = re.search(keys_pattern, console_log, re.DOTALL)
              assert keys_match, "Could not find SSH host keys section"
              keys_content = keys_match.group(1).strip()
              assert "ssh-" in keys_content, "SSH keys should contain ssh- prefix"

          with subtest("Network connectivity"):
              client.succeed(f"ping -c 1 {machine_ip}")

          with subtest("SSH connectivity with strict host key checking"):
              # Install the pre-generated private key on client
              setup_client_ssh_key(client, client_private_key)

              # Get console log and extract host keys
              console_log = machine.get_console_log()
              keys_pattern = r"-----BEGIN SSH HOST KEY KEYS-----(.*?)-----END SSH HOST KEY KEYS-----"
              keys_match = re.search(keys_pattern, console_log, re.DOTALL)
              assert keys_match, "Could not find SSH host keys section"

              # Create known_hosts file from console-extracted host keys
              keys_content = keys_match.group(1).strip()
              known_hosts_entries = []
              for line in keys_content.split('\n'):
                  if line.strip() and line.startswith('ssh-'):
                      known_hosts_entries.append(f"{machine_ip} {line.strip()}")

              assert known_hosts_entries, "No SSH host keys found for known_hosts generation"

              known_hosts_content = '\n'.join(known_hosts_entries)
              client.succeed(f"cat > /root/.ssh/known_hosts << 'EOF'\n{known_hosts_content}\nEOF")

              # Test SSH connectivity with strict host key checking
              ssh_result = client.succeed(f"ssh -o ConnectTimeout=60 -o BatchMode=yes -i /root/.ssh/id_ed25519 root@{machine_ip} 'echo Hello from $(hostname)'")
              assert "Hello from test-instance" in ssh_result, f"Unexpected SSH result: {ssh_result}"

          with subtest("Basic EC2 functionality"):
              machine.succeed("findmnt / -o SIZE -n | grep -E '[0-9]+G'")

          with subtest("Decompression of gzip-compressed user-data"):
              import gzip as gzip_mod
              test_data = b"#!/bin/bash\necho gzip-decompression-test\n"
              test_userdata_decompression(machine, user_data_path, gzip_mod.compress(test_data), "gzip")

          with subtest("Decompression of bzip2-compressed user-data"):
              import bz2
              test_data = b"#!/bin/bash\necho bzip2-decompression-test\n"
              test_userdata_decompression(machine, user_data_path, bz2.compress(test_data), "bzip2")

          with subtest("Decompression of xz-compressed user-data"):
              import lzma
              test_data = b"#!/bin/bash\necho xz-decompression-test\n"
              test_userdata_decompression(machine, user_data_path, lzma.compress(test_data), "xz")

          with subtest("Decompression of zstd-compressed user-data"):
              test_data = b"#!/bin/bash\necho zstd-decompression-test\n"
              proc = subprocess.run(
                  ["${hostPkgs.zstd}/bin/zstd", "-c"],
                  input=test_data, capture_output=True, check=True,
              )
              test_userdata_decompression(machine, user_data_path, proc.stdout, "zstd")

          with subtest("Decompression of lzip-compressed user-data"):
              test_data = b"#!/bin/bash\necho lzip-decompression-test\n"
              proc = subprocess.run(
                  ["${hostPkgs.lzip}/bin/lzip", "-c"],
                  input=test_data, capture_output=True, check=True,
              )
              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()
    '';
}