diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 956710d0a..811ecf3fd 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -15,7 +15,7 @@ jobs: run: rustup target add x86_64-unknown-linux-musl - name: Build and install libkrun to test prefix - run: make test-prefix + run: make test-prefix NET=1 - name: Clippy (test_cases guest) run: | @@ -45,13 +45,20 @@ jobs: sudo usermod -a -G kvm $USER - name: Install additional packages - run: sudo apt-get install -y --no-install-recommends build-essential patchelf pkg-config net-tools + run: sudo apt-get install -y --no-install-recommends build-essential patchelf pkg-config net-tools buildah dnsmasq iperf3 + + - name: Install passt from source + run: | + curl -L https://passt.top/passt/snapshot/passt-2026_01_20.386b5f5.tar.gz | tar xz + cd passt-2026_01_20.386b5f5 + make + sudo make install - name: Install libkrunfw run: TAG=`curl -sL https://api.github.com/repos/containers/libkrunfw/releases/latest |jq -r .tag_name` && curl -L -o /tmp/libkrunfw-x86_64.tgz https://github.com/containers/libkrunfw/releases/download/$TAG/libkrunfw-x86_64.tgz && mkdir tmp && tar xf /tmp/libkrunfw-x86_64.tgz -C tmp && sudo mv tmp/lib64/* /lib/x86_64-linux-gnu - name: Integration tests - run: KRUN_ENOMEM_WORKAROUND=1 KRUN_NO_UNSHARE=1 KRUN_TEST_BASE_DIR=/tmp/libkrun-tests make test TEST_FLAGS="--keep-all --github-summary" + run: KRUN_ENOMEM_WORKAROUND=1 KRUN_TEST_BASE_DIR=/tmp/libkrun-tests make test NET=1 IPERF_DURATION=3 TEST_FLAGS="--keep-all --github-summary" - name: Upload test logs if: always() @@ -61,6 +68,7 @@ jobs: path: | /tmp/libkrun-tests/ !/tmp/libkrun-tests/**/guest-agent + !/tmp/libkrun-tests/**/rootfs if-no-files-found: ignore integration-tests-aarch64: @@ -76,7 +84,7 @@ jobs: run: rustup target add aarch64-unknown-linux-musl - name: Build and install libkrun to test prefix - run: make test-prefix + run: make test-prefix NET=1 - name: Clippy (test_cases guest) run: | @@ -99,7 +107,14 @@ jobs: cargo clippy --locked --target aarch64-unknown-linux-musl -p guest-agent -- -D warnings - name: Install additional packages - run: sudo apt-get install -y --no-install-recommends build-essential patchelf pkg-config net-tools + run: sudo apt-get install -y --no-install-recommends build-essential patchelf pkg-config net-tools dnsmasq iperf3 git uidmap + + - name: Install passt from source + run: | + curl -L https://passt.top/passt/snapshot/passt-2026_01_20.386b5f5.tar.gz | tar xz + cd passt-2026_01_20.386b5f5 + make + sudo make install - name: Install libkrunfw run: TAG=`curl -sL https://api.github.com/repos/containers/libkrunfw/releases/latest |jq -r .tag_name` && curl -L -o /tmp/libkrunfw-aarch64.tgz https://github.com/containers/libkrunfw/releases/download/$TAG/libkrunfw-aarch64.tgz && mkdir tmp && tar xf /tmp/libkrunfw-aarch64.tgz -C tmp && sudo mv tmp/lib64/* /lib/aarch64-linux-gnu @@ -108,7 +123,7 @@ jobs: run: rm -fr /tmp/libkrun-tests - name: Integration tests - run: KRUN_ENOMEM_WORKAROUND=1 KRUN_NO_UNSHARE=1 KRUN_TEST_BASE_DIR=/tmp/libkrun-tests make test TEST_FLAGS="--keep-all --github-summary" + run: KRUN_ENOMEM_WORKAROUND=1 KRUN_NO_UNSHARE=1 KRUN_TEST_BASE_DIR=/tmp/libkrun-tests make test NET=1 IPERF_DURATION=3 TEST_FLAGS="--keep-all --github-summary" - name: Upload test logs if: always() @@ -118,4 +133,5 @@ jobs: path: | /tmp/libkrun-tests/ !/tmp/libkrun-tests/**/guest-agent + !/tmp/libkrun-tests/**/rootfs if-no-files-found: ignore diff --git a/init/dhcp.c b/init/dhcp.c new file mode 100644 index 000000000..b0978cfd6 --- /dev/null +++ b/init/dhcp.c @@ -0,0 +1,587 @@ +/* + * DHCP Client Implementation + * + * Standalone DHCP client for configuring IPv4 network interfaces. + * Translated from Rust implementation in muvm/src/guest/net.rs + */ + +#include "dhcp.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define DHCP_BUFFER_SIZE 576 +#define DHCP_MSG_OFFER 2 +#define DHCP_MSG_ACK 5 + +/* Helper function to send netlink message */ +static int nl_send(int sock, struct nlmsghdr *nlh) +{ + struct sockaddr_nl sa = { + .nl_family = AF_NETLINK, + }; + + struct iovec iov = { + .iov_base = nlh, + .iov_len = nlh->nlmsg_len, + }; + + struct msghdr msg = { + .msg_name = &sa, + .msg_namelen = sizeof(sa), + .msg_iov = &iov, + .msg_iovlen = 1, + }; + + return sendmsg(sock, &msg, 0); +} + +/* Helper function to receive netlink response */ +static int nl_recv(int sock, char *buf, size_t len) +{ + struct sockaddr_nl sa; + struct iovec iov = { + .iov_base = buf, + .iov_len = len, + }; + + struct msghdr msg = { + .msg_name = &sa, + .msg_namelen = sizeof(sa), + .msg_iov = &iov, + .msg_iovlen = 1, + }; + + return recvmsg(sock, &msg, 0); +} + +/* Add routing attribute to netlink message */ +static void add_rtattr(struct nlmsghdr *nlh, int type, const void *data, + int len) +{ + int rtalen = RTA_SPACE(len); + struct rtattr *rta = + (struct rtattr *)(((char *)nlh) + NLMSG_ALIGN(nlh->nlmsg_len)); + rta->rta_type = type; + rta->rta_len = RTA_LENGTH(len); + memcpy(RTA_DATA(rta), data, len); + nlh->nlmsg_len = NLMSG_ALIGN(nlh->nlmsg_len) + rtalen; +} + +/* Set MTU */ +static int set_mtu(int nl_sock, int iface_index, unsigned int mtu) +{ + char buf[4096]; + struct nlmsghdr *nlh; + struct nlmsgerr *err; + struct ifinfomsg *ifi; + + memset(buf, 0, sizeof(buf)); + nlh = (struct nlmsghdr *)buf; + nlh->nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + nlh->nlmsg_type = RTM_NEWLINK; + nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + nlh->nlmsg_seq = 1; + nlh->nlmsg_pid = getpid(); + + ifi = (struct ifinfomsg *)NLMSG_DATA(nlh); + ifi->ifi_family = AF_UNSPEC; + ifi->ifi_type = ARPHRD_ETHER; + ifi->ifi_index = iface_index; + + add_rtattr(nlh, IFLA_MTU, &mtu, sizeof(mtu)); + + if (nl_send(nl_sock, nlh) < 0) { + perror("nl_send failed for set_mtu"); + return -1; + } + + /* Receive ACK */ + int len = nl_recv(nl_sock, buf, sizeof(buf)); + if (len < 0) { + perror("nl_recv failed for set_mtu"); + return -1; + } + + if (nlh->nlmsg_type != NLMSG_ERROR) { + printf("netlink didn't return a valid answer for set_mtu\n"); + return -1; + } + + err = (struct nlmsgerr *)NLMSG_DATA(nlh); + if (err->error != 0) { + printf("netlink returned an error for set_mtu: %d\n", err->error); + return -1; + } + + return 0; +} + +/* Add or delete IPv4 route */ +static int mod_route4(int nl_sock, int iface_index, int cmd, struct in_addr gw) +{ + char buf[4096]; + struct nlmsghdr *nlh; + struct nlmsgerr *err; + struct rtmsg *rtm; + struct in_addr dst = {.s_addr = INADDR_ANY}; + + memset(buf, 0, sizeof(buf)); + nlh = (struct nlmsghdr *)buf; + nlh->nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); + nlh->nlmsg_type = cmd; + nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_CREATE | NLM_F_ACK; + nlh->nlmsg_seq = 1; + nlh->nlmsg_pid = getpid(); + + rtm = (struct rtmsg *)NLMSG_DATA(nlh); + rtm->rtm_family = AF_INET; + rtm->rtm_dst_len = 0; + rtm->rtm_src_len = 0; + rtm->rtm_tos = 0; + rtm->rtm_table = RT_TABLE_MAIN; + rtm->rtm_protocol = RTPROT_BOOT; + rtm->rtm_scope = RT_SCOPE_UNIVERSE; + rtm->rtm_type = RTN_UNICAST; + rtm->rtm_flags = 0; + + add_rtattr(nlh, RTA_OIF, &iface_index, sizeof(iface_index)); + add_rtattr(nlh, RTA_DST, &dst, sizeof(dst)); + add_rtattr(nlh, RTA_GATEWAY, &gw, sizeof(gw)); + + if (nl_send(nl_sock, nlh) < 0) { + perror("nl_send failed for mod_route4"); + return -1; + } + + /* Receive ACK */ + int len = nl_recv(nl_sock, buf, sizeof(buf)); + if (len < 0) { + perror("nl_recv failed for mod_route4"); + return -1; + } + + if (nlh->nlmsg_type != NLMSG_ERROR) { + printf("netlink didn't return a valid answer for mod_route4\n"); + return -1; + } + + err = (struct nlmsgerr *)NLMSG_DATA(nlh); + if (err->error != 0) { + printf("netlink returned an error for mod_route4: %d\n", err->error); + return -1; + } + + return 0; +} + +/* Add or delete IPv4 address */ +static int mod_addr4(int nl_sock, int iface_index, int cmd, struct in_addr addr, + unsigned char prefix_len) +{ + char buf[4096]; + struct nlmsghdr *nlh; + struct nlmsgerr *err; + struct ifaddrmsg *ifa; + + memset(buf, 0, sizeof(buf)); + nlh = (struct nlmsghdr *)buf; + nlh->nlmsg_len = NLMSG_LENGTH(sizeof(struct ifaddrmsg)); + nlh->nlmsg_type = cmd; + nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_CREATE | NLM_F_ACK; + nlh->nlmsg_seq = 1; + nlh->nlmsg_pid = getpid(); + + ifa = (struct ifaddrmsg *)NLMSG_DATA(nlh); + ifa->ifa_family = AF_INET; + ifa->ifa_prefixlen = prefix_len; + ifa->ifa_flags = 0; + ifa->ifa_scope = RT_SCOPE_UNIVERSE; + ifa->ifa_index = iface_index; + + add_rtattr(nlh, IFA_LOCAL, &addr, sizeof(addr)); + add_rtattr(nlh, IFA_ADDRESS, &addr, sizeof(addr)); + + if (nl_send(nl_sock, nlh) < 0) { + perror("nl_send failed for mod_addr4"); + return -1; + } + + /* Receive ACK */ + int len = nl_recv(nl_sock, buf, sizeof(buf)); + if (len < 0) { + perror("nl_recv failed for mod_addr4"); + return -1; + } + + if (nlh->nlmsg_type != NLMSG_ERROR) { + printf("netlink didn't return a valid answer for mod_addr4\n"); + return -1; + } + + err = (struct nlmsgerr *)NLMSG_DATA(nlh); + if (err->error != 0) { + printf("netlink returned an error for mod_addr4: %d\n", err->error); + return -1; + } + + return 0; +} + +/* Count leading ones in a 32-bit value */ +static unsigned char count_leading_ones(uint32_t val) +{ + unsigned char count = 0; + for (int i = 31; i >= 0; i--) { + if (val & (1U << i)) { + count++; + } else { + break; + } + } + return count; +} + +/* Return the DHCP message type (option 53) from a response, or 0 */ +static unsigned char get_dhcp_msg_type(const unsigned char *response, + ssize_t len) +{ + /* Walk DHCP options (TLV chain starting after the magic cookie) */ + size_t p = 240; + while (p < (size_t)len) { + unsigned char opt = response[p]; + + if (opt == 0xff) /* end */ + break; + if (opt == 0) { /* padding */ + p++; + continue; + } + + unsigned char opt_len = response[p + 1]; + p += 2; + + if (p + opt_len > (size_t)len) + break; + if (opt == 53 && opt_len >= 1) /* Message Type */ + return response[p]; + + p += opt_len; + } + return 0; +} + +/* Parse a DHCP ACK and configure the interface. Returns 0 or -1 on error. */ +static int handle_dhcp_ack(int nl_sock, int iface_index, + const unsigned char *response, ssize_t len) +{ + /* Parse DHCP response */ + struct in_addr addr; + /* yiaddr is at offset 16-19 in network byte order */ + memcpy(&addr.s_addr, &response[16], sizeof(addr.s_addr)); + + struct in_addr netmask = {.s_addr = INADDR_ANY}; + struct in_addr router = {.s_addr = INADDR_ANY}; + /* Clamp MTU to passt's limit */ + uint16_t mtu = 65520; + + FILE *resolv = fopen("/etc/resolv.conf", "w"); + if (!resolv) { + perror("Failed to open /etc/resolv.conf"); + } + + /* Parse DHCP options (start at offset 240 after magic cookie) */ + size_t p = 240; + while (p < (size_t)len) { + unsigned char opt = response[p]; + + if (opt == 0xff) { + /* Option 255: End (of options) */ + break; + } + + if (opt == 0) { /* Padding */ + p++; + continue; + } + + unsigned char opt_len = response[p + 1]; + p += 2; /* Length doesn't include code and length field itself */ + + if (p + opt_len > (size_t)len) { + /* Malformed packet, option length exceeds packet boundary */ + break; + } + + if (opt == 1) { + /* Option 1: Subnet Mask */ + memcpy(&netmask.s_addr, &response[p], sizeof(netmask.s_addr)); + } else if (opt == 3) { + /* Option 3: Router */ + memcpy(&router.s_addr, &response[p], sizeof(router.s_addr)); + } else if (opt == 6) { + /* Option 6: Domain Name Server */ + if (resolv) { + for (int dns_p = p; dns_p + 3 < p + opt_len; dns_p += 4) { + fprintf(resolv, "nameserver %d.%d.%d.%d\n", response[dns_p], + response[dns_p + 1], response[dns_p + 2], + response[dns_p + 3]); + } + } + } else if (opt == 26) { + /* Option 26: Interface MTU */ + mtu = (response[p] << 8) | response[p + 1]; + + /* We don't know yet if IPv6 is available: don't go below 1280 B + */ + if (mtu < 1280) + mtu = 1280; + if (mtu > 65520) + mtu = 65520; + } + + p += opt_len; + } + + if (resolv) { + fclose(resolv); + } + + /* Calculate prefix length from netmask */ + unsigned char prefix_len = count_leading_ones(ntohl(netmask.s_addr)); + + if (mod_addr4(nl_sock, iface_index, RTM_NEWADDR, addr, prefix_len) != 0) { + printf("couldn't add the address provided by the DHCP server\n"); + return -1; + } + if (mod_route4(nl_sock, iface_index, RTM_NEWROUTE, router) != 0) { + printf("couldn't add the default route provided by the DHCP server\n"); + return -1; + } + set_mtu(nl_sock, iface_index, mtu); + return 0; +} + +/* Send DISCOVER with Rapid Commit, process ACK, configure address and route */ +int do_dhcp(const char *iface) +{ + struct sockaddr_in bind_addr, dest_addr; + struct dhcp_packet request = {0}; + unsigned char response[DHCP_BUFFER_SIZE]; + struct timeval timeout; + int iface_index; + int broadcast = 1; + int nl_sock = -1; + int sock = -1; + int ret = -1; + + iface_index = if_nametoindex(iface); + if (iface_index == 0) { + perror("Failed to find index for network interface"); + return ret; + } + + nl_sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE); + if (nl_sock < 0) { + perror("Failed to create netlink socket"); + return ret; + } + + struct sockaddr_nl sa = { + .nl_family = AF_NETLINK, + .nl_pid = getpid(), + .nl_groups = 0, + }; + + if (bind(nl_sock, (struct sockaddr *)&sa, sizeof(sa)) < 0) { + perror("Failed to bind netlink socket"); + goto cleanup; + } + + /* Send request (DHCPDISCOVER) */ + sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { + perror("socket failed"); + goto cleanup; + } + + /* Allow broadcast */ + if (setsockopt(sock, SOL_SOCKET, SO_BROADCAST, &broadcast, + sizeof(broadcast)) < 0) { + perror("setsockopt SO_BROADCAST failed"); + goto cleanup; + } + + if (setsockopt(sock, SOL_SOCKET, SO_BINDTODEVICE, iface, + strlen(iface) + 1) < 0) { + perror("setsockopt SO_BINDTODEVICE failed"); + goto cleanup; + } + + /* Bind to port 68 (DHCP client) */ + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin_family = AF_INET; + bind_addr.sin_port = htons(68); + bind_addr.sin_addr.s_addr = INADDR_ANY; + + if (bind(sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) { + perror("bind failed"); + goto cleanup; + } + + request.op = 1; /* BOOTREQUEST */ + request.htype = 1; /* Hardware address type: Ethernet */ + request.hlen = 6; /* Hardware address length */ + request.hops = 0; /* DHCP relay Hops */ + request.xid = + htonl(getpid()); /* Transaction ID: use PID for some randomness */ + request.secs = + 0; /* Seconds elapsed since beginning of acquisition or renewal */ + request.flags = htons(0x8000); /* DHCP message flags: Broadcast */ + request.ciaddr = 0; /* Client IP address (not set yet) */ + request.yiaddr = 0; /* 'your' IP address (server will fill) */ + request.siaddr = 0; /* Server IP address (not set) */ + request.giaddr = 0; /* Relay agent IP address (not set) */ + request.magic = htonl(0x63825363); /* Magic cookie */ + + /* chaddr, sname, and file are already zeroed by struct initialization */ + + /* Build DHCP options */ + int opt_offset = 0; + + /* Option 53: DHCP Message Type = DISCOVER (1) */ + request.options[opt_offset++] = 53; + request.options[opt_offset++] = 1; + request.options[opt_offset++] = 1; + + /* Option 80: Rapid Commit (RFC 4039) */ + request.options[opt_offset++] = 80; + request.options[opt_offset++] = 0; + + /* Option 255: End of options */ + request.options[opt_offset++] = 0xff; + + /* Remaining bytes are padding (up to 300 bytes) */ + + /* Send DHCP DISCOVER */ + memset(&dest_addr, 0, sizeof(dest_addr)); + dest_addr.sin_family = AF_INET; + dest_addr.sin_port = htons(67); + dest_addr.sin_addr.s_addr = INADDR_BROADCAST; + + if (sendto(sock, &request, sizeof(request), 0, + (struct sockaddr *)&dest_addr, sizeof(dest_addr)) < 0) { + perror("sendto failed"); + goto cleanup; + } + + /* Keep IPv6-only fast: set receive timeout to 100ms */ + timeout.tv_sec = 0; + timeout.tv_usec = 100000; + if (setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < + 0) { + perror("setsockopt SO_RCVTIMEO failed"); + goto cleanup; + } + + /* Get response: DHCPACK (Rapid Commit) or DHCPOFFER */ + struct sockaddr_in from_addr; + socklen_t from_len = sizeof(from_addr); + ssize_t len = recvfrom(sock, response, sizeof(response), 0, + (struct sockaddr *)&from_addr, &from_len); + + if (len <= 0) + goto done; /* No DHCP response — not an error, VM may be IPv6-only */ + + unsigned char msg_type = get_dhcp_msg_type(response, len); + + if (msg_type == DHCP_MSG_ACK) { + /* Rapid Commit — server sent ACK directly */ + close(sock); + sock = -1; + if (handle_dhcp_ack(nl_sock, iface_index, response, len) != 0) + goto cleanup; + } else if (msg_type == DHCP_MSG_OFFER) { + /* + * DHCPOFFER — complete the 4-way handshake by sending DHCPREQUEST + * and waiting for DHCPACK. Servers without Rapid Commit (e.g. + * gvproxy) require this. + */ + struct in_addr offered_addr; + memcpy(&offered_addr.s_addr, &response[16], + sizeof(offered_addr.s_addr)); + + /* Build DHCPREQUEST */ + memset(request.options, 0, sizeof(request.options)); + opt_offset = 0; + + /* Option 53: DHCP Message Type = REQUEST (3) */ + request.options[opt_offset++] = 53; + request.options[opt_offset++] = 1; + request.options[opt_offset++] = 3; + + /* Option 50: Requested IP Address */ + request.options[opt_offset++] = 50; + request.options[opt_offset++] = 4; + memcpy(&request.options[opt_offset], &offered_addr.s_addr, 4); + opt_offset += 4; + + /* Option 54: Server Identifier (from_addr) */ + request.options[opt_offset++] = 54; + request.options[opt_offset++] = 4; + memcpy(&request.options[opt_offset], &from_addr.sin_addr.s_addr, 4); + opt_offset += 4; + + /* Option 255: End */ + request.options[opt_offset++] = 0xff; + + if (sendto(sock, &request, sizeof(request), 0, + (struct sockaddr *)&dest_addr, sizeof(dest_addr)) < 0) { + perror("sendto DHCPREQUEST failed"); + goto cleanup; + } + + from_len = sizeof(from_addr); + len = recvfrom(sock, response, sizeof(response), 0, + (struct sockaddr *)&from_addr, &from_len); + + close(sock); + sock = -1; + + if (len <= 0) { + printf("no DHCPACK received\n"); + goto cleanup; + } + + if (handle_dhcp_ack(nl_sock, iface_index, response, len) != 0) + goto cleanup; + } else { + printf("unexpected DHCP message type %d\n", msg_type); + goto cleanup; + } + +done: + ret = 0; +cleanup: + if (sock >= 0) { + close(sock); + } + if (nl_sock >= 0) { + close(nl_sock); + } + return ret; +} diff --git a/init/dhcp.h b/init/dhcp.h new file mode 100644 index 000000000..2a4abfb1a --- /dev/null +++ b/init/dhcp.h @@ -0,0 +1,59 @@ +/* + * DHCP Client Implementation + * + * Standalone DHCP client for configuring IPv4 network interfaces. + * Translated from Rust implementation in muvm/src/guest/net.rs + */ + +#ifndef DHCP_H +#define DHCP_H + +#include + +/* BOOTP vendor-specific area size (64) - magic cookie (4) */ +#define DHCP_OPTIONS_SIZE 60 + +/* DHCP packet structure (RFC 2131) */ +struct dhcp_packet { + uint8_t op; /* Message op code / message type (1 = BOOTREQUEST) */ + uint8_t htype; /* Hardware address type (1 = Ethernet) */ + uint8_t hlen; /* Hardware address length (6 for Ethernet) */ + uint8_t hops; /* Client sets to zero */ + uint32_t xid; /* Transaction ID */ + uint16_t secs; /* Seconds elapsed since client began address acquisition */ + uint16_t flags; /* Flags (0x8000 = Broadcast) */ + uint32_t ciaddr; /* Client IP address */ + uint32_t yiaddr; /* 'your' (client) IP address */ + uint32_t siaddr; /* IP address of next server to use in bootstrap */ + uint32_t giaddr; /* Relay agent IP address */ + uint8_t chaddr[16]; /* Client hardware address */ + uint8_t sname[64]; /* Optional server host name */ + uint8_t file[128]; /* Boot file name */ + uint32_t magic; /* Magic cookie (0x63825363) */ + uint8_t options[DHCP_OPTIONS_SIZE]; /* Options field */ +} __attribute__((packed)); + +/* + * Perform DHCP discovery and configuration for a network interface + * + * This function: + * 1. Sets up a temporary link-local address (169.254.1.1/16) + * 2. Sends a DHCP DISCOVER message with Rapid Commit option + * 3. Waits up to 100ms for a DHCP ACK response + * 4. Parses the response and configures: + * - IPv4 address with appropriate prefix length + * - Default gateway route + * - DNS servers (overwriting /etc/resolv.conf) + * - Interface MTU + * 5. Cleans up temporary configuration + * + * Parameters: + * iface - The name of the network interface to be configured. + * + * Returns: + * 0 on success (whether or not DHCP response was received) + * -1 on error + */ +int do_dhcp(const char *iface); + +#endif /* DHCP_H */ diff --git a/init/init.c b/init/init.c index 5f753c958..897e3f293 100644 --- a/init/init.c +++ b/init/init.c @@ -24,6 +24,7 @@ #include +#include "dhcp.h" #include "jsmn.h" #ifdef SEV @@ -1168,6 +1169,20 @@ int main(int argc, char **argv) strncpy(ifr.ifr_name, "lo", IFNAMSIZ); ifr.ifr_flags |= IFF_UP; ioctl(sockfd, SIOCSIFFLAGS, &ifr); + + memset(&ifr, 0, sizeof ifr); + strncpy(ifr.ifr_name, "eth0", IFNAMSIZ); + if (ioctl(sockfd, SIOCGIFFLAGS, &ifr) == 0) { + /* eth0 exists, bring it up first */ + ifr.ifr_flags |= IFF_UP; + ioctl(sockfd, SIOCSIFFLAGS, &ifr); + + /* Configure eth0 with DHCP */ + if (do_dhcp("eth0") != 0) { + printf("Warning: DHCP configuration for eth0 failed\n"); + } + } + close(sockfd); } diff --git a/src/devices/build.rs b/src/devices/build.rs index 0d5cc0c97..49a4346d2 100644 --- a/src/devices/build.rs +++ b/src/devices/build.rs @@ -6,6 +6,7 @@ fn build_default_init() -> PathBuf { let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); let libkrun_root = manifest_dir.join("../.."); let init_src = libkrun_root.join("init/init.c"); + let dhcp_src = libkrun_root.join("init/dhcp.c"); let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); let init_bin = out_dir.join("init"); @@ -14,10 +15,15 @@ fn build_default_init() -> PathBuf { println!("cargo:rerun-if-env-changed=CC"); println!("cargo:rerun-if-env-changed=TIMESYNC"); println!("cargo:rerun-if-changed={}", init_src.display()); + println!("cargo:rerun-if-changed={}", dhcp_src.display()); println!( "cargo:rerun-if-changed={}", libkrun_root.join("init/jsmn.h").display() ); + println!( + "cargo:rerun-if-changed={}", + libkrun_root.join("init/dhcp.h").display() + ); let mut init_cc_flags = vec!["-O2", "-static", "-Wall"]; if std::env::var_os("TIMESYNC").as_deref() == Some(OsStr::new("1")) { @@ -35,6 +41,7 @@ fn build_default_init() -> PathBuf { .arg("-o") .arg(&init_bin) .arg(&init_src) + .arg(&dhcp_src) .status() .unwrap_or_else(|e| panic!("failed to execute {cc}: {e}")); diff --git a/tests/Cargo.lock b/tests/Cargo.lock index 157d21c5f..fd0a8a588 100644 --- a/tests/Cargo.lock +++ b/tests/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "anstream" -version = "0.6.18" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -28,50 +28,50 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ "windows-sys", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "once_cell", + "once_cell_polyfill", "windows-sys", ] [[package]] name = "anyhow" -version = "1.0.95" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bindgen" @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.8.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "cexpr" @@ -108,9 +108,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -130,9 +130,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.27" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "769b0145982b4b48713e01ec42d61614425f27b7058bda7180a3a41f30104796" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -140,9 +140,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.27" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -152,9 +152,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.24" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -164,21 +164,21 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "either" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "fuchsia-cprng" @@ -188,9 +188,9 @@ checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "guest-agent" @@ -208,19 +208,25 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "krun-sys" version = "1.11.1" @@ -231,9 +237,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.169" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "macros" @@ -245,9 +251,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -288,31 +294,31 @@ dependencies = [ ] [[package]] -name = "once_cell" -version = "1.20.2" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "proc-macro2" -version = "1.0.93" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.38" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -356,9 +362,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -368,9 +374,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -379,9 +385,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "remove_dir_all" @@ -410,6 +416,49 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "1.3.0" @@ -424,9 +473,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.98" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -451,14 +500,16 @@ dependencies = [ "krun-sys", "macros", "nix", + "serde", + "serde_json", "tempdir", ] [[package]] name = "unicode-ident" -version = "1.0.16" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "utf8parse" @@ -489,74 +540,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows-sys" -version = "0.59.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-targets" -version = "0.52.6" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows-link", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/run.sh b/tests/run.sh index cd4a58606..0ddc64d14 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -68,11 +68,4 @@ if [ -n "${KRUN_TEST_BASE_DIR}" ]; then RUNNER_ARGS="${RUNNER_ARGS} --base-dir ${KRUN_TEST_BASE_DIR}" fi -if [ "$OS" != "Darwin" ] && [ -z "${KRUN_NO_UNSHARE}" ] && which unshare 2>&1 >/dev/null; then - unshare --user --map-root-user --net -- /bin/sh -c "ifconfig lo 127.0.0.1 && exec target/debug/runner ${RUNNER_ARGS}" -else - echo "WARNING: Running tests without a network namespace." - echo "Tests may fail if the required network ports are already in use." - echo - target/debug/runner ${RUNNER_ARGS} -fi +target/debug/runner ${RUNNER_ARGS} diff --git a/tests/runner/Cargo.toml b/tests/runner/Cargo.toml index b74e9ad7c..8133341b8 100644 --- a/tests/runner/Cargo.toml +++ b/tests/runner/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] test_cases = { path = "../test_cases", features = ["host"] } anyhow = "1.0.95" -nix = { version = "0.29.0", features = ["resource", "fs"] } +nix = { version = "0.29.0", features = ["resource", "fs", "signal", "process"] } macros = { path = "../macros" } clap = { version = "4.5.27", features = ["derive"] } tempdir = "0.3.7" diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index ec816daa2..a44b021dc 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -8,14 +8,7 @@ use std::panic::catch_unwind; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use tempdir::TempDir; -use test_cases::{test_cases, ShouldRun, Test, TestCase, TestSetup}; - -#[derive(Clone)] -enum TestOutcome { - Pass, - Fail, - Skip(&'static str), -} +use test_cases::{test_cases, Report, ShouldRun, Test, TestCase, TestOutcome, TestSetup}; struct TestResult { name: String, @@ -45,6 +38,48 @@ fn start_vm(test_setup: TestSetup) -> anyhow::Result<()> { Ok(()) } +/// Kill background processes registered for cleanup via +/// [`TestSetup::register_cleanup_pid`]. Sends SIGTERM first, waits up to 5s +/// for graceful exit, then SIGKILL any survivors. +fn kill_cleanup_pids(test_dir: &Path) { + use nix::sys::signal::{kill, Signal}; + use nix::unistd::Pid; + use std::io::BufRead; + + let Ok(file) = File::open(test_dir.join("cleanup.pids")) else { + return; + }; + + let mut pids = Vec::new(); + for line in std::io::BufReader::new(file).lines() { + let Ok(line) = line else { continue }; + if let Ok(raw) = line.trim().parse::() { + pids.push(Pid::from_raw(raw)); + } + } + + for &pid in &pids { + let _ = kill(pid, Signal::SIGTERM); + } + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + pids.retain(|&pid| kill(pid, None).is_ok()); + if pids.is_empty() || std::time::Instant::now() >= deadline { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + for &pid in &pids { + eprintln!( + "WARNING: cleanup process {} did not exit after SIGTERM, sending SIGKILL", + pid + ); + let _ = kill(pid, Signal::SIGKILL); + } +} + fn run_single_test( test_case: &TestCase, base_dir: &Path, @@ -72,37 +107,140 @@ fn run_single_test( let test_dir = base_dir.join(test_case.name); fs::create_dir(&test_dir).context("Failed to create test directory")?; + // Prepare rootfs: build from container image if needed, otherwise create empty dir + let rootfs_dir = test_dir.join("rootfs"); + if let Some(containerfile) = test_case.rootfs_image() { + use test_cases::rootfs; + if let Err(e) = rootfs::prepare_rootfs(containerfile, &rootfs_dir) { + eprintln!("SKIP ({e})"); + return Ok(TestResult { + name: test_case.name.to_string(), + outcome: TestOutcome::Skip("rootfs image build failed"), + log_path: None, + }); + } + } else { + fs::create_dir(&rootfs_dir).context("Failed to create rootfs directory")?; + } + let log_path = test_dir.join("log.txt"); let log_file = File::create(&log_path).context("Failed to create log file")?; + let stdout_path = test_dir.join("stdout.txt"); + let stdout_file = File::create(&stdout_path).context("Failed to create stdout file")?; + + // Use `buildah unshare` for full subuid/subgid mapping + `unshare --net` + // for network namespace isolation. + // Fall back to running directly (with a warning) if buildah or unshare isn't available. + let has_cmd = |cmd: &str| { + Command::new(cmd) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + }; + let use_buildah_unshare = cfg!(target_os = "linux") + && std::env::var_os("KRUN_NO_UNSHARE").is_none() + && has_cmd("buildah") + && has_cmd("unshare"); + + let child = if use_buildah_unshare { + let exe = executable.display(); + let name = test_case.name; + let dir = test_dir.display(); + Command::new("buildah") + .args(["unshare", "--", "unshare", "--net", "--", "sh", "-c"]) + .arg(format!( + "echo '=== namespace debug ===' >&2; id >&2; cat /proc/self/uid_map >&2; cat /proc/self/gid_map >&2; ip link >&2; ifconfig lo 127.0.0.1; echo '=== ifconfig exit: '$?' ===' >&2; ip addr >&2; echo '=== end debug ===' >&2; exec {exe} start-vm --test-case {name} --tmp-dir {dir}" + )) + .stdin(Stdio::piped()) + .stdout(stdout_file) + .stderr(log_file) + .spawn() + .context("Failed to start subprocess for test")? + } else { + if cfg!(target_os = "linux") { + eprintln!("WARNING: buildah not available, running without namespace isolation."); + eprintln!("Tests may fail if the required network ports are already in use."); + } + Command::new(&executable) + .arg("start-vm") + .arg("--test-case") + .arg(test_case.name) + .arg("--tmp-dir") + .arg(&test_dir) + .stdin(Stdio::piped()) + .stdout(stdout_file) + .stderr(log_file) + .spawn() + .context("Failed to start subprocess for test")? + }; - let child = Command::new(&executable) - .arg("start-vm") - .arg("--test-case") - .arg(test_case.name) - .arg("--tmp-dir") - .arg(&test_dir) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(log_file) - .spawn() - .context("Failed to start subprocess for test")?; + // Enforce a per-test timeout. If the child doesn't exit within the + // deadline, kill it so we don't hang the entire suite. + let timeout = std::time::Duration::from_secs(test_case.timeout_secs()); + let mut child = child; + let deadline = std::time::Instant::now() + timeout; + loop { + if child.try_wait().unwrap_or(None).is_some() { + break; + } + if std::time::Instant::now() >= deadline { + eprintln!("TIMEOUT ({}s)", timeout.as_secs()); + let _ = child.kill(); + let _ = child.wait(); + kill_cleanup_pids(&test_dir); + return Ok(TestResult { + name: test_case.name.to_string(), + outcome: TestOutcome::Timeout, + log_path: Some(log_path), + }); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + let stdout = fs::read(&stdout_path).unwrap_or_default(); let test_name = test_case.name.to_string(); - let result = catch_unwind(|| { + let outcome = match catch_unwind(|| { let test = get_test(&test_name).unwrap(); - test.check(child); - }); + test.check(stdout) + }) { + Ok(outcome) => outcome, + Err(_) => TestOutcome::Fail("test.check() panicked".to_string()), + }; - let outcome = if result.is_ok() { - eprintln!("OK"); - if !keep_all { - let _ = fs::remove_dir_all(&test_dir); + // Kill any background processes registered for cleanup (e.g. gvproxy). + // Runs after check() regardless of outcome, so leaked processes are + // cleaned up even if the test crashed. + kill_cleanup_pids(&test_dir); + + match &outcome { + TestOutcome::Pass => { + eprintln!("OK"); + if !keep_all { + let _ = fs::remove_dir_all(&test_dir); + } } - TestOutcome::Pass - } else { - eprintln!("FAIL"); - TestOutcome::Fail - }; + TestOutcome::Fail(reason) => { + eprintln!("FAIL:"); + eprintln!("{reason}"); + } + TestOutcome::Skip(reason) => { + eprintln!("SKIP ({})", reason); + } + TestOutcome::Timeout => { + eprintln!("TIMEOUT"); + } + TestOutcome::Report(report) => { + eprintln!("REPORT"); + eprintln!("{:2}", report.text()); + if !keep_all { + let _ = fs::remove_dir_all(&test_dir); + } + } + } Ok(TestResult { name: test_case.name.to_string(), @@ -116,6 +254,7 @@ fn write_github_summary( num_pass: usize, num_fail: usize, num_skip: usize, + num_report: usize, ) -> anyhow::Result<()> { let summary_path = env::var("GITHUB_STEP_SUMMARY") .context("GITHUB_STEP_SUMMARY environment variable not set")?; @@ -128,22 +267,31 @@ fn write_github_summary( let num_ran = num_pass + num_fail; let status = if num_fail == 0 { "✅" } else { "❌" }; - let skip_msg = if num_skip > 0 { - format!(" ({num_skip} skipped)") - } else { + let mut extra = Vec::new(); + if num_skip > 0 { + extra.push(format!("{num_skip} skipped")); + } + if num_report > 0 { + extra.push(format!("{num_report} reports")); + } + let extra_msg = if extra.is_empty() { String::new() + } else { + format!(" ({})", extra.join(", ")) }; writeln!( file, - "## {status} Integration Tests - {num_pass}/{num_ran} passed{skip_msg}\n" + "## {status} Integration Tests - {num_pass}/{num_ran} passed{extra_msg}\n" )?; for result in results { let (icon, status_text) = match &result.outcome { TestOutcome::Pass => ("✅", String::new()), - TestOutcome::Fail => ("❌", String::new()), + TestOutcome::Fail(_) => ("❌", String::new()), TestOutcome::Skip(reason) => ("⏭️", format!(" - {}", reason)), + TestOutcome::Timeout => ("⏳", String::from(" - Timeout")), + TestOutcome::Report(_) => ("📊", String::new()), }; writeln!(file, "
")?; @@ -153,7 +301,13 @@ fn write_github_summary( result.name, status_text )?; - if let Some(log_path) = &result.log_path { + if let TestOutcome::Fail(reason) = &result.outcome { + writeln!(file, "**Error:**\n```\n{reason}\n```\n---\n")?; + } + + if let TestOutcome::Report(report) = &result.outcome { + writeln!(file, "{}", report.gh_markdown())?; + } else if let Some(log_path) = &result.log_path { let log_content = fs::read_to_string(log_path).unwrap_or_default(); writeln!(file, "```")?; // Limit log size to avoid huge summaries (2 MiB limit) @@ -221,34 +375,45 @@ fn run_tests( .count(); let num_fail = results .iter() - .filter(|r| matches!(r.outcome, TestOutcome::Fail)) + .filter(|r| matches!(r.outcome, TestOutcome::Fail(_))) .count(); let num_skip = results .iter() .filter(|r| matches!(r.outcome, TestOutcome::Skip(_))) .count(); + let num_report = results + .iter() + .filter(|r| matches!(r.outcome, TestOutcome::Report(_))) + .count(); let num_ran = num_pass + num_fail; // Write GitHub Actions summary if requested if github_summary { - write_github_summary(&results, num_pass, num_fail, num_skip)?; + write_github_summary(&results, num_pass, num_fail, num_skip, num_report)?; } - let skip_msg = if num_skip > 0 { - format!(" ({num_skip} skipped)") - } else { + let mut extra = Vec::new(); + if num_skip > 0 { + extra.push(format!("{num_skip} skipped")); + } + if num_report > 0 { + extra.push(format!("{num_report} reports")); + } + let extra_msg = if extra.is_empty() { String::new() + } else { + format!(" ({})", extra.join(", ")) }; if num_fail > 0 { eprintln!("(See test artifacts at: {})", base_dir.display()); - println!("\nFAIL - {num_pass}/{num_ran} passed{skip_msg}"); + println!("\nFAIL - {num_pass}/{num_ran} passed{extra_msg}"); anyhow::bail!("") } else { if keep_all { eprintln!("(See test artifacts at: {})", base_dir.display()); } - eprintln!("\nOK - {num_pass}/{num_ran} passed{skip_msg}"); + eprintln!("\nOK - {num_pass}/{num_ran} passed{extra_msg}"); } Ok(()) diff --git a/tests/test_cases/Cargo.toml b/tests/test_cases/Cargo.toml index 34d646797..96a06564e 100644 --- a/tests/test_cases/Cargo.toml +++ b/tests/test_cases/Cargo.toml @@ -3,7 +3,7 @@ name = "test_cases" edition = "2021" [features] -host = ["krun-sys"] +host = ["krun-sys", "serde", "serde_json"] guest = [] [lib] @@ -12,6 +12,8 @@ name = "test_cases" [dependencies] krun-sys = { path = "../../krun-sys", optional = true } macros = { path = "../macros" } -nix = { version = "0.29.0", features = ["socket"] } +nix = { version = "0.29.0", features = ["socket", "ioctl"] } anyhow = "1.0.95" +serde = { version = "1", features = ["derive"], optional = true } +serde_json = { version = "1", optional = true } tempdir = "0.3.7" \ No newline at end of file diff --git a/tests/test_cases/src/common.rs b/tests/test_cases/src/common.rs index 6a3ee2483..efc531f00 100644 --- a/tests/test_cases/src/common.rs +++ b/tests/test_cases/src/common.rs @@ -20,16 +20,16 @@ fn copy_guest_agent(dir: &Path) -> anyhow::Result<()> { Ok(()) } -/// Common part of most test. This setups an empty root filesystem, copies the guest agent there -/// and runs the guest agent in the VM. -/// Note that some tests might want to use a different root file system (perhaps a qcow image), -/// in which case the test can implement the equivalent functionality itself, or better if there -/// are more test doing that, add another utility method in this file. +/// Sets up the root filesystem, copies the guest agent into it, and enters the VM. /// -/// The returned object is used for deleting the temporary files. +/// The rootfs directory (`test_setup.tmp_dir/rootfs`) is expected to already exist, +/// either empty or pre-populated by the runner from a container image. pub fn setup_fs_and_enter(ctx: u32, test_setup: TestSetup) -> anyhow::Result<()> { - let root_dir = test_setup.tmp_dir.join("root"); - create_dir(&root_dir).context("Failed to create root directory")?; + let root_dir = test_setup.tmp_dir.join("rootfs"); + + if !root_dir.exists() { + create_dir(&root_dir).context("Failed to create rootfs directory")?; + } let path_str = CString::new(root_dir.as_os_str().as_bytes()).context("CString::new")?; copy_guest_agent(&root_dir)?; diff --git a/tests/test_cases/src/lib.rs b/tests/test_cases/src/lib.rs index f164ed87e..bda659e62 100644 --- a/tests/test_cases/src/lib.rs +++ b/tests/test_cases/src/lib.rs @@ -10,9 +10,23 @@ use test_tsi_tcp_guest_connect::TestTsiTcpGuestConnect; mod test_tsi_tcp_guest_listen; use test_tsi_tcp_guest_listen::TestTsiTcpGuestListen; +pub(crate) mod test_net; +use test_net::TestNet; + +mod test_net_perf; +use test_net_perf::TestNetPerf; + mod test_multiport_console; use test_multiport_console::TestMultiportConsole; +pub enum TestOutcome { + Pass, + Fail(String), + Timeout, + Skip(&'static str), + Report(Box), +} + pub enum ShouldRun { Yes, No(&'static str), @@ -55,18 +69,74 @@ pub fn test_cases() -> Vec { "tsi-tcp-guest-listen", Box::new(TestTsiTcpGuestListen::new()), ), + TestCase::new("net-passt", Box::new(TestNet::new_passt())), + TestCase::new("net-tap", Box::new(TestNet::new_tap())), + TestCase::new("net-gvproxy", Box::new(TestNet::new_gvproxy())), + TestCase::new("net-vmnet-helper", Box::new(TestNet::new_vmnet_helper())), TestCase::new("multiport-console", Box::new(TestMultiportConsole)), + TestCase::new("perf-net-passt-tx", Box::new(TestNetPerf::new_passt_tx())), + TestCase::new("perf-net-passt-rx", Box::new(TestNetPerf::new_passt_rx())), + TestCase::new("perf-net-tap-tx", Box::new(TestNetPerf::new_tap_tx())), + TestCase::new("perf-net-tap-rx", Box::new(TestNetPerf::new_tap_rx())), + TestCase::new( + "perf-net-gvproxy-tx", + Box::new(TestNetPerf::new_gvproxy_tx()), + ), + TestCase::new( + "perf-net-gvproxy-rx", + Box::new(TestNetPerf::new_gvproxy_rx()), + ), + TestCase::new( + "perf-net-vmnet-helper-tx", + Box::new(TestNetPerf::new_vmnet_helper_tx()), + ), + TestCase::new( + "perf-net-vmnet-helper-rx", + Box::new(TestNetPerf::new_vmnet_helper_rx()), + ), ] } //////////////////// // Implementation details: ////////////////// + +pub trait ReportImpl { + fn fmt_text(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result; + fn fmt_gh_markdown(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result; +} + +pub trait Report: ReportImpl { + fn text(&self) -> ReportText<'_, Self> { + ReportText(self) + } + + fn gh_markdown(&self) -> ReportGhMarkdown<'_, Self> { + ReportGhMarkdown(self) + } +} + +impl Report for T {} + +pub struct ReportText<'a, T: ReportImpl + ?Sized>(pub &'a T); + +impl std::fmt::Display for ReportText<'_, T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt_text(f) + } +} + +pub struct ReportGhMarkdown<'a, T: ReportImpl + ?Sized>(pub &'a T); + +impl std::fmt::Display for ReportGhMarkdown<'_, T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt_gh_markdown(f) + } +} + use macros::{guest, host}; #[host] use std::path::PathBuf; -#[host] -use std::process::Child; #[cfg(all(feature = "guest", feature = "host"))] compile_error!("Cannot enable both guest and host in the same binary!"); @@ -76,6 +146,9 @@ mod common; #[cfg(feature = "host")] mod krun; + +#[cfg(feature = "host")] +pub mod rootfs; mod tcp_tester; #[host] @@ -86,21 +159,56 @@ pub struct TestSetup { pub tmp_dir: PathBuf, } +#[host] +impl TestSetup { + /// Register a PID to be killed after the test finishes. + /// + /// The runner will SIGKILL these PIDs after check() returns, even if the + /// test crashed. Use this for background processes (e.g. gvproxy) that + /// outlive the VM. + pub fn register_cleanup_pid(&self, pid: u32) { + use std::io::Write; + let path = self.tmp_dir.join("cleanup.pids"); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("Failed to open cleanup.pids"); + writeln!(file, "{}", pid).expect("Failed to write cleanup PID"); + } +} + #[host] pub trait Test { /// Start the VM fn start_vm(self: Box, test_setup: TestSetup) -> anyhow::Result<()>; /// Checks the output of the (host) process which started the VM - fn check(self: Box, child: Child) { - let output = child.wait_with_output().unwrap(); - assert_eq!(String::from_utf8(output.stdout).unwrap(), "OK\n"); + fn check(self: Box, stdout: Vec) -> TestOutcome { + let output = String::from_utf8(stdout).unwrap(); + if output == "OK\n" { + TestOutcome::Pass + } else { + TestOutcome::Fail(format!("expected exactly {:?}, got {:?}", "OK\n", output)) + } } /// Check if this test should run on this platform. fn should_run(&self) -> ShouldRun { ShouldRun::Yes } + + /// Return Containerfile content if this test needs a custom rootfs image. + /// The runner will build the image via podman and extract it before launching the VM. + /// If podman is unavailable, the test is skipped. + fn rootfs_image(&self) -> Option<&'static str> { + None + } + + /// Per-test timeout in seconds. The runner kills the test if it exceeds this. + fn timeout_secs(&self) -> u64 { + 15 + } } #[guest] @@ -127,6 +235,16 @@ impl TestCase { self.test.should_run() } + #[host] + pub fn rootfs_image(&self) -> Option<&'static str> { + self.test.rootfs_image() + } + + #[host] + pub fn timeout_secs(&self) -> u64 { + self.test.timeout_secs() + } + #[allow(dead_code)] pub fn name(&self) -> &'static str { self.name diff --git a/tests/test_cases/src/rootfs.rs b/tests/test_cases/src/rootfs.rs new file mode 100644 index 000000000..7a9f5f86e --- /dev/null +++ b/tests/test_cases/src/rootfs.rs @@ -0,0 +1,129 @@ +//! Podman-based rootfs provisioning for tests that need a full Linux rootfs. +//! +//! `prepare_rootfs` builds a podman image from a Containerfile string, creates a container, +//! and pipes `podman export` directly into `tar -x` to populate the destination directory. +//! The image tag is derived from the hash of the Containerfile content (`krun-test-`), +//! so podman's layer cache makes rebuilds fast when the Containerfile hasn't changed. + +use anyhow::{bail, Context}; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +fn containerfile_tag(containerfile: &str) -> anyhow::Result { + let output = Command::new("sha256sum") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .and_then(|mut child| { + child + .stdin + .take() + .unwrap() + .write_all(containerfile.as_bytes())?; + child.wait_with_output() + }) + .context("sha256sum")?; + + let hash = String::from_utf8(output.stdout).context("sha256sum output not utf-8")?; + let short = hash.get(..16).context("sha256sum output too short")?; + Ok(format!("krun-test-{short}")) +} + +fn podman_available() -> bool { + Command::new("podman") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Builds a podman image from `containerfile`, creates a container, and extracts +/// its filesystem directly into `dest` (no intermediate tarball). +/// +/// Returns an error if podman is unavailable or the build/export fails. +pub fn prepare_rootfs(containerfile: &str, dest: &Path) -> anyhow::Result<()> { + if !podman_available() { + bail!("podman not available"); + } + + let tag = containerfile_tag(containerfile)?; + + // Build image (podman layer cache makes this fast when unchanged) + let mut build = Command::new("podman") + .args(["build", "-t", &tag, "-f", "-", "."]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .context("spawning podman build")?; + + build + .stdin + .take() + .unwrap() + .write_all(containerfile.as_bytes()) + .context("writing containerfile to podman stdin")?; + + let output = build + .wait_with_output() + .context("waiting for podman build")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("podman build failed: {stderr}"); + } + + // Create a container from the image + let create_out = Command::new("podman") + .args(["create", &tag]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .context("podman create")?; + + if !create_out.status.success() { + let stderr = String::from_utf8_lossy(&create_out.stderr); + bail!("podman create failed: {stderr}"); + } + + let ctr_id = String::from_utf8(create_out.stdout) + .context("container id not utf-8")? + .trim() + .to_string(); + + // Pipe podman export directly into tar extract + fs::create_dir_all(dest).context("creating rootfs destination directory")?; + + let mut export = Command::new("podman") + .args(["export", &ctr_id]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("podman export")?; + + let export_stdout = export.stdout.take().unwrap(); + + let tar_status = Command::new("tar") + .args(["-x", "--no-same-owner", "-C"]) + .arg(dest) + .stdin(export_stdout) + .status() + .context("tar extract from podman export")?; + + let export_out = export + .wait_with_output() + .context("waiting for podman export")?; + + if !export_out.status.success() { + bail!("podman export failed"); + } + if !tar_status.success() { + bail!("tar extraction failed"); + } + + Ok(()) +} diff --git a/tests/test_cases/src/tcp_tester.rs b/tests/test_cases/src/tcp_tester.rs index 8a4268730..941badbe0 100644 --- a/tests/test_cases/src/tcp_tester.rs +++ b/tests/test_cases/src/tcp_tester.rs @@ -26,8 +26,8 @@ fn set_timeouts(stream: &mut TcpStream) { .unwrap(); } -fn connect(port: u16) -> TcpStream { - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), port); +fn connect(server_ip: Ipv4Addr, port: u16) -> TcpStream { + let addr = SocketAddr::new(IpAddr::V4(server_ip), port); let mut tries = 0; loop { match TcpStream::connect(addr) { @@ -45,12 +45,13 @@ fn connect(port: u16) -> TcpStream { #[derive(Debug, Copy, Clone)] pub struct TcpTester { + server_ip: Ipv4Addr, port: u16, } impl TcpTester { - pub const fn new(port: u16) -> Self { - Self { port } + pub const fn new(server_ip: Ipv4Addr, port: u16) -> Self { + Self { server_ip, port } } pub fn create_server_socket(&self) -> TcpListener { @@ -70,7 +71,7 @@ impl TcpTester { } pub fn run_client(&self) { - let mut stream = connect(self.port); + let mut stream = connect(self.server_ip, self.port); set_timeouts(&mut stream); expect_msg(&mut stream, b"ping!"); expect_wouldblock(&mut stream); diff --git a/tests/test_cases/src/test_net/gvproxy.rs b/tests/test_cases/src/test_net/gvproxy.rs new file mode 100644 index 000000000..96da5aa00 --- /dev/null +++ b/tests/test_cases/src/test_net/gvproxy.rs @@ -0,0 +1,109 @@ +//! Gvproxy backend for virtio-net test (macOS only) + +use crate::{krun_call, ShouldRun, TestSetup}; +use krun_sys::{COMPAT_NET_FEATURES, NET_FLAG_VFKIT}; +use nix::libc; +use std::ffi::CString; + +type KrunAddNetUnixgramFn = unsafe extern "C" fn( + ctx_id: u32, + c_path: *const std::ffi::c_char, + fd: i32, + c_mac: *mut u8, + features: u32, + flags: u32, +) -> i32; + +fn get_krun_add_net_unixgram() -> KrunAddNetUnixgramFn { + let symbol = CString::new("krun_add_net_unixgram").unwrap(); + let ptr = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + assert!(!ptr.is_null(), "krun_add_net_unixgram not found"); + unsafe { std::mem::transmute(ptr) } +} + +const GVPROXY_PATH: &str = match option_env!("GVPROXY_PATH") { + Some(path) => path, + None => "/opt/homebrew/opt/podman/libexec/podman/gvproxy", +}; + +fn gvproxy_path() -> Option<&'static str> { + std::path::Path::new(GVPROXY_PATH) + .exists() + .then_some(GVPROXY_PATH) +} + +fn start_gvproxy( + socket_path: &str, + log_path: &std::path::Path, +) -> std::io::Result { + use std::process::{Command, Stdio}; + + let gvproxy = gvproxy_path().expect("gvproxy not found"); + + let log_file = std::fs::File::create(log_path)?; + + Command::new(gvproxy) + .arg("--listen-vfkit") + .arg(format!("unixgram:{}", socket_path)) + .arg("-debug") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log_file) + .spawn() +} + +fn wait_for_socket(path: &std::path::Path, timeout_ms: u64) -> bool { + let start = std::time::Instant::now(); + while start.elapsed().as_millis() < timeout_ms as u128 { + if path.exists() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + false +} + +pub(crate) fn should_run() -> ShouldRun { + #[cfg(not(target_os = "macos"))] + return ShouldRun::No("gvproxy unixgram only supported on macOS"); + + #[cfg(target_os = "macos")] + { + if gvproxy_path().is_none() { + return ShouldRun::No("gvproxy not installed"); + } + ShouldRun::Yes + } +} + +pub(crate) fn setup_backend(ctx: u32, test_setup: &TestSetup) -> anyhow::Result<()> { + let tmp_dir = test_setup + .tmp_dir + .canonicalize() + .unwrap_or_else(|_| test_setup.tmp_dir.clone()); + let socket_path = tmp_dir.join("gvproxy.sock"); + let gvproxy_log = tmp_dir.join("gvproxy.log"); + + let gvproxy_child = start_gvproxy(socket_path.to_str().unwrap(), &gvproxy_log)?; + test_setup.register_cleanup_pid(gvproxy_child.id()); + + anyhow::ensure!( + wait_for_socket(&socket_path, 5000), + "gvproxy failed to create socket" + ); + + let mut mac: [u8; 6] = [0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xee]; + let c_socket_path = CString::new(socket_path.to_str().unwrap()).unwrap(); + + unsafe { + krun_call!(get_krun_add_net_unixgram()( + ctx, + c_socket_path.as_ptr(), + -1, + mac.as_mut_ptr(), + COMPAT_NET_FEATURES, + NET_FLAG_VFKIT, + ))?; + } + Ok(()) +} diff --git a/tests/test_cases/src/test_net/mod.rs b/tests/test_cases/src/test_net/mod.rs new file mode 100644 index 000000000..9f792635b --- /dev/null +++ b/tests/test_cases/src/test_net/mod.rs @@ -0,0 +1,145 @@ +//! Unified virtio-net integration tests +//! +//! All tests follow the same pattern: +//! 1. Host: Start backend + TCP server +//! 2. Guest: Connect to host TCP server (eth0 configured via DHCP by init) + +use crate::tcp_tester::TcpTester; +use macros::{guest, host}; + +#[host] +use crate::{ShouldRun, TestSetup}; + +#[cfg(feature = "host")] +pub(crate) mod gvproxy; +#[cfg(feature = "host")] +pub(crate) mod passt; +#[cfg(feature = "host")] +pub(crate) mod tap; +#[cfg(feature = "host")] +pub(crate) mod vmnet_helper; + +/// Virtio-net test with configurable backend +pub struct TestNet { + tcp_tester: TcpTester, + #[cfg(feature = "host")] + should_run: fn() -> ShouldRun, + #[cfg(feature = "host")] + setup_backend: fn(u32, &TestSetup) -> anyhow::Result<()>, + #[cfg(feature = "host")] + cleanup: Option, +} + +impl TestNet { + pub fn new_passt() -> Self { + Self { + tcp_tester: TcpTester::new([169, 254, 2, 2].into(), 9000), + #[cfg(feature = "host")] + should_run: passt::should_run, + #[cfg(feature = "host")] + setup_backend: passt::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_tap() -> Self { + Self { + tcp_tester: TcpTester::new([10, 0, 0, 1].into(), 9001), + #[cfg(feature = "host")] + should_run: tap::should_run, + #[cfg(feature = "host")] + setup_backend: tap::setup_backend, + #[cfg(feature = "host")] + cleanup: Some(tap::cleanup), + } + } + + pub fn new_gvproxy() -> Self { + Self { + tcp_tester: TcpTester::new([192, 168, 127, 254].into(), 9002), + #[cfg(feature = "host")] + should_run: gvproxy::should_run, + #[cfg(feature = "host")] + setup_backend: gvproxy::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_vmnet_helper() -> Self { + Self { + tcp_tester: TcpTester::new([192, 168, 105, 1].into(), 9003), + #[cfg(feature = "host")] + should_run: vmnet_helper::should_run, + #[cfg(feature = "host")] + setup_backend: vmnet_helper::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } +} + +#[host] +mod host { + use super::*; + use crate::common::setup_fs_and_enter; + use crate::{krun_call, krun_call_u32, Test, TestOutcome, TestSetup}; + use krun_sys::*; + use std::thread; + + impl Test for TestNet { + fn should_run(&self) -> ShouldRun { + if unsafe { krun_call_u32!(krun_has_feature(KRUN_FEATURE_NET.into())) }.ok() != Some(1) + { + return ShouldRun::No("libkrun compiled without NET"); + } + (self.should_run)() + } + + fn check(self: Box, stdout: Vec) -> TestOutcome { + if let Some(cleanup) = self.cleanup { + cleanup(); + } + let output = String::from_utf8(stdout).unwrap(); + if output == "OK\n" { + TestOutcome::Pass + } else { + TestOutcome::Fail(format!("expected exactly {:?}, got {:?}", "OK\n", output)) + } + } + + fn start_vm(self: Box, test_setup: TestSetup) -> anyhow::Result<()> { + // Start TCP server + let tcp_tester = self.tcp_tester; + let listener = tcp_tester.create_server_socket(); + thread::spawn(move || tcp_tester.run_server(listener)); + + unsafe { + krun_call!(krun_set_log_level(KRUN_LOG_LEVEL_TRACE))?; + let ctx = krun_call_u32!(krun_create_ctx())?; + krun_call!(krun_set_vm_config(ctx, 1, 512))?; + + // Backend-specific setup + (self.setup_backend)(ctx, &test_setup)?; + + setup_fs_and_enter(ctx, test_setup)?; + } + Ok(()) + } + } +} + +#[guest] +mod guest { + use super::*; + use crate::Test; + + impl Test for TestNet { + fn in_guest(self: Box) { + self.tcp_tester.run_client(); + + println!("OK"); + } + } +} diff --git a/tests/test_cases/src/test_net/passt.rs b/tests/test_cases/src/test_net/passt.rs new file mode 100644 index 000000000..f769fb77f --- /dev/null +++ b/tests/test_cases/src/test_net/passt.rs @@ -0,0 +1,94 @@ +//! Passt backend for virtio-net test + +use crate::{krun_call, ShouldRun, TestSetup}; +use krun_sys::COMPAT_NET_FEATURES; +use nix::libc; +use std::ffi::CString; +use std::os::unix::io::RawFd; + +type KrunAddNetUnixstreamFn = unsafe extern "C" fn( + ctx_id: u32, + c_path: *const std::ffi::c_char, + fd: std::ffi::c_int, + c_mac: *mut u8, + features: u32, + flags: u32, +) -> i32; + +fn get_krun_add_net_unixstream() -> KrunAddNetUnixstreamFn { + let symbol = CString::new("krun_add_net_unixstream").unwrap(); + let ptr = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + assert!(!ptr.is_null(), "krun_add_net_unixstream not found"); + unsafe { std::mem::transmute(ptr) } +} + +fn passt_available() -> bool { + std::process::Command::new("which") + .arg("passt") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn start_passt() -> std::io::Result { + let mut fds = [0 as libc::c_int; 2]; + if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) } < 0 { + return Err(std::io::Error::last_os_error()); + } + let (parent_fd, child_fd) = (fds[0], fds[1]); + let child_fd_str = child_fd.to_string(); + //FIXME? use low level stuff isntead! + let pid = unsafe { libc::fork() }; + if pid < 0 { + return Err(std::io::Error::last_os_error()); + } + + if pid == 0 { + unsafe { libc::close(parent_fd) }; + let passt = CString::new("passt").unwrap(); + let arg_f = CString::new("-f").unwrap(); + let arg_fd = CString::new("--fd").unwrap(); + let arg_fd_val = CString::new(child_fd_str).unwrap(); + unsafe { + libc::execlp( + passt.as_ptr(), + passt.as_ptr(), + arg_f.as_ptr(), + arg_fd.as_ptr(), + arg_fd_val.as_ptr(), + std::ptr::null::(), + ); + } + std::process::exit(1); + } + + unsafe { libc::close(child_fd) }; + Ok(parent_fd) +} + +pub(crate) fn should_run() -> ShouldRun { + if cfg!(target_os = "macos") { + return ShouldRun::No("passt not supported on macOS"); + } + if !passt_available() { + return ShouldRun::No("passt not installed"); + } + ShouldRun::Yes +} + +pub(crate) fn setup_backend(ctx: u32, _test_setup: &TestSetup) -> anyhow::Result<()> { + let passt_fd = start_passt()?; + let mut mac: [u8; 6] = [0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xee]; + + unsafe { + krun_call!(get_krun_add_net_unixstream()( + ctx, + std::ptr::null(), + passt_fd, + mac.as_mut_ptr(), + COMPAT_NET_FEATURES, + 0, + ))?; + } + Ok(()) +} diff --git a/tests/test_cases/src/test_net/tap.rs b/tests/test_cases/src/test_net/tap.rs new file mode 100644 index 000000000..7f36925ed --- /dev/null +++ b/tests/test_cases/src/test_net/tap.rs @@ -0,0 +1,217 @@ +//! TAP backend for virtio-net test + +use crate::{krun_call, ShouldRun, TestSetup}; +use krun_sys::COMPAT_NET_FEATURES; +use nix::libc; +use nix::sys::socket::{socket, AddressFamily, SockFlag, SockType}; +use std::ffi::CString; +use std::fs::OpenOptions; +use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket}; +use std::os::fd::AsRawFd; +use std::process::{Command, Stdio}; + +const DEFAULT_TAP_NAME: &str = "tap0"; +const HOST_IP: [u8; 4] = [10, 0, 0, 1]; +const NETMASK: [u8; 4] = [255, 255, 255, 0]; + +type KrunAddNetTapFn = unsafe extern "C" fn( + ctx_id: u32, + c_tap_name: *const std::ffi::c_char, + c_mac: *mut u8, + features: u32, + flags: u32, +) -> i32; + +fn get_krun_add_net_tap() -> KrunAddNetTapFn { + let symbol = CString::new("krun_add_net_tap").unwrap(); + let ptr = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + assert!(!ptr.is_null(), "krun_add_net_tap not found"); + unsafe { std::mem::transmute(ptr) } +} + +fn interface_exists(name: &str) -> bool { + std::path::Path::new(&format!("/sys/class/net/{}", name)).exists() +} + +// TAP device setup +const TUNSETIFF: libc::c_ulong = 0x400454ca; +const TUNSETPERSIST: libc::c_ulong = 0x400454cb; +const IFF_TAP: libc::c_short = 0x0002; +const IFF_NO_PI: libc::c_short = 0x1000; +const IFF_VNET_HDR: libc::c_short = 0x4000; +const IFNAMSIZ: usize = 16; +const IFF_UP: libc::c_short = 0x1; +const IFF_RUNNING: libc::c_short = 0x40; + +#[repr(C)] +struct Ifreq { + ifr_name: [u8; IFNAMSIZ], + ifr_ifru: IfreqIfru, +} + +#[repr(C)] +#[derive(Copy, Clone)] +union IfreqIfru { + ifru_flags: libc::c_short, + ifru_addr: libc::sockaddr, + _pad: [u8; 24], +} + +nix::ioctl_write_ptr_bad!(ioctl_tunsetiff, TUNSETIFF, Ifreq); +nix::ioctl_write_int_bad!(ioctl_tunsetpersist, TUNSETPERSIST); +nix::ioctl_readwrite_bad!(ioctl_siocsifaddr, 0x8916, Ifreq); +nix::ioctl_readwrite_bad!(ioctl_siocsifnetmask, 0x891c, Ifreq); +nix::ioctl_readwrite_bad!(ioctl_siocgifflags, 0x8913, Ifreq); +nix::ioctl_readwrite_bad!(ioctl_siocsifflags, 0x8914, Ifreq); + +fn set_interface_name(ifr: &mut Ifreq, name: &str) { + let bytes = name.as_bytes(); + let len = bytes.len().min(IFNAMSIZ - 1); + ifr.ifr_name = [0u8; IFNAMSIZ]; + ifr.ifr_name[..len].copy_from_slice(&bytes[..len]); +} + +fn make_sockaddr_in(ip: [u8; 4]) -> libc::sockaddr { + let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() }; + addr.sin_family = libc::AF_INET as libc::sa_family_t; + addr.sin_addr.s_addr = u32::from_ne_bytes(ip); + unsafe { std::mem::transmute(addr) } +} + +fn create_tap(name: &str) -> std::io::Result<()> { + let tun = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/net/tun")?; + let mut ifr: Ifreq = unsafe { std::mem::zeroed() }; + set_interface_name(&mut ifr, name); + ifr.ifr_ifru.ifru_flags = IFF_TAP | IFF_NO_PI | IFF_VNET_HDR; + unsafe { ioctl_tunsetiff(tun.as_raw_fd(), &ifr) }.map_err(std::io::Error::other)?; + unsafe { ioctl_tunsetpersist(tun.as_raw_fd(), 1) }.map_err(std::io::Error::other)?; + Ok(()) +} + +fn configure_host_interface(name: &str, ip: [u8; 4], netmask: [u8; 4]) -> nix::Result<()> { + let sock = socket( + AddressFamily::Inet, + SockType::Datagram, + SockFlag::empty(), + None, + )?; + let fd = sock.as_raw_fd(); + + let mut ifr: Ifreq = unsafe { std::mem::zeroed() }; + set_interface_name(&mut ifr, name); + ifr.ifr_ifru.ifru_addr = make_sockaddr_in(ip); + unsafe { ioctl_siocsifaddr(fd, &mut ifr)? }; + + let mut ifr: Ifreq = unsafe { std::mem::zeroed() }; + set_interface_name(&mut ifr, name); + ifr.ifr_ifru.ifru_addr = make_sockaddr_in(netmask); + unsafe { ioctl_siocsifnetmask(fd, &mut ifr)? }; + + let mut ifr: Ifreq = unsafe { std::mem::zeroed() }; + set_interface_name(&mut ifr, name); + unsafe { ioctl_siocgifflags(fd, &mut ifr)? }; + unsafe { ifr.ifr_ifru.ifru_flags |= IFF_UP | IFF_RUNNING }; + unsafe { ioctl_siocsifflags(fd, &mut ifr)? }; + + Ok(()) +} + +fn dnsmasq_available() -> bool { + Command::new("which") + .arg("dnsmasq") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +pub(crate) fn should_run() -> ShouldRun { + if cfg!(target_os = "macos") { + return ShouldRun::No("TAP not supported on macOS"); + } + if let Ok(tap_name) = std::env::var("LIBKRUN_TAP_NAME") { + if !interface_exists(&tap_name) { + return ShouldRun::No("TAP interface not found"); + } + } else if !std::path::Path::new("/dev/net/tun").exists() { + return ShouldRun::No("/dev/net/tun not available"); + } + if !dnsmasq_available() { + return ShouldRun::No("dnsmasq not installed"); + } + ShouldRun::Yes +} + +pub(crate) fn cleanup() { + if let Ok(tun) = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/net/tun") + { + let mut ifr: Ifreq = unsafe { std::mem::zeroed() }; + set_interface_name(&mut ifr, DEFAULT_TAP_NAME); + ifr.ifr_ifru.ifru_flags = IFF_TAP | IFF_NO_PI; + if unsafe { ioctl_tunsetiff(tun.as_raw_fd(), &ifr) }.is_ok() { + let _ = unsafe { ioctl_tunsetpersist(tun.as_raw_fd(), 0) }; + } + } +} + +fn start_dhcp_server(tap_name: &str, test_setup: &TestSetup) -> anyhow::Result<()> { + let lease_file = test_setup.tmp_dir.join("dnsmasq.leases"); + let child = Command::new("dnsmasq") + .arg("--no-daemon") + .arg(format!("--interface={tap_name}")) + .arg("--dhcp-range=10.0.0.2,10.0.0.10,255.255.255.0") + .arg("--dhcp-option=3,10.0.0.1") // gateway + .arg("--dhcp-rapid-commit") // init's DHCP client uses Rapid Commit + .arg("--no-ping") // skip ARP probe delay before assigning + .arg(format!("--dhcp-leasefile={}", lease_file.display())) + .arg("--bind-dynamic") + .arg("--except-interface=lo") + .arg("--port=0") // disable DNS, we only need DHCP + .arg("--no-resolv") + .arg("--no-hosts") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| anyhow::anyhow!("Failed to start dnsmasq: {e}"))?; + test_setup.register_cleanup_pid(child.id()); + + // Wait for dnsmasq to bind port 67 before proceeding + for _ in 0..50 { + if UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 67)).is_err() { + return Ok(()); // port 67 is taken — dnsmasq is ready + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + anyhow::bail!("dnsmasq did not start in time"); +} + +pub(crate) fn setup_backend(ctx: u32, test_setup: &TestSetup) -> anyhow::Result<()> { + let tap_name = if let Ok(name) = std::env::var("LIBKRUN_TAP_NAME") { + name + } else { + create_tap(DEFAULT_TAP_NAME)?; + configure_host_interface(DEFAULT_TAP_NAME, HOST_IP, NETMASK) + .map_err(|e| anyhow::anyhow!("Failed to configure TAP: {}", e))?; + start_dhcp_server(DEFAULT_TAP_NAME, test_setup)?; + DEFAULT_TAP_NAME.to_string() + }; + + let mut mac: [u8; 6] = [0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xee]; + let tap_name_c = CString::new(tap_name).unwrap(); + + unsafe { + krun_call!(get_krun_add_net_tap()( + ctx, + tap_name_c.as_ptr(), + mac.as_mut_ptr(), + COMPAT_NET_FEATURES, + 0, + ))?; + } + Ok(()) +} diff --git a/tests/test_cases/src/test_net/vmnet_helper.rs b/tests/test_cases/src/test_net/vmnet_helper.rs new file mode 100644 index 000000000..05069603a --- /dev/null +++ b/tests/test_cases/src/test_net/vmnet_helper.rs @@ -0,0 +1,186 @@ +//! vmnet-helper backend for virtio-net test (macOS only) + +use crate::{krun_call, ShouldRun, TestSetup}; +use krun_sys::{ + NET_FEATURE_CSUM, NET_FEATURE_GUEST_CSUM, NET_FEATURE_GUEST_TSO4, NET_FEATURE_HOST_TSO4, +}; +use nix::libc; +use std::ffi::CString; +use std::io::{BufRead, BufReader, Read}; +use std::process::{Command, Stdio}; + +type KrunAddNetUnixgramFn = unsafe extern "C" fn( + ctx_id: u32, + c_path: *const std::ffi::c_char, + fd: i32, + c_mac: *mut u8, + features: u32, + flags: u32, +) -> i32; + +fn get_krun_add_net_unixgram() -> KrunAddNetUnixgramFn { + let symbol = CString::new("krun_add_net_unixgram").unwrap(); + let ptr = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + assert!(!ptr.is_null(), "krun_add_net_unixgram not found"); + unsafe { std::mem::transmute(ptr) } +} + +const VMNET_HELPER_PATH: &str = match option_env!("VMNET_HELPER_PATH") { + Some(path) => path, + None => "/opt/homebrew/opt/vmnet-helper/libexec/vmnet-helper", +}; + +fn vmnet_helper_path() -> Option<&'static str> { + std::path::Path::new(VMNET_HELPER_PATH) + .exists() + .then_some(VMNET_HELPER_PATH) +} + +/// Parse a MAC address string like "1e:d4:d1:27:4b:bf" into 6 bytes. +fn parse_mac(s: &str) -> Option<[u8; 6]> { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 6 { + return None; + } + let mut mac = [0u8; 6]; + for (i, part) in parts.iter().enumerate() { + mac[i] = u8::from_str_radix(part, 16).ok()?; + } + Some(mac) +} + +struct VmnetConfig { + fd: i32, + mac: [u8; 6], + pid: u32, +} + +/// Start vmnet-helper with `--fd 3`, wait for its JSON config on stdout, +/// and return the fd + MAC address from vmnet. +/// +/// Creates a `SOCK_DGRAM` socketpair, passes one end to vmnet-helper as fd 3 +/// (matching what `vmnet-client` does), and returns the other end for use +/// with `krun_add_net_unixgram`. +fn start_vmnet_helper(log_path: &std::path::Path) -> std::io::Result { + let helper = vmnet_helper_path().expect("vmnet-helper not found"); + + // Create a SOCK_DGRAM socketpair + let mut fds = [0 as libc::c_int; 2]; + if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_DGRAM, 0, fds.as_mut_ptr()) } < 0 { + return Err(std::io::Error::last_os_error()); + } + let (our_fd, helper_fd) = (fds[0], fds[1]); + + // On macOS SOCK_DGRAM, SO_SNDBUF determines the maximum frame size (not + // buffering). Must be >= 65550 for TSO frames. + // TODO: SO_RCVBUF at 65550 causes "network unreachable" — DHCP issue? + const SNDBUF_SIZE: libc::c_int = 65550; + const RCVBUF_SIZE: libc::c_int = 1024 * 1024; + for fd in [our_fd, helper_fd] { + unsafe { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_SNDBUF, + &SNDBUF_SIZE as *const _ as *const libc::c_void, + std::mem::size_of_val(&SNDBUF_SIZE) as libc::socklen_t, + ); + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_RCVBUF, + &RCVBUF_SIZE as *const _ as *const libc::c_void, + std::mem::size_of_val(&RCVBUF_SIZE) as libc::socklen_t, + ); + } + } + + let log_file = std::fs::File::create(log_path)?; + + let mut child = Command::new(helper) + .arg("--fd") + .arg(helper_fd.to_string()) + .arg("--enable-tso") + .arg("--enable-checksum-offload") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(log_file) + .spawn()?; + + // Parent: close helper's end of the socketpair + unsafe { libc::close(helper_fd) }; + + // Read the JSON config line from vmnet-helper's stdout. + // vmnet-helper writes a single JSON line then keeps running. + let stdout = child.stdout.take().unwrap(); + let reader = BufReader::new(stdout); + let mut config_line = String::new(); + reader + .take(4096) + .read_line(&mut config_line) + .map_err(|e| std::io::Error::other(format!("failed to read vmnet-helper config: {e}")))?; + + if config_line.is_empty() { + return Err(std::io::Error::other( + "vmnet-helper exited without producing config", + )); + } + + eprintln!("vmnet-helper config: {}", config_line.trim()); + + // Parse the MAC address from the JSON config. + // The JSON looks like: {"vmnet_mac_address":"1e:d4:d1:27:4b:bf",...} + let mac_str = config_line + .split("\"vmnet_mac_address\":\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .ok_or_else(|| std::io::Error::other("vmnet_mac_address not found in config"))?; + + let mac = parse_mac(mac_str) + .ok_or_else(|| std::io::Error::other(format!("invalid MAC address: {mac_str}")))?; + + Ok(VmnetConfig { + fd: our_fd, + mac, + pid: child.id(), + }) +} + +pub(crate) fn should_run() -> ShouldRun { + #[cfg(not(target_os = "macos"))] + return ShouldRun::No("vmnet-helper only supported on macOS"); + + #[cfg(target_os = "macos")] + { + if vmnet_helper_path().is_none() { + return ShouldRun::No("vmnet-helper not installed"); + } + ShouldRun::Yes + } +} + +pub(crate) fn setup_backend(ctx: u32, test_setup: &TestSetup) -> anyhow::Result<()> { + let tmp_dir = test_setup + .tmp_dir + .canonicalize() + .unwrap_or_else(|_| test_setup.tmp_dir.clone()); + let vmnet_log = tmp_dir.join("vmnet-helper.log"); + + let mut config = start_vmnet_helper(&vmnet_log)?; + test_setup.register_cleanup_pid(config.pid); + + unsafe { + krun_call!(get_krun_add_net_unixgram()( + ctx, + std::ptr::null(), + config.fd, + config.mac.as_mut_ptr(), + NET_FEATURE_CSUM + | NET_FEATURE_GUEST_CSUM + | NET_FEATURE_GUEST_TSO4 + | NET_FEATURE_HOST_TSO4, + 0, // no VFKIT flag + ))?; + } + Ok(()) +} diff --git a/tests/test_cases/src/test_net_perf.rs b/tests/test_cases/src/test_net_perf.rs new file mode 100644 index 000000000..8b1e79840 --- /dev/null +++ b/tests/test_cases/src/test_net_perf.rs @@ -0,0 +1,446 @@ +//! iperf3-based performance tests for virtio-net backends +//! +//! Each test: +//! 1. Host: Start iperf3 server + network backend +//! 2. Guest: Run iperf3 client (eth0 configured via DHCP by init) +//! 3. Host: Parse iperf3 JSON output, produce markdown report +//! +//! Tests are parametrized by backend and direction (TX = guest→host, RX = host→guest). + +use macros::{guest, host}; + +#[host] +use crate::{ShouldRun, TestSetup}; + +/// Virtio-net performance test with configurable backend and direction +pub struct TestNetPerf { + #[cfg(feature = "guest")] + host_ip: [u8; 4], + port: u16, + /// If true, run iperf3 with -R (reverse: server sends, client receives = RX) + reverse: bool, + #[cfg(feature = "host")] + should_run: fn() -> ShouldRun, + #[cfg(feature = "host")] + setup_backend: fn(u32, &TestSetup) -> anyhow::Result<()>, + #[cfg(feature = "host")] + cleanup: Option, +} + +impl TestNetPerf { + pub fn new_passt_tx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [169, 254, 2, 2], + port: 15100, + reverse: false, + #[cfg(feature = "host")] + should_run: crate::test_net::passt::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::passt::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_passt_rx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [169, 254, 2, 2], + port: 15110, + reverse: true, + #[cfg(feature = "host")] + should_run: crate::test_net::passt::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::passt::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_tap_tx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [10, 0, 0, 1], + port: 15101, + reverse: false, + #[cfg(feature = "host")] + should_run: crate::test_net::tap::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::tap::setup_backend, + #[cfg(feature = "host")] + cleanup: Some(crate::test_net::tap::cleanup), + } + } + + pub fn new_tap_rx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [10, 0, 0, 1], + port: 15111, + reverse: true, + #[cfg(feature = "host")] + should_run: crate::test_net::tap::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::tap::setup_backend, + #[cfg(feature = "host")] + cleanup: Some(crate::test_net::tap::cleanup), + } + } + + pub fn new_gvproxy_tx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [192, 168, 127, 254], + port: 15102, + reverse: false, + #[cfg(feature = "host")] + should_run: crate::test_net::gvproxy::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::gvproxy::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_gvproxy_rx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [192, 168, 127, 254], + port: 15112, + reverse: true, + #[cfg(feature = "host")] + should_run: crate::test_net::gvproxy::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::gvproxy::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_vmnet_helper_tx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [192, 168, 105, 1], + port: 15103, + reverse: false, + #[cfg(feature = "host")] + should_run: crate::test_net::vmnet_helper::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::vmnet_helper::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_vmnet_helper_rx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [192, 168, 105, 1], + port: 15113, + reverse: true, + #[cfg(feature = "host")] + should_run: crate::test_net::vmnet_helper::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::vmnet_helper::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } +} + +#[host] +mod host { + use super::*; + use crate::common::setup_fs_and_enter; + use crate::{krun_call, krun_call_u32, Test, TestOutcome, TestSetup}; + use krun_sys::*; + use std::process::{Child, Command, Stdio}; + + const CONTAINERFILE: &str = "\ +FROM fedora:43 +RUN dnf install -y iperf3 && dnf clean all +"; + + fn iperf3_available() -> bool { + Command::new("iperf3") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + fn start_iperf_server(port: u16) -> std::io::Result { + Command::new("iperf3") + .arg("-s") + .arg("-p") + .arg(port.to_string()) + .arg("-1") // one-off: exit after first client + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + } + + #[derive(serde::Deserialize)] + struct Iperf3Output { + intervals: Vec, + end: Iperf3End, + } + + #[derive(serde::Deserialize)] + struct Iperf3Interval { + sum: Iperf3Sum, + } + + #[derive(serde::Deserialize)] + struct Iperf3End { + sum_sent: Iperf3Sum, + sum_received: Iperf3Sum, + } + + #[derive(serde::Deserialize)] + #[allow(dead_code)] + struct Iperf3Sum { + start: f64, + end: f64, + seconds: f64, + bytes: f64, + bits_per_second: f64, + } + + struct Iperf3Report { + output: Iperf3Output, + reverse: bool, + } + + impl Iperf3Report { + fn label(&self) -> &'static str { + if self.reverse { + "RX (host→guest)" + } else { + "TX (guest→host)" + } + } + + fn summary(&self) -> &Iperf3Sum { + if self.reverse { + &self.output.end.sum_received + } else { + &self.output.end.sum_sent + } + } + } + + fn fmt_throughput(bits_per_second: f64) -> String { + if bits_per_second >= 1_000_000_000.0 { + format!("{:.2} Gbit/s", bits_per_second / 1_000_000_000.0) + } else { + format!("{:.2} Mbit/s", bits_per_second / 1_000_000.0) + } + } + + fn fmt_transferred(bytes: f64) -> String { + if bytes >= 1024.0 * 1024.0 * 1024.0 { + format!("{:.2} GiB", bytes / (1024.0 * 1024.0 * 1024.0)) + } else { + format!("{:.2} MiB", bytes / (1024.0 * 1024.0)) + } + } + + impl crate::ReportImpl for Iperf3Report { + fn fmt_text(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let i = f.width().unwrap_or(0); + writeln!(f, "{:i$}iperf3 — {}\n", "", self.label())?; + writeln!( + f, + "{:i$}{:<9} {:>18} {:>14}", + "", "Interval", "Throughput", "Transferred" + )?; + writeln!(f, "{:i$}{:-<9} {:-<18} {:-<14}", "", "", "", "")?; + for interval in &self.output.intervals { + let s = &interval.sum; + let iv = format!("{:.0}-{:.0}s", s.start, s.end); + writeln!( + f, + "{:i$}{:<9} {:>18} {:>14}", + "", + iv, + fmt_throughput(s.bits_per_second), + fmt_transferred(s.bytes), + )?; + } + let s = self.summary(); + writeln!(f, "{:i$}{:-<9} {:-<18} {:-<14}", "", "", "", "")?; + write!( + f, + "{:i$}{:<9} {:>18} {:>14}", + "", + "Total", + fmt_throughput(s.bits_per_second), + fmt_transferred(s.bytes), + ) + } + + fn fmt_gh_markdown(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "### iperf3 — {}\n", self.label())?; + writeln!(f, "| Interval | Throughput | Transferred |")?; + writeln!(f, "|----------|-----------|-------------|")?; + for interval in &self.output.intervals { + let s = &interval.sum; + writeln!( + f, + "| {:.0}-{:.0}s | {} | {} |", + s.start, + s.end, + fmt_throughput(s.bits_per_second), + fmt_transferred(s.bytes), + )?; + } + let s = self.summary(); + write!( + f, + "| **Total** | **{}** | **{}** |", + fmt_throughput(s.bits_per_second), + fmt_transferred(s.bytes), + ) + } + } + + impl Test for TestNetPerf { + fn should_run(&self) -> ShouldRun { + if option_env!("IPERF_DURATION").is_none() { + return ShouldRun::No("IPERF_DURATION not set"); + } + if unsafe { krun_call_u32!(krun_has_feature(KRUN_FEATURE_NET.into())) }.ok() != Some(1) + { + return ShouldRun::No("libkrun compiled without NET"); + } + let backend_result = (self.should_run)(); + if let ShouldRun::No(_) = backend_result { + return backend_result; + } + if !iperf3_available() { + return ShouldRun::No("iperf3 not installed on host"); + } + ShouldRun::Yes + } + + fn rootfs_image(&self) -> Option<&'static str> { + Some(CONTAINERFILE) + } + + fn timeout_secs(&self) -> u64 { + let iperf_secs: u64 = option_env!("IPERF_DURATION") + .and_then(|s| s.parse().ok()) + .unwrap_or(10); + // iperf duration + overhead for VM boot, retries, and network setup + iperf_secs + 15 + } + + fn start_vm(self: Box, test_setup: TestSetup) -> anyhow::Result<()> { + // Start iperf3 server on host (one-off, exits after first client) + let iperf_server = start_iperf_server(self.port)?; + test_setup.register_cleanup_pid(iperf_server.id()); + + // Give iperf3 server a moment to start + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Check it's still running + let mut iperf_server = iperf_server; + if let Some(status) = iperf_server.try_wait()? { + anyhow::bail!("iperf3 server exited early: {status}"); + } + + unsafe { + let ctx = krun_call_u32!(krun_create_ctx())?; + krun_call!(krun_set_vm_config(ctx, 1, 512))?; + + // Backend-specific setup + (self.setup_backend)(ctx, &test_setup)?; + + setup_fs_and_enter(ctx, test_setup)?; + } + Ok(()) + } + + fn check(self: Box, stdout: Vec) -> TestOutcome { + if let Some(cleanup) = self.cleanup { + cleanup(); + } + let stdout = String::from_utf8_lossy(&stdout).to_string(); + + match serde_json::from_str::(&stdout) { + Ok(iperf_output) => TestOutcome::Report(Box::new(Iperf3Report { + output: iperf_output, + reverse: self.reverse, + })), + Err(e) => TestOutcome::Fail(format!( + "expected valid iperf3 JSON, got error: {e}\nstdout: {stdout}" + )), + } + } + } +} + +#[guest] +mod guest { + use super::*; + use crate::Test; + use std::process::Command; + use std::time::Duration; + + impl Test for TestNetPerf { + fn in_guest(self: Box) { + let host_ip = format!( + "{}.{}.{}.{}", + self.host_ip[0], self.host_ip[1], self.host_ip[2], self.host_ip[3] + ); + + let Some(iperf_duration) = option_env!("IPERF_DURATION") else { + unreachable!() + }; + + // Run iperf3 client with JSON output, retry up to 5 times + let mut last_output = None; + for attempt in 0..5 { + if attempt > 0 { + std::thread::sleep(Duration::from_secs(2)); + } + + let mut cmd = Command::new("/usr/bin/iperf3"); + cmd.arg("-c") + .arg(&host_ip) + .arg("-p") + .arg(self.port.to_string()) + .arg("-t") + .arg(iperf_duration) + .arg("-J"); + + if self.reverse { + cmd.arg("-R"); + } + + let output = cmd.output().expect("Failed to run iperf3"); + + if output.status.success() { + // Print JSON output to stdout (host will read it) + let stdout = String::from_utf8(output.stdout).expect("iperf3 output not UTF-8"); + print!("{}", stdout); + return; + } + + last_output = Some(output); + } + + let output = last_output.unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + panic!( + "iperf3 failed after 5 attempts (exit={}):\nstderr: {}\nstdout: {}", + output.status, stderr, stdout + ); + } + } +} diff --git a/tests/test_cases/src/test_tsi_tcp_guest_connect.rs b/tests/test_cases/src/test_tsi_tcp_guest_connect.rs index 038501b37..426cdad05 100644 --- a/tests/test_cases/src/test_tsi_tcp_guest_connect.rs +++ b/tests/test_cases/src/test_tsi_tcp_guest_connect.rs @@ -1,5 +1,6 @@ use crate::tcp_tester::TcpTester; use macros::{guest, host}; +use std::net::Ipv4Addr; const PORT: u16 = 8000; @@ -10,7 +11,7 @@ pub struct TestTsiTcpGuestConnect { impl TestTsiTcpGuestConnect { pub fn new() -> TestTsiTcpGuestConnect { Self { - tcp_tester: TcpTester::new(PORT), + tcp_tester: TcpTester::new(Ipv4Addr::LOCALHOST, PORT), } } } diff --git a/tests/test_cases/src/test_tsi_tcp_guest_listen.rs b/tests/test_cases/src/test_tsi_tcp_guest_listen.rs index 9838ed893..41e0ffc2d 100644 --- a/tests/test_cases/src/test_tsi_tcp_guest_listen.rs +++ b/tests/test_cases/src/test_tsi_tcp_guest_listen.rs @@ -1,5 +1,6 @@ use crate::tcp_tester::TcpTester; use macros::{guest, host}; +use std::net::Ipv4Addr; const PORT: u16 = 8001; @@ -10,7 +11,7 @@ pub struct TestTsiTcpGuestListen { impl TestTsiTcpGuestListen { pub fn new() -> Self { Self { - tcp_tester: TcpTester::new(PORT), + tcp_tester: TcpTester::new(Ipv4Addr::LOCALHOST, PORT), } } }