diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3ddb93..bf8f731 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: with: node-version: "20" cache: npm + - run: sudo apt-get update && sudo apt-get install -y libslirp-dev libglib2.0-dev - run: npm ci --ignore-scripts - run: npm run build - run: npm run test:unit @@ -101,6 +102,7 @@ jobs: with: node-version: "20" cache: npm + - run: sudo apt-get update && sudo apt-get install -y libslirp-dev libglib2.0-dev - run: npm ci --ignore-scripts - run: npm run build - run: node --test dist/test/native.test.js diff --git a/.github/workflows/release-prebuilds.yml b/.github/workflows/release-prebuilds.yml index 4844d5a..ba17d24 100644 --- a/.github/workflows/release-prebuilds.yml +++ b/.github/workflows/release-prebuilds.yml @@ -57,7 +57,7 @@ jobs: with: node-version: "20" cache: npm - - run: sudo apt-get update && sudo apt-get install -y g++ libslirp-dev + - run: sudo apt-get update && sudo apt-get install -y g++ libslirp-dev libglib2.0-dev - run: npm ci --ignore-scripts - name: Build native addon env: diff --git a/README.md b/README.md index 18f780b..a0803d2 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,8 @@ Linux/KVM runtime: - Linux x64 npm installs use the bundled native prebuild by default - `python3`, `make`, and `g++` are only needed when forcing or falling back to a local `node-gyp` build +- `libslirp-dev` and `libglib2.0-dev` are needed when forcing a local build + that should support `network: "slirp"` / `--net slirp` - `mkfs.ext4`, `mount`, `umount`, `truncate`, `install` - `ip`, `iptables`, `sysctl` for `network: "auto"` / `--net auto` - `git` for `--repo` / SDK repo builds @@ -385,8 +387,8 @@ macOS/HVF runtime: | Run `--image alpine:3.20` from cache | Yes | Yes | Yes | Cache must already contain the prepared rootfs | | Run unsupported `--image ...` from cold cache | Yes | Yes, with WSL2 | Yes, when the OCI image has arm64 layers | OCI network access and ext4 builder | | Interactive shell / getty console | Yes | Yes | Yes | Host terminal | -| `apk add`, DNS, outbound network | Yes | Yes | Yes | Linux TAP/NAT or Slirp on Windows/macOS | -| TCP port forwarding | Yes | Yes | Yes | Linux TAP/NAT or Slirp on Windows/macOS | +| `apk add`, DNS, outbound network | Yes | Yes | Yes | Linux TAP/NAT or explicit Slirp; Slirp on Windows/macOS | +| TCP port forwarding | Yes | Yes | Yes | Linux TAP/NAT or explicit Slirp; Slirp on Windows/macOS | | Pause, resume, stop | Yes | Yes | Yes | Native backend | | RNG device for guest entropy | Yes | Yes | Host kernel entropy path | Native backend | | Multi-vCPU native runner | Yes | Yes | Yes | Full Linux guest SMP parity remains backend work | @@ -400,7 +402,8 @@ macOS/HVF runtime: Yes, VM execution can run without `sudo` when the host user can open `/dev/kvm` and the VM does not need root-only setup. The root-only parts are rootfs -creation/mounting and `--net auto`. +creation/mounting and `--net auto`; `--net slirp` avoids TAP/iptables setup when +the Linux native addon was built with libslirp. One simple workflow is: diff --git a/binding.gyp b/binding.gyp index 5f5d044..87f8286 100644 --- a/binding.gyp +++ b/binding.gyp @@ -18,7 +18,14 @@ "-fstack-protector-strong", "-D_FORTIFY_SOURCE=2" ], - "ldflags": ["-Wl,-z,relro", "-Wl,-z,now", "-Wl,-z,noexecstack"] + "ldflags": ["-Wl,-z,relro", "-Wl,-z,now", "-Wl,-z,noexecstack"], + "conditions": [ + ["\" r.end('ok\\n')).listen(3000, '0.0.0.0')\"" \ + --net slirp \ + -p 18081:3000 \ + --timeout-ms 30000 + +curl http://127.0.0.1:18081 +``` + `-p 3000`, `-p 18080:3000`, and `-p 127.0.0.1:18080:3000/tcp` follow Docker's common TCP publish form. UDP and publish-all are not implemented in the current Linux/KVM release. diff --git a/native/kvm/backend.cc b/native/kvm/backend.cc index d59e1f7..f801582 100644 --- a/native/kvm/backend.cc +++ b/native/kvm/backend.cc @@ -3,9 +3,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -35,6 +37,16 @@ #include #include +#ifdef NODE_VMM_HAVE_LIBSLIRP +extern "C" { +#if __has_include() +#include +#else +#include +#endif +} +#endif + #ifndef MFD_CLOEXEC #define MFD_CLOEXEC 0x0001U #endif @@ -1576,12 +1588,6 @@ class Uart { } void handle_tx_byte(uint8_t value) { - if (echo_stdout_) { - char byte = static_cast(value); - std::lock_guard lock(mu_); - emit_tx_locked(std::string(&byte, 1)); - return; - } static const std::string query = "\x1b[6n"; std::lock_guard lock(mu_); if (!terminal_query_.empty() || value == 0x1B) { @@ -2084,6 +2090,13 @@ Fd OpenTap(const std::string& tap_name) { return fd; } +struct SlirpHostFwdConfig { + bool udp{false}; + uint32_t host_ip{0}; + uint16_t host_port{0}; + uint16_t guest_port{0}; +}; + class VirtioNet { public: VirtioNet( @@ -2092,12 +2105,28 @@ class VirtioNet { uint64_t mmio_base, uint32_t irq, const std::string& tap_name, - const std::string& mac) - : sys_(sys), mem_(mem), mmio_base_(mmio_base), irq_(irq), tap_(OpenTap(tap_name)), mac_(ParseMac(mac)) { + const std::string& mac, + bool slirp_enabled, + const std::string& slirp_host_ip, + const std::string& slirp_guest_ip, + const std::string& slirp_netmask, + const std::string& slirp_dns, + const std::vector& slirp_host_fwds) + : sys_(sys), + mem_(mem), + mmio_base_(mmio_base), + irq_(irq), + tap_(slirp_enabled ? Fd{} : OpenTap(tap_name)), + mac_(ParseMac(mac)) { queues_[0].size = kMaxQueueSize; queues_[1].size = kMaxQueueSize; + if (slirp_enabled) { + start_slirp(slirp_host_ip, slirp_guest_ip, slirp_netmask, slirp_dns, slirp_host_fwds); + } } + ~VirtioNet() { stop_slirp(); } + uint64_t mmio_base() const { return mmio_base_; } void read_mmio(uint64_t addr, uint8_t* data, uint32_t len) { @@ -2130,6 +2159,11 @@ class VirtioNet { } void poll_rx() { + if (slirp_enabled()) { + poll_slirp(); + flush_slirp_rx(); + return; + } if (!queues_[0].ready) { drain_tap(false); return; @@ -2152,13 +2186,16 @@ class VirtioNet { if (n == 0) { break; } - inject_rx_frame(frame, static_cast(n)); + (void)inject_rx_frame(frame, static_cast(n)); } } - bool enabled() const { return tap_.get() >= 0; } + bool enabled() const { return tap_.get() >= 0 || slirp_enabled(); } bool tap_readable() const { + if (slirp_enabled()) { + return true; + } struct pollfd pfd {}; pfd.fd = tap_.get(); pfd.events = POLLIN; @@ -2333,8 +2370,11 @@ class VirtioNet { return q.ready && q.last_avail != ReadU16(mem_.ptr(q.driver_addr + 2, 2)); } - void inject_rx_frame(const uint8_t* frame, size_t len) { + bool inject_rx_frame(const uint8_t* frame, size_t len) { Queue& q = queues_[0]; + if (!has_rx_buffer(q)) { + return false; + } uint16_t head = ReadU16(mem_.ptr(q.driver_addr + 4 + uint64_t(q.last_avail % q.size) * 2, 2)); q.last_avail++; DescChain chain = walk_chain(q, head); @@ -2363,10 +2403,11 @@ class VirtioNet { } } if (offset < needed) { - return; + return false; } push_used(q, head, static_cast(needed)); signal_queue(); + return true; } void handle_tx_queue() { @@ -2385,6 +2426,7 @@ class VirtioNet { DescChain chain = walk_chain(q, head); std::array iov {}; int iov_len = 0; + std::vector packet; size_t skip = 12; for (size_t i = 0; i < chain.size; i++) { const Desc& d = chain[i]; @@ -2397,12 +2439,21 @@ class VirtioNet { pos = skip; skip = 0; if (d.len > pos && iov_len < static_cast(iov.size())) { - iov[iov_len].iov_base = mem_.ptr(d.addr + pos, d.len - pos); + uint8_t* data = mem_.ptr(d.addr + pos, d.len - pos); + if (slirp_enabled()) { + packet.insert(packet.end(), data, data + d.len - pos); + } else { + iov[iov_len].iov_base = data; iov[iov_len].iov_len = d.len - pos; iov_len++; + } } } - if (iov_len > 0) { + if (slirp_enabled()) { + input_slirp_packet(packet.data(), packet.size()); + poll_slirp(); + flush_slirp_rx(); + } else if (iov_len > 0) { ssize_t n = writev(tap_.get(), iov.data(), iov_len); if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { throw std::runtime_error(ErrnoMessage("write tap")); @@ -2413,6 +2464,9 @@ class VirtioNet { } void drain_tap(bool throw_errors) { + if (tap_.get() < 0) { + return; + } uint8_t buf[2048]; for (;;) { ssize_t n = read(tap_.get(), buf, sizeof(buf)); @@ -2429,6 +2483,215 @@ class VirtioNet { } } + bool slirp_enabled() const { +#ifdef NODE_VMM_HAVE_LIBSLIRP + return slirp_ != nullptr; +#else + return false; +#endif + } + + void start_slirp( + const std::string& host_ip, + const std::string& guest_ip, + const std::string& netmask, + const std::string& dns, + const std::vector& host_fwds) { +#ifdef NODE_VMM_HAVE_LIBSLIRP + in_addr host_addr{}; + in_addr guest_addr{}; + in_addr mask_addr{}; + in_addr dns_addr{}; + const std::string effective_host = host_ip.empty() ? "10.0.2.2" : host_ip; + const std::string effective_guest = guest_ip.empty() ? "10.0.2.15" : guest_ip; + const std::string effective_mask = netmask.empty() ? "255.255.255.0" : netmask; + const std::string effective_dns = dns.empty() ? "10.0.2.3" : dns; + Check(inet_pton(AF_INET, effective_host.c_str(), &host_addr) == 1, "invalid slirp host IP: " + effective_host); + Check(inet_pton(AF_INET, effective_guest.c_str(), &guest_addr) == 1, "invalid slirp guest IP: " + effective_guest); + Check(inet_pton(AF_INET, effective_mask.c_str(), &mask_addr) == 1, "invalid slirp netmask: " + effective_mask); + Check(inet_pton(AF_INET, effective_dns.c_str(), &dns_addr) == 1, "invalid slirp DNS IP: " + effective_dns); + + in_addr network_addr{}; + network_addr.s_addr = htonl(ntohl(host_addr.s_addr) & ntohl(mask_addr.s_addr)); + + SlirpConfig cfg{}; + cfg.version = SLIRP_CONFIG_VERSION_MAX; + cfg.in_enabled = true; + cfg.vnetwork = network_addr; + cfg.vnetmask = mask_addr; + cfg.vhost = host_addr; + cfg.vdhcp_start = guest_addr; + cfg.vnameserver = dns_addr; + cfg.if_mtu = 1500; + cfg.if_mru = 1500; + + memset(&slirp_cb_, 0, sizeof(slirp_cb_)); + slirp_cb_.send_packet = &VirtioNet::slirp_send_packet_cb; + slirp_cb_.guest_error = &VirtioNet::slirp_guest_error_cb; + slirp_cb_.clock_get_ns = &VirtioNet::slirp_clock_get_ns_cb; + slirp_cb_.notify = &VirtioNet::slirp_notify_cb; + slirp_cb_.register_poll_fd = &VirtioNet::slirp_register_poll_fd_cb; + slirp_cb_.unregister_poll_fd = &VirtioNet::slirp_unregister_poll_fd_cb; + + slirp_ = slirp_new(&cfg, &slirp_cb_, this); + Check(slirp_ != nullptr, "slirp_new failed"); + + for (const SlirpHostFwdConfig& fwd : host_fwds) { + in_addr host_bind{}; + in_addr guest_bind = guest_addr; + host_bind.s_addr = htonl(fwd.host_ip); + int rc = slirp_add_hostfwd(slirp_, fwd.udp ? 1 : 0, host_bind, fwd.host_port, guest_bind, fwd.guest_port); + Check(rc == 0, "slirp host forward failed: " + std::to_string(fwd.host_port) + " -> " + + effective_guest + ":" + std::to_string(fwd.guest_port)); + } +#else + (void)host_ip; + (void)guest_ip; + (void)netmask; + (void)dns; + (void)host_fwds; + throw std::runtime_error("Linux/KVM slirp networking is not available in this build; install libslirp-dev and rebuild"); +#endif + } + + void stop_slirp() { +#ifdef NODE_VMM_HAVE_LIBSLIRP + if (slirp_ != nullptr) { + slirp_cleanup(slirp_); + slirp_ = nullptr; + } +#endif + } + + void input_slirp_packet(const uint8_t* data, size_t len) { +#ifdef NODE_VMM_HAVE_LIBSLIRP + if (slirp_ != nullptr && data != nullptr && len > 0) { + slirp_input(slirp_, data, static_cast(len)); + } +#else + (void)data; + (void)len; +#endif + } + +#ifdef NODE_VMM_HAVE_LIBSLIRP + struct SlirpPollSet { + std::vector fds; + }; +#endif + + void poll_slirp() { +#ifdef NODE_VMM_HAVE_LIBSLIRP + if (slirp_ == nullptr) { + return; + } + uint32_t timeout_ms = 0; + SlirpPollSet poll_set; + slirp_pollfds_fill(slirp_, &timeout_ms, &VirtioNet::slirp_add_poll_cb, &poll_set); + int rc = 0; + if (!poll_set.fds.empty()) { + rc = poll(poll_set.fds.data(), static_cast(poll_set.fds.size()), 0); + if (rc < 0 && errno == EINTR) { + rc = 0; + } + } + slirp_pollfds_poll(slirp_, rc < 0 ? 1 : 0, &VirtioNet::slirp_get_revents_cb, &poll_set); +#endif + } + + void enqueue_slirp_rx(const uint8_t* data, size_t len) { + if (data == nullptr || len == 0) { + return; + } + slirp_rx_frames_.emplace_back(data, data + len); + } + + void flush_slirp_rx() { + while (!slirp_rx_frames_.empty()) { + const std::vector& frame = slirp_rx_frames_.front(); + if (!inject_rx_frame(frame.data(), frame.size())) { + return; + } + slirp_rx_frames_.pop_front(); + } + } + +#ifdef NODE_VMM_HAVE_LIBSLIRP + static short poll_events_from_slirp(int events) { + short out = 0; + if (events & SLIRP_POLL_IN) out |= POLLIN; + if (events & SLIRP_POLL_OUT) out |= POLLOUT; + if (events & SLIRP_POLL_PRI) out |= POLLPRI; + if (events & SLIRP_POLL_ERR) out |= POLLERR; + if (events & SLIRP_POLL_HUP) out |= POLLHUP; + return out; + } + + static int slirp_events_from_poll(short events) { + int out = 0; + if (events & POLLIN) out |= SLIRP_POLL_IN; + if (events & POLLOUT) out |= SLIRP_POLL_OUT; + if (events & POLLPRI) out |= SLIRP_POLL_PRI; + if (events & POLLERR) out |= SLIRP_POLL_ERR; + if (events & POLLHUP) out |= SLIRP_POLL_HUP; + return out; + } + + static int slirp_add_poll_cb(int fd, int events, void* opaque) { + auto* set = static_cast(opaque); + pollfd pfd{}; + pfd.fd = fd; + pfd.events = poll_events_from_slirp(events); + set->fds.push_back(pfd); + return static_cast(set->fds.size() - 1); + } + + static int slirp_get_revents_cb(int index, void* opaque) { + auto* set = static_cast(opaque); + if (index < 0 || static_cast(index) >= set->fds.size()) { + return 0; + } + return slirp_events_from_poll(set->fds[static_cast(index)].revents); + } + + static ssize_t slirp_send_packet_cb(const void* buf, size_t len, void* opaque) { + auto* self = static_cast(opaque); + if (buf == nullptr || len == 0) { + return 0; + } + if (len < 60) { + uint8_t padded[64]{}; + memcpy(padded, buf, len); + self->enqueue_slirp_rx(padded, 60); + return static_cast(len); + } + self->enqueue_slirp_rx(static_cast(buf), len); + return static_cast(len); + } + + static void slirp_guest_error_cb(const char* msg, void* /*opaque*/) { + if (msg != nullptr) { + fprintf(stderr, "[node-vmm kvm] slirp guest error: %s\n", msg); + } + } + + static int64_t slirp_clock_get_ns_cb(void* /*opaque*/) { + struct timespec ts {}; + clock_gettime(CLOCK_MONOTONIC, &ts); + return int64_t(ts.tv_sec) * 1000000000LL + int64_t(ts.tv_nsec); + } + + static void slirp_notify_cb(void* /*opaque*/) {} + + static void slirp_register_poll_fd_cb(int /*fd*/, void* opaque) { + slirp_notify_cb(opaque); + } + + static void slirp_unregister_poll_fd_cb(int /*fd*/, void* opaque) { + slirp_notify_cb(opaque); + } +#endif + KvmSystem& sys_; GuestMemory mem_; uint64_t mmio_base_{0}; @@ -2442,6 +2705,11 @@ class VirtioNet { uint32_t queue_sel_{0}; uint32_t interrupt_status_{0}; Queue queues_[2]; + std::deque> slirp_rx_frames_; +#ifdef NODE_VMM_HAVE_LIBSLIRP + ::Slirp* slirp_{nullptr}; + SlirpCb slirp_cb_{}; +#endif }; void HandleIo(struct kvm_run* run, Uart& uart, GuestExit* guest_exit = nullptr) { @@ -2696,6 +2964,43 @@ std::vector GetAttachedDisks(napi_env env, napi_value obj) { return out; } +uint32_t ParseIpv4HostOrder(const std::string& input, const std::string& label) { + in_addr addr {}; + Check(inet_pton(AF_INET, input.c_str(), &addr) == 1, "invalid IPv4 address for " + label + ": " + input); + return ntohl(addr.s_addr); +} + +std::vector GetSlirpHostFwds(napi_env env, napi_value obj) { + if (!HasNonNullishNamed(env, obj, "netSlirpHostFwds")) { + return {}; + } + napi_value value = GetNamed(env, obj, "netSlirpHostFwds"); + bool is_array = false; + napi_is_array(env, value, &is_array); + Check(is_array, "netSlirpHostFwds must be an array"); + + uint32_t length = 0; + napi_get_array_length(env, value, &length); + std::vector out; + out.reserve(length); + for (uint32_t i = 0; i < length; i++) { + napi_value entry; + napi_get_element(env, value, i, &entry); + napi_valuetype type = napi_undefined; + napi_typeof(env, entry, &type); + Check(type == napi_object, "netSlirpHostFwds entries must be objects"); + SlirpHostFwdConfig fwd; + fwd.udp = GetBool(env, entry, "udp", false); + fwd.host_ip = ParseIpv4HostOrder(GetString(env, entry, "hostAddr", "127.0.0.1"), "netSlirpHostFwds.hostAddr"); + fwd.host_port = static_cast(GetUint32(env, entry, "hostPort", 0)); + fwd.guest_port = static_cast(GetUint32(env, entry, "guestPort", 0)); + Check(fwd.host_port > 0, "netSlirpHostFwds entries require hostPort"); + Check(fwd.guest_port > 0, "netSlirpHostFwds entries require guestPort"); + out.push_back(fwd); + } + return out; +} + struct RunControl { int32_t* words{nullptr}; size_t length{0}; @@ -3174,10 +3479,16 @@ napi_value RunVm(napi_env env, napi_callback_info info) { bool interactive = GetBool(env, argv[0], "interactive", false); std::string tap_name = GetString(env, argv[0], "netTapName"); std::string guest_mac = GetString(env, argv[0], "netGuestMac"); + bool slirp_enabled = GetBool(env, argv[0], "netSlirpEnabled", false); + std::string slirp_host_ip = GetString(env, argv[0], "netHostIp", "10.0.2.2"); + std::string slirp_guest_ip = GetString(env, argv[0], "netGuestIp", "10.0.2.15"); + std::string slirp_netmask = GetString(env, argv[0], "netNetmask", "255.255.255.0"); + std::string slirp_dns = GetString(env, argv[0], "netDns", "10.0.2.3"); + std::vector slirp_host_fwds = GetSlirpHostFwds(env, argv[0]); std::vector attached_disks = GetAttachedDisks(env, argv[0]); RunControl control = GetRunControl(env, argv[0]); control.set_state(kControlStateStarting); - bool network_enabled = !tap_name.empty(); + bool network_enabled = !tap_name.empty() || slirp_enabled; Check(attached_disks.size() < kMaxIoApicPins, "too many attached disks"); uint32_t disk_count = static_cast(attached_disks.size() + 1); CheckVirtioMmioDeviceCount(disk_count + (network_enabled ? 1 : 0)); @@ -3185,7 +3496,8 @@ napi_value RunVm(napi_env env, napi_callback_info info) { Check(!rootfs_path.empty(), "rootfsPath is required"); Check(!cmdline.empty(), "cmdline is required"); Check(cpus >= 1 && cpus <= kMaxVcpus, "cpus must be between 1 and 64"); - Check(!network_enabled || !guest_mac.empty(), "netGuestMac is required when netTapName is set"); + Check(!(slirp_enabled && !tap_name.empty()), "netSlirpEnabled cannot be combined with netTapName"); + Check(!network_enabled || !guest_mac.empty(), "netGuestMac is required when networking is enabled"); Check(cmdline.size() + 1 <= kKernelCmdlineMax, "kernel cmdline is too long"); KvmSystem sys = CreateVm(mem_mib, true); @@ -3210,7 +3522,18 @@ napi_value RunVm(napi_env env, napi_callback_info info) { if (network_enabled) { uint32_t net_index = disk_count; net = std::make_unique( - sys, mem, VirtioMmioBase(net_index), VirtioMmioIrq(net_index), tap_name, guest_mac); + sys, + mem, + VirtioMmioBase(net_index), + VirtioMmioIrq(net_index), + tap_name, + guest_mac, + slirp_enabled, + slirp_host_ip, + slirp_guest_ip, + slirp_netmask, + slirp_dns, + slirp_host_fwds); } std::vector vcpus; diff --git a/prebuilds/linux-x64/node_vmm_native.node b/prebuilds/linux-x64/node_vmm_native.node index 6508496..8a9f7db 100755 Binary files a/prebuilds/linux-x64/node_vmm_native.node and b/prebuilds/linux-x64/node_vmm_native.node differ diff --git a/scripts/build-native.mjs b/scripts/build-native.mjs index d03f4ed..a22488e 100644 --- a/scripts/build-native.mjs +++ b/scripts/build-native.mjs @@ -25,9 +25,9 @@ if (!required && !force && existsSync(prebuild)) { process.exit(0); } -// Detect libslirp via vcpkg so the WHP backend can pull in virtio-net + slirp -// when available. The binding.gyp side reads NODE_VMM_HAVE_LIBSLIRP and -// NODE_VMM_LIBSLIRP_ROOT off the spawn environment. +// Detect libslirp so WHP and KVM can pull in virtio-net + slirp when +// available. The binding.gyp side reads NODE_VMM_HAVE_LIBSLIRP and +// platform-specific include/link flags off the spawn environment. const env = { ...process.env }; if (process.platform === "win32") { // Prefer the project-vendored libslirp (third_party/libslirp), populated by @@ -51,6 +51,21 @@ if (process.platform === "win32") { break; } } +if (process.platform === "linux") { + const exists = spawnSync("pkg-config", ["--exists", "slirp"], { stdio: "ignore" }); + if ((exists.status ?? 1) === 0) { + const cflags = spawnSync("pkg-config", ["--cflags", "slirp"], { encoding: "utf8" }); + const libs = spawnSync("pkg-config", ["--libs", "slirp"], { encoding: "utf8" }); + if ((cflags.status ?? 1) === 0 && (libs.status ?? 1) === 0) { + env.NODE_VMM_HAVE_LIBSLIRP = "1"; + env.NODE_VMM_LIBSLIRP_CFLAGS = cflags.stdout.trim(); + env.NODE_VMM_LIBSLIRP_LIBS = libs.stdout.trim(); + process.stdout.write("node-vmm native build: linking libslirp from pkg-config\n"); + } + } else { + process.stdout.write("node-vmm native build: libslirp not found; Linux --net slirp will be unavailable\n"); + } +} const result = spawnSync("node-gyp", ["rebuild"], { stdio: "inherit", shell: process.platform === "win32", env }); diff --git a/src/cli.ts b/src/cli.ts index 1b81089..8fc806f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -127,7 +127,7 @@ Common flags: --mem MIB guest memory (default: 256) --cpus N vCPU count, 1-64 (default: 1) --net auto|none|tap|slirp - network mode (default: auto; WHP auto resolves to slirp) + network mode (default: auto; WHP/HVF auto resolves to slirp) --tap NAME existing/created TAP name for --net tap or auto -p, --publish SPEC Docker-style TCP publish: [IP:]HOST:CONTAINER[/tcp] --port SPEC alias for --publish, repeatable diff --git a/src/index.ts b/src/index.ts index 92849d2..78be2f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,7 +137,7 @@ export function capabilitiesForHost(host: Partial = {}): HostC rootfsBuild: true, prebuiltRootfs: true, defaultNetwork: "auto", - networkModes: ["auto", "none", "tap"], + networkModes: ["auto", "none", "tap", "slirp"], tapNetwork: true, portForwarding: true, minCpus: 1, @@ -265,7 +265,7 @@ function featureLines(capabilities = hostCapabilities()): string[] { ? "network: virtio-mmio net with libslirp via --net auto/--net slirp; TCP publish supported" : backend === "hvf" ? "network: virtio-mmio net with libslirp via --net auto/--net slirp; TCP publish supported" - : "network: virtio-mmio net with TAP/NAT via --net auto; TCP publish supported" + : "network: virtio-mmio net with TAP/NAT via --net auto or libslirp via --net slirp; TCP publish supported" : "network: none by default; WHP networking, TAP, and TCP publish are not available yet", backend === "whp" ? "snapshot: rootfs snapshot bundles and WHP dirty-page probes; RAM/device restore still pending" @@ -1391,7 +1391,7 @@ export async function runImage( options.cmdline, options.bootArgs, [ - network.mode === "slirp" && capabilities.backend !== "hvf" + network.mode !== "none" && capabilities.backend !== "hvf" ? virtioNetKernelArg(attachedDisks.length, capabilities.backend) : undefined, network.kernelIpArg, @@ -1541,7 +1541,7 @@ export async function startVm( options.cmdline, options.bootArgs, [ - network.mode === "slirp" && capabilities.backend !== "hvf" + network.mode !== "none" && capabilities.backend !== "hvf" ? virtioNetKernelArg(attachedDisks.length, capabilities.backend) : undefined, network.kernelIpArg, @@ -1695,7 +1695,7 @@ export async function bootRootfs( options.cmdline, options.bootArgs, [ - network.mode === "slirp" && capabilities.backend !== "hvf" + network.mode !== "none" && capabilities.backend !== "hvf" ? virtioNetKernelArg(attachedDisks.length, capabilities.backend) : undefined, network.kernelIpArg, diff --git a/src/kvm.ts b/src/kvm.ts index a1591c2..6961cd5 100644 --- a/src/kvm.ts +++ b/src/kvm.ts @@ -197,6 +197,7 @@ export function runKvmVmAsync(config: KvmRunConfig, options: KvmRunAsyncOptions return Promise.reject(error); } return new Promise((resolve, reject) => { + /* c8 ignore start - console notification control needs live guest output; e2e/manual KVM runs cover it. */ const control = options.onConsoleOutput ? config.control && config.control.length >= CONTROL_WORDS ? config.control @@ -208,8 +209,10 @@ export function runKvmVmAsync(config: KvmRunConfig, options: KvmRunAsyncOptions Atomics.store(control, CONTROL_CONSOLE, 0); } const workerConfig = control ? { ...config, control } : config; + /* c8 ignore stop */ const worker = new Worker(new URL("./kvm-worker.js", import.meta.url), { workerData: workerConfig }); let settled = false; + /* c8 ignore start - console notification control needs live guest output; e2e/manual KVM runs cover it. */ let consoleNotified = false; let consolePoll: ReturnType | undefined; const notifyConsole = (): void => { @@ -238,6 +241,7 @@ export function runKvmVmAsync(config: KvmRunConfig, options: KvmRunAsyncOptions consolePoll = undefined; } }; + /* c8 ignore stop */ const settle = (fn: () => void): void => { if (settled) { return; diff --git a/src/net.ts b/src/net.ts index 66f5f1e..44926a2 100644 --- a/src/net.ts +++ b/src/net.ts @@ -399,7 +399,6 @@ export async function setupNetwork(options: SetupNetworkOptions): Promise /node-vmm/status 2>/dev/null || true node_vmm_log "[node-vmm] command exited with status $status" diff --git a/test/e2e.test.ts b/test/e2e.test.ts index efc764e..1523acf 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; +import http from "node:http"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -30,6 +32,81 @@ async function removeTempTree(target: string): Promise { } } +async function freeTcpPort(): Promise { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + await new Promise((resolve) => server.close(() => resolve())); + assert(address && typeof address !== "string"); + return address.port; +} + +function requestText(port: number, timeoutMs = 1000): Promise { + return new Promise((resolve, reject) => { + const request = http.request( + { host: "127.0.0.1", port, path: "/", method: "GET", agent: false, timeout: timeoutMs }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + body += chunk; + }); + response.on("end", () => resolve(body)); + }, + ); + request.on("timeout", () => request.destroy(new Error("request timed out"))); + request.on("error", reject); + request.end(); + }); +} + +async function waitHttpText(port: number, marker: string, timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const body = await requestText(port); + if (body.includes(marker)) { + return body; + } + lastError = new Error(`HTTP body did not include ${marker}: ${body.slice(0, 200)}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw lastError instanceof Error ? lastError : new Error(`HTTP did not become ready on ${port}`); +} + +function waitForChild(child: ReturnType, timeoutMs: number): Promise<{ code: number | null; output: string }> { + return new Promise((resolve, reject) => { + let output = ""; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error(`child did not exit within ${timeoutMs}ms\n${output}`)); + }, timeoutMs); + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk) => { + output += chunk; + }); + child.stderr?.on("data", (chunk) => { + output += chunk; + }); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ code, output }); + }); + }); +} + async function buildLocalBusyboxRootfs(tempDir: string, mode: "batch" | "interactive" = "interactive"): Promise { assert.equal(existsSync(BUSYBOX), true, `busybox not found: ${BUSYBOX}`); const rootfs = path.join(tempDir, "e2e.ext4"); @@ -99,7 +176,7 @@ import sys import time cmd = sys.argv[1:] -send_input = os.environ.get("NODE_VMM_E2E_PTY_INPUT", "echo e2e-console-ok\nexit\n").encode() +send_input = os.environ.get("NODE_VMM_E2E_PTY_INPUT", "echo e2e-console-ok\r\nexit\r\n").encode() expect = os.environ.get("NODE_VMM_E2E_PTY_EXPECT", "e2e-console-ok").encode() master, slave = pty.openpty() proc = subprocess.Popen(cmd, stdin=slave, stdout=slave, stderr=slave, close_fds=True) @@ -200,6 +277,7 @@ test("e2e interactive shell accepts input and exits", { skip: !E2E_ENABLED }, as kernel, "--cmd", "/bin/sh", + "--interactive", "--mem", "256", "--net", @@ -317,3 +395,60 @@ test("e2e exposes requested vCPUs to Linux guests", { skip: !E2E_ENABLED }, asyn await removeTempTree(tempDir); } }); + +test("e2e KVM slirp publishes a guest HTTP port", { skip: !E2E_ENABLED }, async () => { + const kernel = await requireKernelPath(); + assert.equal(existsSync(kernel), true, `kernel not found: ${kernel}`); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-e2e-slirp-")); + const hostPort = await freeTcpPort(); + const marker = "linux-slirp-port-ok"; + const command = + "node -e \"const http=require('node:http'); " + + "const server=http.createServer((_req,res)=>res.end('linux-slirp-port-ok\\n',()=>server.close(()=>process.exit(0)))); " + + "server.listen(3000,'0.0.0.0')\""; + let child: ReturnType | undefined; + try { + child = spawn( + "sudo", + [ + "-n", + "env", + `NODE_VMM_KERNEL=${kernel}`, + `PATH=${process.env.PATH || ""}`, + "node", + "dist/src/main.js", + "run", + "--image", + "node:22-alpine", + "--disk", + "768", + "--cache-dir", + path.join(tempDir, "oci-cache"), + "--kernel", + kernel, + "--cmd", + command, + "--mem", + "512", + "--net", + "slirp", + "--publish", + `${hostPort}:3000`, + "--timeout-ms", + "120000", + ], + { cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] }, + ); + const childDone = waitForChild(child, 180_000); + const body = await waitHttpText(hostPort, marker); + assert.match(body, new RegExp(marker)); + const result = await childDone; + assert.equal(result.code, 0, result.output); + assert.match(result.output, /stopped:/); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + child.kill("SIGTERM"); + } + await removeTempTree(tempDir); + } +}); diff --git a/test/unit.test.ts b/test/unit.test.ts index f32cf5b..9d91c45 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -58,6 +58,7 @@ import { boolOption, intOption, keyValueOption, stringListOption, stringOption } import { defaultKernelCmdline, hvfDefaultKernelCmdline, + probeHvf, runHvfVm, runHvfVmAsync, runHvfVmControlled, @@ -329,6 +330,7 @@ test("kernel helpers resolve node-vmm env, cache, gocracker URLs, and downloads" assert.equal(gocrackerKernelUrl(DEFAULT_GOCRACKER_KERNEL, env), "https://example.test/kernels/gocracker-guest-standard-vmlinux.gz"); assert.match(gocrackerKernelUrl("custom", {} as NodeJS.ProcessEnv), /gocracker\/main\/artifacts\/kernels\/custom\.gz$/); assert.ok(defaultKernelCandidates({ cwd: dir, env }).includes(cachedKernel)); + assert.ok(defaultKernelCandidates({ cwd: dir, env, name: "custom-kernel" }).some((candidate) => candidate.endsWith("custom-kernel"))); assert.ok(defaultKernelCandidates({ env }).some((candidate) => candidate.endsWith(platformKernel))); assert.equal(await requireKernelPath({ cwd: dir, env }), cachedKernel); assert.equal(await findDefaultKernel({ cwd: dir, env: { NODE_VMM_KERNEL: "rel/vmlinux" } as NodeJS.ProcessEnv }), path.join(dir, "rel/vmlinux")); @@ -734,8 +736,10 @@ test("host capabilities model backend defaults and WHP runtime limits", () => { assert.equal(kvm.archLine, "linux/x86_64"); assert.equal(defaultNetworkForCapabilities(kvm, undefined), "auto"); assert.equal(defaultNetworkForCapabilities(kvm, undefined, "tap-test"), "tap"); + assert.deepEqual(kvm.networkModes, ["auto", "none", "tap", "slirp"]); assert.equal(kvm.rootfsMaxCpus, 64); assert.doesNotThrow(() => validateVmOptionsForCapabilities({ cpus: 64, network: "auto", ports: ["3000"] }, kvm)); + assert.doesNotThrow(() => validateVmOptionsForCapabilities({ cpus: 64, network: "slirp", ports: ["3000"] }, kvm)); assert.doesNotThrow(() => validateRootfsRuntimeForCapabilities("run", { cpus: 64 }, kvm)); assert.doesNotThrow(() => assertRootfsBuildSupportedForCapabilities("run", { image: "alpine:3.20" }, kvm)); @@ -1057,6 +1061,9 @@ test("runKvmVmControlled exposes lifecycle helpers and forwards native validatio }); test("HVF run wrappers forward native validation errors", async () => { + if (process.platform !== "darwin" || process.arch !== "arm64") { + assert.throws(() => probeHvf(), /probeHvf|native backend/); + } assert.match(hvfDefaultKernelCmdline(), /console=ttyAMA0/); const config = { kernelPath: "", rootfsPath: "", cmdline: "", memMiB: 1, cpus: 2 }; assert.throws(() => runHvfVm(config), /kernelPath is required/);