From 9e2edc949631bb2577177aaf45b4c1658a766fcf Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:10:43 +0000 Subject: [PATCH 1/4] Add Windows guest networking --- docs/windows-networking.md | 18 ++ lib/guest/client.go | 2 + lib/guest/guest.pb.go | 14 +- lib/guest/guest.proto | 1 + lib/instances/create.go | 14 ++ lib/instances/restore.go | 48 +++- lib/instances/restore_egress_test.go | 15 ++ lib/instances/start.go | 14 ++ lib/instances/windows.go | 3 - ...ndows_networking_integration_linux_test.go | 109 +++++++++ lib/instances/windows_test.go | 2 +- lib/system/guest_agent/network_windows.go | 212 +++++++++++++++++- 12 files changed, 430 insertions(+), 22 deletions(-) create mode 100644 docs/windows-networking.md create mode 100644 lib/instances/windows_networking_integration_linux_test.go diff --git a/docs/windows-networking.md b/docs/windows-networking.md new file mode 100644 index 000000000..c84fb76fa --- /dev/null +++ b/docs/windows-networking.md @@ -0,0 +1,18 @@ +# Windows networking + +Hypeman can attach a Windows 11 QEMU guest to its normal TAP/bridge network. The public instance model remains unchanged: `NetworkEnabled` allocates the address, MAC, gateway, netmask, DNS servers, and TAP device used for Linux guests. + +After the Windows guest agent becomes reachable over virtio-vsock, Hypeman sends the allocation through the typed `ReconfigureNetwork` RPC. The Windows agent: + +1. finds the virtio-net adapter by its allocated MAC address; +2. removes stale IPv4 addresses and default routes; +3. creates the allocated IPv4 address and default route with Windows IP Helper APIs; and +4. applies the allocated DNS servers with `SetInterfaceDnsSettings`. + +Windows never uses the Linux shell-command fallback. Create fails and stops the VM if the typed reconfiguration fails. Start applies the current allocation again, allowing an instance to receive a different address or MAC after it was stopped. + +RDP is not a Hypeman API. A prepared persona may enable RDP, and callers can reach TCP port 3389 through the instance's generic allocated IP after applying their normal ingress policy. + +## Integration fixture + +`TestWindowsNetworkingIntegration` uses the private `HYPEMAN_WINDOWS_TEST_AGENT_PERSONA` fixture (default `/ci/windows/persona-agent.qcow2`). It verifies the address from inside Windows, performs a DNS lookup, checks ICMP, and opens the RDP TCP port over the allocated TAP network. The fixture and its Windows license are not stored in this repository. diff --git a/lib/guest/client.go b/lib/guest/client.go index c5d95f8fe..cfdee83b9 100644 --- a/lib/guest/client.go +++ b/lib/guest/client.go @@ -157,6 +157,7 @@ type ReconfigureNetworkOptions struct { IPv4 string Prefix uint32 Gateway string + DNSServers []string WaitForAgent time.Duration } @@ -240,6 +241,7 @@ func reconfigureNetworkOnce(ctx context.Context, dialer hypervisor.VsockDialer, Ipv4: opts.IPv4, Prefix: opts.Prefix, Gateway: opts.Gateway, + DnsServers: opts.DNSServers, }) finishGuestNetworkStepSpan(span, err) if err != nil { diff --git a/lib/guest/guest.pb.go b/lib/guest/guest.pb.go index 4554f413d..d520602eb 100644 --- a/lib/guest/guest.pb.go +++ b/lib/guest/guest.pb.go @@ -1328,6 +1328,7 @@ type ReconfigureNetworkRequest struct { Ipv4 string `protobuf:"bytes,3,opt,name=ipv4,proto3" json:"ipv4,omitempty"` // New IPv4 address without prefix Prefix uint32 `protobuf:"varint,4,opt,name=prefix,proto3" json:"prefix,omitempty"` // IPv4 prefix length Gateway string `protobuf:"bytes,5,opt,name=gateway,proto3" json:"gateway,omitempty"` // Default gateway IPv4 address + DnsServers []string `protobuf:"bytes,6,rep,name=dns_servers,json=dnsServers,proto3" json:"dns_servers,omitempty"` // DNS server IPv4 addresses unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1397,6 +1398,13 @@ func (x *ReconfigureNetworkRequest) GetGateway() string { return "" } +func (x *ReconfigureNetworkRequest) GetDnsServers() []string { + if x != nil { + return x.DnsServers + } + return nil +} + // ReconfigureNetworkResponse acknowledges the network reconfiguration request type ReconfigureNetworkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1527,13 +1535,15 @@ const file_lib_guest_guest_proto_rawDesc = "" + "\x05error\x18\b \x01(\tR\x05error\")\n" + "\x0fShutdownRequest\x12\x16\n" + "\x06signal\x18\x01 \x01(\x05R\x06signal\"\x12\n" + - "\x10ShutdownResponse\"\x9a\x01\n" + + "\x10ShutdownResponse\"\xbb\x01\n" + "\x19ReconfigureNetworkRequest\x12%\n" + "\x0einterface_name\x18\x01 \x01(\tR\rinterfaceName\x12\x10\n" + "\x03mac\x18\x02 \x01(\tR\x03mac\x12\x12\n" + "\x04ipv4\x18\x03 \x01(\tR\x04ipv4\x12\x16\n" + "\x06prefix\x18\x04 \x01(\rR\x06prefix\x12\x18\n" + - "\agateway\x18\x05 \x01(\tR\agateway\"\x1c\n" + + "\agateway\x18\x05 \x01(\tR\agateway\x12\x1f\n" + + "\vdns_servers\x18\x06 \x03(\tR\n" + + "dnsServers\"\x1c\n" + "\x1aReconfigureNetworkResponse*@\n" + "\vExecSession\x12\x17\n" + "\x13EXEC_SESSION_SYSTEM\x10\x00\x12\x18\n" + diff --git a/lib/guest/guest.proto b/lib/guest/guest.proto index 41b55771e..205b4c477 100644 --- a/lib/guest/guest.proto +++ b/lib/guest/guest.proto @@ -171,6 +171,7 @@ message ReconfigureNetworkRequest { string ipv4 = 3; // New IPv4 address without prefix uint32 prefix = 4; // IPv4 prefix length string gateway = 5; // Default gateway IPv4 address + repeated string dns_servers = 6; // DNS server IPv4 addresses } // ReconfigureNetworkResponse acknowledges the network reconfiguration request diff --git a/lib/instances/create.go b/lib/instances/create.go index 3d31f1a08..109ea4610 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -575,6 +575,20 @@ func (m *manager) createInstance( log.WarnContext(ctx, "failed to update metadata after VM start", "instance_id", id, "error", err) } + if windows && netConfig != nil { + networkCtx, networkSpanEnd := m.startLifecycleStep(ctx, "configure_guest_network", + attribute.String("instance_id", id), + attribute.String("hypervisor", string(stored.HypervisorType)), + attribute.String("operation", "configure_guest_network"), + ) + if err := reconfigureGuestNetworkConfig(networkCtx, stored, netConfig); err != nil { + networkSpanEnd(err) + _, _ = m.stopInstance(ctx, id) + return nil, fmt.Errorf("configure Windows guest network: %w", err) + } + networkSpanEnd(nil) + } + // Success - release cleanup stack (prevent cleanup) cu.Release() diff --git a/lib/instances/restore.go b/lib/instances/restore.go index 85d7c01bd..658428a78 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -474,7 +474,16 @@ func (m *manager) acquireRestoreSlot(ctx context.Context, hvType hypervisor.Type } func reconfigureGuestNetwork(ctx context.Context, stored *StoredMetadata, alloc *network.Allocation) error { - cfg, err := guestNetworkReconfigureConfig(alloc) + if alloc == nil { + return fmt.Errorf("missing network allocation") + } + return reconfigureGuestNetworkConfig(ctx, stored, &network.NetworkConfig{ + IP: alloc.IP, MAC: alloc.MAC, Gateway: alloc.Gateway, Netmask: alloc.Netmask, DNS: alloc.DNS, TAPDevice: alloc.TAPDevice, + }) +} + +func reconfigureGuestNetworkConfig(ctx context.Context, stored *StoredMetadata, netConfig *network.NetworkConfig) error { + cfg, err := guestNetworkReconfigureConfig(netConfig) if err != nil { return err } @@ -484,16 +493,22 @@ func reconfigureGuestNetwork(ctx context.Context, stored *StoredMetadata, alloc return fmt.Errorf("create vsock dialer: %w", err) } + interfaceName := "eth0" + if isWindowsPlatform(stored.Platform) { + interfaceName = "" + } err = guest.ReconfigureNetworkInInstance(ctx, dialer, guest.ReconfigureNetworkOptions{ - InterfaceName: "eth0", + InterfaceName: interfaceName, MAC: cfg.mac, IPv4: cfg.ip, Prefix: uint32(cfg.prefix), Gateway: cfg.gateway, + DNSServers: cfg.dns, WaitForAgent: 120 * time.Second, }) if err != nil { - if status.Code(err) == codes.Unimplemented { + if status.Code(err) == codes.Unimplemented && !isWindowsPlatform(stored.Platform) { + alloc := &network.Allocation{IP: netConfig.IP, MAC: netConfig.MAC, Gateway: netConfig.Gateway, Netmask: netConfig.Netmask} return reconfigureGuestNetworkWithExec(ctx, dialer, alloc) } return fmt.Errorf("reconfigure guest network: %w", err) @@ -528,37 +543,46 @@ type guestNetworkConfig struct { ip string mac string gateway string + dns []string prefix int } -func guestNetworkReconfigureConfig(alloc *network.Allocation) (*guestNetworkConfig, error) { - if alloc == nil { +func guestNetworkReconfigureConfig(netConfig *network.NetworkConfig) (*guestNetworkConfig, error) { + if netConfig == nil { return nil, fmt.Errorf("missing network allocation") } - ip := strings.TrimSpace(alloc.IP) + ip := strings.TrimSpace(netConfig.IP) if ip == "" { return nil, fmt.Errorf("missing network allocation IP") } - mac := strings.ToLower(strings.TrimSpace(alloc.MAC)) + mac := strings.ToLower(strings.TrimSpace(netConfig.MAC)) if mac == "" { return nil, fmt.Errorf("missing network allocation MAC") } if _, err := net.ParseMAC(mac); err != nil { - return nil, fmt.Errorf("invalid network allocation MAC %q: %w", alloc.MAC, err) + return nil, fmt.Errorf("invalid network allocation MAC %q: %w", netConfig.MAC, err) } - gateway := strings.TrimSpace(alloc.Gateway) + gateway := strings.TrimSpace(netConfig.Gateway) if gateway == "" { return nil, fmt.Errorf("missing network allocation gateway") } - prefix, err := netmaskToPrefix(alloc.Netmask) + prefix, err := netmaskToPrefix(netConfig.Netmask) if err != nil { return nil, err } - return &guestNetworkConfig{ip: ip, mac: mac, gateway: gateway, prefix: prefix}, nil + var dns []string + for _, server := range strings.FieldsFunc(netConfig.DNS, func(r rune) bool { return r == ',' || r == ' ' }) { + server = strings.TrimSpace(server) + if net.ParseIP(server).To4() == nil { + return nil, fmt.Errorf("invalid DNS server %q", server) + } + dns = append(dns, server) + } + return &guestNetworkConfig{ip: ip, mac: mac, gateway: gateway, dns: dns, prefix: prefix}, nil } func guestNetworkReconfigureCommand(alloc *network.Allocation) (string, error) { - cfg, err := guestNetworkReconfigureConfig(alloc) + cfg, err := guestNetworkReconfigureConfig(networkConfigFromAllocation(alloc)) if err != nil { return "", err } diff --git a/lib/instances/restore_egress_test.go b/lib/instances/restore_egress_test.go index f65b0eb15..224c8e586 100644 --- a/lib/instances/restore_egress_test.go +++ b/lib/instances/restore_egress_test.go @@ -30,6 +30,21 @@ func TestNetworkConfigFromAllocation_PreservesDNS(t *testing.T) { assert.Equal(t, alloc.TAPDevice, cfg.TAPDevice) } +func TestGuestNetworkReconfigureConfigParsesDNS(t *testing.T) { + t.Parallel() + + cfg, err := guestNetworkReconfigureConfig(&network.NetworkConfig{ + IP: "10.102.146.62", MAC: "02:00:00:85:17:c8", Gateway: "10.102.0.1", Netmask: "255.255.0.0", DNS: "1.1.1.1, 8.8.8.8", + }) + require.NoError(t, err) + assert.Equal(t, []string{"1.1.1.1", "8.8.8.8"}, cfg.dns) + + _, err = guestNetworkReconfigureConfig(&network.NetworkConfig{ + IP: "10.102.146.62", MAC: "02:00:00:85:17:c8", Gateway: "10.102.0.1", Netmask: "255.255.0.0", DNS: "not-an-address", + }) + require.ErrorContains(t, err, "invalid DNS server") +} + func TestGuestNetworkReconfigureCommand_AppliesAllocatedMAC(t *testing.T) { t.Parallel() diff --git a/lib/instances/start.go b/lib/instances/start.go index cc02476ec..4c07a8048 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -223,6 +223,20 @@ func (m *manager) startInstance( log.WarnContext(ctx, "failed to update metadata after VM start", "instance_id", id, "error", err) } + if isWindowsPlatform(stored.Platform) && netConfig != nil { + networkCtx, networkSpanEnd := m.startLifecycleStep(ctx, "configure_guest_network", + attribute.String("instance_id", id), + attribute.String("hypervisor", string(stored.HypervisorType)), + attribute.String("operation", "configure_guest_network"), + ) + if err := reconfigureGuestNetworkConfig(networkCtx, stored, netConfig); err != nil { + networkSpanEnd(err) + _, _ = m.stopInstance(ctx, id) + return nil, fmt.Errorf("configure Windows guest network: %w", err) + } + networkSpanEnd(nil) + } + // Return instance state from current metadata without forcing a log scan. finalInst := m.toInstanceWithoutHydration(ctx, meta) // Record metrics diff --git a/lib/instances/windows.go b/lib/instances/windows.go index 6a37c6c30..3a767a692 100644 --- a/lib/instances/windows.go +++ b/lib/instances/windows.go @@ -34,9 +34,6 @@ func validateWindowsCreate(req CreateInstanceRequest, image *images.Image, caps if req.Vcpus != 0 && req.Vcpus < 2 { return fmt.Errorf("%w: Windows 11 requires at least 2 vCPUs", ErrInvalidRequest) } - if req.NetworkEnabled { - return fmt.Errorf("%w: Windows networking is added in the networking phase", ErrInvalidRequest) - } if len(req.Volumes) != 0 || len(req.Devices) != 0 || req.GPU != nil { return fmt.Errorf("%w: Windows instances do not yet support volumes or device passthrough", ErrInvalidRequest) } diff --git a/lib/instances/windows_networking_integration_linux_test.go b/lib/instances/windows_networking_integration_linux_test.go new file mode 100644 index 000000000..725ff5b9d --- /dev/null +++ b/lib/instances/windows_networking_integration_linux_test.go @@ -0,0 +1,109 @@ +//go:build linux && amd64 + +package instances + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "os/exec" + "testing" + "time" + + "github.com/kernel/hypeman/lib/forkvm" + "github.com/kernel/hypeman/lib/guest" + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWindowsNetworkingIntegration(t *testing.T) { + fixture := os.Getenv("HYPEMAN_WINDOWS_TEST_AGENT_PERSONA") + if fixture == "" { + fixture = "/ci/windows/persona-agent.qcow2" + } + if _, err := os.Stat(fixture); err != nil { + if os.Getenv("CI") == "true" { + t.Fatalf("required Windows networking fixture is missing: %s", fixture) + } + t.Skipf("Windows networking fixture is unavailable: %s", fixture) + } + acquireHeavyIO(t) + + manager, dataDir := setupTestManagerForQEMU(t) + p := paths.New(dataDir) + const digestHex = "abababababababababababababababababababababababababababababababab" + image := &images.Image{ + Name: "registry.example/windows/persona:networking-integration", + Digest: "sha256:" + digestHex, + Platform: "windows/amd64", + Status: images.StatusReady, + Machine: &images.MachineImage{ + Kind: images.MachineImageWindowsPersona, + Base: "registry.example/windows/base@sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + TPM: "2.0", + SecureBoot: "required", + VirtualSize: 80 << 30, + }, + } + manager.imageManager = windowsFixtureImageManager{image: image} + personaPath, err := images.GetMachineDiskPath(p, image.Name, image.Digest, image.Machine) + require.NoError(t, err) + require.NoError(t, forkvm.CopyRegularFile(fixture, personaPath)) + require.NoError(t, os.Chmod(personaPath, 0444)) + + ctx := context.Background() + instance, err := manager.CreateInstance(ctx, CreateInstanceRequest{ + Name: "windows-networking-integration", + Image: image.Name, + Platform: "windows/amd64", + Size: 8 << 30, + Vcpus: 4, + NetworkEnabled: true, + Hypervisor: hypervisor.TypeQEMU, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = deleteTestInstanceNow(context.Background(), manager, instance.Id) }) + require.NotEmpty(t, instance.IP) + require.NotEmpty(t, instance.MAC) + + assertWindowsNetworkReady(t, ctx, manager, instance.Id, instance.IP) +} + +func assertWindowsNetworkReady(t *testing.T, ctx context.Context, manager *manager, instanceID, expectedIP string) { + t.Helper() + require.Eventually(t, func() bool { + current, err := manager.GetInstance(ctx, instanceID) + return err == nil && current.State == StateRunning + }, 4*time.Minute, time.Second) + + dialer, err := manager.GetVsockDialer(ctx, instanceID) + require.NoError(t, err) + var stdout, stderr bytes.Buffer + command := fmt.Sprintf("$a=Get-NetIPAddress -AddressFamily IPv4 | Where-Object IPAddress -eq '%s'; if (-not $a) { exit 20 }; [System.Net.Dns]::GetHostAddresses('example.com') | Out-Null; [Console]::Out.Write($a.IPAddress)", expectedIP) + exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command}, + Stdout: &stdout, + Stderr: &stderr, + Timeout: 30, + }) + require.NoError(t, err, stderr.String()) + require.Equal(t, 0, exit.Code, stderr.String()) + assert.Equal(t, expectedIP, stdout.String()) + + require.Eventually(t, func() bool { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(expectedIP, "3389"), time.Second) + if err != nil { + return false + } + _ = conn.Close() + return true + }, 2*time.Minute, time.Second, "RDP did not become reachable over the allocated network") + + ping := exec.Command("ping", "-c", "3", "-W", "2", expectedIP) + require.NoError(t, ping.Run(), "allocated Windows IP did not answer ICMP") +} diff --git a/lib/instances/windows_test.go b/lib/instances/windows_test.go index 130d7b165..87f71e7c0 100644 --- a/lib/instances/windows_test.go +++ b/lib/instances/windows_test.go @@ -32,6 +32,7 @@ func TestValidateWindowsCreate(t *testing.T) { image := windowsImageFixture() windowsCaps := hypervisor.Capabilities{SupportsUEFIBoot: true, SupportsTPM: true} require.NoError(t, validateWindowsCreate(CreateInstanceRequest{}, image, windowsCaps)) + require.NoError(t, validateWindowsCreate(CreateInstanceRequest{NetworkEnabled: true}, image, windowsCaps)) tests := []struct { name string @@ -39,7 +40,6 @@ func TestValidateWindowsCreate(t *testing.T) { caps hypervisor.Capabilities }{ {name: "missing boot capabilities"}, - {name: "networking", caps: windowsCaps, req: CreateInstanceRequest{NetworkEnabled: true}}, {name: "small memory", caps: windowsCaps, req: CreateInstanceRequest{Size: 2 << 30}}, {name: "one CPU", caps: windowsCaps, req: CreateInstanceRequest{Vcpus: 1}}, {name: "command", caps: windowsCaps, req: CreateInstanceRequest{Cmd: []string{"cmd.exe"}}}, diff --git a/lib/system/guest_agent/network_windows.go b/lib/system/guest_agent/network_windows.go index 9ebff6cb0..d933d1b9c 100644 --- a/lib/system/guest_agent/network_windows.go +++ b/lib/system/guest_agent/network_windows.go @@ -4,12 +4,216 @@ package main import ( "context" + "errors" + "fmt" + "net" + "strings" + "syscall" + "time" + "unsafe" pb "github.com/kernel/hypeman/lib/guest" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" + "golang.org/x/sys/windows" ) -func (s *guestServer) ReconfigureNetwork(context.Context, *pb.ReconfigureNetworkRequest) (*pb.ReconfigureNetworkResponse, error) { - return nil, status.Error(codes.Unimplemented, "Windows network reconfiguration is not available") +const ( + addressFamilyIPv4 = 2 + gaaFlagIncludePrefixes = 0x10 + dnsSettingsVersion1 = 1 + dnsSettingNameServer = 0x2 + windowsErrorBufferLarge = syscall.Errno(111) + windowsErrorNotFound = syscall.Errno(1168) +) + +var ( + errWindowsAdapterNotFound = errors.New("Windows network adapter not found") + ipHelperDLL = windows.NewLazySystemDLL("iphlpapi.dll") + initializeUnicastAddressProc = ipHelperDLL.NewProc("InitializeUnicastIpAddressEntry") + createUnicastAddressProc = ipHelperDLL.NewProc("CreateUnicastIpAddressEntry") + deleteUnicastAddressProc = ipHelperDLL.NewProc("DeleteUnicastIpAddressEntry") + initializeForwardEntryProc = ipHelperDLL.NewProc("InitializeIpForwardEntry") + createForwardEntryProc = ipHelperDLL.NewProc("CreateIpForwardEntry2") + deleteForwardEntryProc = ipHelperDLL.NewProc("DeleteIpForwardEntry2") + setInterfaceDNSSettingsProc = ipHelperDLL.NewProc("SetInterfaceDnsSettings") +) + +type dnsInterfaceSettings struct { + Version uint32 + Flags uint64 + Domain *uint16 + NameServer *uint16 + SearchList *uint16 + RegistrationEnabled uint32 + RegisterAdapterName uint32 + EnableLLMNR uint32 + QueryAdapterName uint32 + ProfileNameServer *uint16 +} + +func (s *guestServer) ReconfigureNetwork(ctx context.Context, req *pb.ReconfigureNetworkRequest) (*pb.ReconfigureNetworkResponse, error) { + mac, err := net.ParseMAC(req.Mac) + if err != nil { + return nil, fmt.Errorf("parse mac %q: %w", req.Mac, err) + } + ipv4 := net.ParseIP(req.Ipv4).To4() + if ipv4 == nil { + return nil, fmt.Errorf("parse ipv4 %q", req.Ipv4) + } + gateway := net.ParseIP(req.Gateway).To4() + if gateway == nil { + return nil, fmt.Errorf("parse gateway %q", req.Gateway) + } + if req.Prefix > 32 { + return nil, fmt.Errorf("invalid ipv4 prefix %d", req.Prefix) + } + for _, server := range req.DnsServers { + if net.ParseIP(server).To4() == nil { + return nil, fmt.Errorf("parse DNS server %q", server) + } + } + + adapter, err := waitForWindowsAdapter(ctx, mac, req.InterfaceName) + if err != nil { + return nil, err + } + if err := configureWindowsAddresses(adapter, ipv4, uint8(req.Prefix), gateway); err != nil { + return nil, err + } + if len(req.DnsServers) > 0 { + if err := configureWindowsDNS(adapter.NetworkGUID, req.DnsServers); err != nil { + return nil, err + } + } + return &pb.ReconfigureNetworkResponse{}, nil +} + +type windowsAdapterInfo struct { + Luid uint64 + Index uint32 + NetworkGUID windows.GUID + IPv4 []net.IP +} + +func waitForWindowsAdapter(ctx context.Context, mac net.HardwareAddr, name string) (*windowsAdapterInfo, error) { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + adapter, err := findWindowsAdapter(mac, name) + if err == nil { + return adapter, nil + } + if !errors.Is(err, errWindowsAdapterNotFound) { + return nil, err + } + select { + case <-ctx.Done(): + return nil, fmt.Errorf("wait for Windows network adapter: %w", ctx.Err()) + case <-ticker.C: + } + } +} + +func findWindowsAdapter(mac net.HardwareAddr, name string) (*windowsAdapterInfo, error) { + size := uint32(15 * 1024) + for { + buffer := make([]byte, size) + first := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buffer[0])) + err := windows.GetAdaptersAddresses(addressFamilyIPv4, gaaFlagIncludePrefixes, 0, first, &size) + if err == windowsErrorBufferLarge { + continue + } + if err != nil { + return nil, fmt.Errorf("list Windows network adapters: %w", err) + } + for adapter := first; adapter != nil; adapter = adapter.Next { + physical := net.HardwareAddr(adapter.PhysicalAddress[:adapter.PhysicalAddressLength]) + friendlyName := windows.UTF16PtrToString(adapter.FriendlyName) + if strings.EqualFold(physical.String(), mac.String()) && (name == "" || strings.EqualFold(name, friendlyName)) { + result := &windowsAdapterInfo{Luid: adapter.Luid, Index: adapter.IfIndex, NetworkGUID: adapter.NetworkGuid} + for address := adapter.FirstUnicastAddress; address != nil; address = address.Next { + if ipv4 := address.Address.IP().To4(); ipv4 != nil { + result.IPv4 = append(result.IPv4, append(net.IP(nil), ipv4...)) + } + } + return result, nil + } + } + return nil, fmt.Errorf("%w: MAC %s", errWindowsAdapterNotFound, mac) + } +} + +func configureWindowsAddresses(adapter *windowsAdapterInfo, ipv4 net.IP, prefix uint8, gateway net.IP) error { + for _, address := range adapter.IPv4 { + var row windows.MibUnicastIpAddressRow + initializeUnicastAddressProc.Call(uintptr(unsafe.Pointer(&row))) + row.InterfaceLuid = adapter.Luid + row.InterfaceIndex = adapter.Index + copyRawIPv4(unsafe.Pointer(&row.Address), address) + if code, _, _ := deleteUnicastAddressProc.Call(uintptr(unsafe.Pointer(&row))); code != 0 && syscall.Errno(code) != windowsErrorNotFound { + return fmt.Errorf("delete Windows IPv4 address: %w", syscall.Errno(code)) + } + } + + var routes *windows.MibIpForwardTable2 + if err := windows.GetIpForwardTable2(addressFamilyIPv4, &routes); err != nil { + return fmt.Errorf("list Windows IPv4 routes: %w", err) + } + if routes != nil { + defer windows.FreeMibTable(unsafe.Pointer(routes)) + for _, route := range routes.Rows() { + destination := (*windows.RawSockaddrInet4)(unsafe.Pointer(&route.DestinationPrefix.Prefix)) + if route.InterfaceLuid == adapter.Luid && route.DestinationPrefix.PrefixLength == 0 && destination.Addr == [4]byte{} { + row := route + if code, _, _ := deleteForwardEntryProc.Call(uintptr(unsafe.Pointer(&row))); code != 0 && syscall.Errno(code) != windowsErrorNotFound { + return fmt.Errorf("delete Windows default route: %w", syscall.Errno(code)) + } + } + } + } + + var addressRow windows.MibUnicastIpAddressRow + initializeUnicastAddressProc.Call(uintptr(unsafe.Pointer(&addressRow))) + addressRow.InterfaceLuid = adapter.Luid + addressRow.InterfaceIndex = adapter.Index + addressRow.OnLinkPrefixLength = prefix + copyRawIPv4(unsafe.Pointer(&addressRow.Address), ipv4) + if code, _, _ := createUnicastAddressProc.Call(uintptr(unsafe.Pointer(&addressRow))); code != 0 { + return fmt.Errorf("create Windows IPv4 address: %w", syscall.Errno(code)) + } + + var route windows.MibIpForwardRow2 + initializeForwardEntryProc.Call(uintptr(unsafe.Pointer(&route))) + route.InterfaceLuid = adapter.Luid + route.InterfaceIndex = adapter.Index + route.DestinationPrefix.PrefixLength = 0 + copyRawIPv4(unsafe.Pointer(&route.DestinationPrefix.Prefix), net.IPv4zero) + copyRawIPv4(unsafe.Pointer(&route.NextHop), gateway) + route.Protocol = windows.MIB_IPPROTO_NETMGMT + route.Origin = windows.NlroManual + if code, _, _ := createForwardEntryProc.Call(uintptr(unsafe.Pointer(&route))); code != 0 { + return fmt.Errorf("create Windows default route: %w", syscall.Errno(code)) + } + return nil +} + +func configureWindowsDNS(interfaceGUID windows.GUID, servers []string) error { + nameServers, err := windows.UTF16PtrFromString(strings.Join(servers, ",")) + if err != nil { + return err + } + settings := dnsInterfaceSettings{Version: dnsSettingsVersion1, Flags: dnsSettingNameServer, NameServer: nameServers} + code, _, _ := setInterfaceDNSSettingsProc.Call( + uintptr(unsafe.Pointer(&interfaceGUID)), + uintptr(unsafe.Pointer(&settings)), + ) + if code != 0 { + return fmt.Errorf("set Windows DNS servers: %w", syscall.Errno(code)) + } + return nil +} + +func copyRawIPv4(destination unsafe.Pointer, ip net.IP) { + address := (*windows.RawSockaddrInet4)(destination) + address.Family = addressFamilyIPv4 + copy(address.Addr[:], ip.To4()) } From 72784a76a9bdb887acf4f376188e9d9d9e419d85 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:50:06 +0000 Subject: [PATCH 2/4] Fix Windows DNS interface configuration --- lib/instances/restore.go | 16 +++++++-- lib/instances/restore_egress_test.go | 17 ++++----- ...ndows_networking_integration_linux_test.go | 6 +++- lib/system/guest_agent/network_windows.go | 35 +++++++++++-------- 4 files changed, 47 insertions(+), 27 deletions(-) diff --git a/lib/instances/restore.go b/lib/instances/restore.go index 658428a78..1d6c91690 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -487,6 +487,12 @@ func reconfigureGuestNetworkConfig(ctx context.Context, stored *StoredMetadata, if err != nil { return err } + if isWindowsPlatform(stored.Platform) { + cfg.dns, err = parseWindowsDNSServers(netConfig.DNS) + if err != nil { + return err + } + } dialer, err := hypervisor.NewVsockDialer(stored.HypervisorType, stored.VsockSocket, stored.VsockCID) if err != nil { @@ -570,15 +576,19 @@ func guestNetworkReconfigureConfig(netConfig *network.NetworkConfig) (*guestNetw if err != nil { return nil, err } + return &guestNetworkConfig{ip: ip, mac: mac, gateway: gateway, prefix: prefix}, nil +} + +func parseWindowsDNSServers(value string) ([]string, error) { var dns []string - for _, server := range strings.FieldsFunc(netConfig.DNS, func(r rune) bool { return r == ',' || r == ' ' }) { + for _, server := range strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ' ' }) { server = strings.TrimSpace(server) if net.ParseIP(server).To4() == nil { - return nil, fmt.Errorf("invalid DNS server %q", server) + return nil, fmt.Errorf("invalid Windows DNS server %q", server) } dns = append(dns, server) } - return &guestNetworkConfig{ip: ip, mac: mac, gateway: gateway, dns: dns, prefix: prefix}, nil + return dns, nil } func guestNetworkReconfigureCommand(alloc *network.Allocation) (string, error) { diff --git a/lib/instances/restore_egress_test.go b/lib/instances/restore_egress_test.go index 224c8e586..c84eab716 100644 --- a/lib/instances/restore_egress_test.go +++ b/lib/instances/restore_egress_test.go @@ -30,19 +30,20 @@ func TestNetworkConfigFromAllocation_PreservesDNS(t *testing.T) { assert.Equal(t, alloc.TAPDevice, cfg.TAPDevice) } -func TestGuestNetworkReconfigureConfigParsesDNS(t *testing.T) { +func TestWindowsDNSServerParsingDoesNotAffectLinuxConfig(t *testing.T) { t.Parallel() - cfg, err := guestNetworkReconfigureConfig(&network.NetworkConfig{ - IP: "10.102.146.62", MAC: "02:00:00:85:17:c8", Gateway: "10.102.0.1", Netmask: "255.255.0.0", DNS: "1.1.1.1, 8.8.8.8", - }) + dns, err := parseWindowsDNSServers("1.1.1.1, 8.8.8.8") require.NoError(t, err) - assert.Equal(t, []string{"1.1.1.1", "8.8.8.8"}, cfg.dns) + assert.Equal(t, []string{"1.1.1.1", "8.8.8.8"}, dns) + _, err = parseWindowsDNSServers("2606:4700:4700::1111") + require.ErrorContains(t, err, "invalid Windows DNS server") - _, err = guestNetworkReconfigureConfig(&network.NetworkConfig{ - IP: "10.102.146.62", MAC: "02:00:00:85:17:c8", Gateway: "10.102.0.1", Netmask: "255.255.0.0", DNS: "not-an-address", + cfg, err := guestNetworkReconfigureConfig(&network.NetworkConfig{ + IP: "10.102.146.62", MAC: "02:00:00:85:17:c8", Gateway: "10.102.0.1", Netmask: "255.255.0.0", DNS: "2606:4700:4700::1111", }) - require.ErrorContains(t, err, "invalid DNS server") + require.NoError(t, err) + assert.Empty(t, cfg.dns) } func TestGuestNetworkReconfigureCommand_AppliesAllocatedMAC(t *testing.T) { diff --git a/lib/instances/windows_networking_integration_linux_test.go b/lib/instances/windows_networking_integration_linux_test.go index 725ff5b9d..d46e3f7c0 100644 --- a/lib/instances/windows_networking_integration_linux_test.go +++ b/lib/instances/windows_networking_integration_linux_test.go @@ -9,6 +9,7 @@ import ( "net" "os" "os/exec" + "strings" "testing" "time" @@ -83,8 +84,11 @@ func assertWindowsNetworkReady(t *testing.T, ctx context.Context, manager *manag dialer, err := manager.GetVsockDialer(ctx, instanceID) require.NoError(t, err) + allocation, err := manager.networkManager.GetAllocation(ctx, instanceID) + require.NoError(t, err) + expectedDNS := strings.Join(strings.FieldsFunc(allocation.DNS, func(r rune) bool { return r == ',' || r == ' ' }), ",") var stdout, stderr bytes.Buffer - command := fmt.Sprintf("$a=Get-NetIPAddress -AddressFamily IPv4 | Where-Object IPAddress -eq '%s'; if (-not $a) { exit 20 }; [System.Net.Dns]::GetHostAddresses('example.com') | Out-Null; [Console]::Out.Write($a.IPAddress)", expectedIP) + command := fmt.Sprintf("$a=Get-NetIPAddress -AddressFamily IPv4 | Where-Object IPAddress -eq '%s'; if (-not $a) { exit 20 }; $dns=@((Get-DnsClientServerAddress -InterfaceIndex $a.InterfaceIndex -AddressFamily IPv4).ServerAddresses); if (($dns -join ',') -ne '%s') { exit 21 }; [System.Net.Dns]::GetHostAddresses('example.com') | Out-Null; [Console]::Out.Write($a.IPAddress)", expectedIP, expectedDNS) exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command}, Stdout: &stdout, diff --git a/lib/system/guest_agent/network_windows.go b/lib/system/guest_agent/network_windows.go index d933d1b9c..ecca89ed4 100644 --- a/lib/system/guest_agent/network_windows.go +++ b/lib/system/guest_agent/network_windows.go @@ -17,12 +17,10 @@ import ( ) const ( - addressFamilyIPv4 = 2 - gaaFlagIncludePrefixes = 0x10 - dnsSettingsVersion1 = 1 - dnsSettingNameServer = 0x2 - windowsErrorBufferLarge = syscall.Errno(111) - windowsErrorNotFound = syscall.Errno(1168) + addressFamilyIPv4 = 2 + gaaFlagIncludePrefixes = 0x10 + dnsSettingsVersion1 = 1 + dnsSettingNameServer = 0x2 ) var ( @@ -35,6 +33,7 @@ var ( createForwardEntryProc = ipHelperDLL.NewProc("CreateIpForwardEntry2") deleteForwardEntryProc = ipHelperDLL.NewProc("DeleteIpForwardEntry2") setInterfaceDNSSettingsProc = ipHelperDLL.NewProc("SetInterfaceDnsSettings") + convertInterfaceLuidProc = ipHelperDLL.NewProc("ConvertInterfaceLuidToGuid") ) type dnsInterfaceSettings struct { @@ -80,7 +79,7 @@ func (s *guestServer) ReconfigureNetwork(ctx context.Context, req *pb.Reconfigur return nil, err } if len(req.DnsServers) > 0 { - if err := configureWindowsDNS(adapter.NetworkGUID, req.DnsServers); err != nil { + if err := configureWindowsDNS(adapter.InterfaceGUID, req.DnsServers); err != nil { return nil, err } } @@ -88,10 +87,10 @@ func (s *guestServer) ReconfigureNetwork(ctx context.Context, req *pb.Reconfigur } type windowsAdapterInfo struct { - Luid uint64 - Index uint32 - NetworkGUID windows.GUID - IPv4 []net.IP + Luid uint64 + Index uint32 + InterfaceGUID windows.GUID + IPv4 []net.IP } func waitForWindowsAdapter(ctx context.Context, mac net.HardwareAddr, name string) (*windowsAdapterInfo, error) { @@ -119,7 +118,7 @@ func findWindowsAdapter(mac net.HardwareAddr, name string) (*windowsAdapterInfo, buffer := make([]byte, size) first := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buffer[0])) err := windows.GetAdaptersAddresses(addressFamilyIPv4, gaaFlagIncludePrefixes, 0, first, &size) - if err == windowsErrorBufferLarge { + if err == windows.ERROR_BUFFER_OVERFLOW { continue } if err != nil { @@ -129,7 +128,13 @@ func findWindowsAdapter(mac net.HardwareAddr, name string) (*windowsAdapterInfo, physical := net.HardwareAddr(adapter.PhysicalAddress[:adapter.PhysicalAddressLength]) friendlyName := windows.UTF16PtrToString(adapter.FriendlyName) if strings.EqualFold(physical.String(), mac.String()) && (name == "" || strings.EqualFold(name, friendlyName)) { - result := &windowsAdapterInfo{Luid: adapter.Luid, Index: adapter.IfIndex, NetworkGUID: adapter.NetworkGuid} + result := &windowsAdapterInfo{Luid: adapter.Luid, Index: adapter.IfIndex} + if code, _, _ := convertInterfaceLuidProc.Call( + uintptr(unsafe.Pointer(&result.Luid)), + uintptr(unsafe.Pointer(&result.InterfaceGUID)), + ); code != 0 { + return nil, fmt.Errorf("convert Windows interface LUID to GUID: %w", syscall.Errno(code)) + } for address := adapter.FirstUnicastAddress; address != nil; address = address.Next { if ipv4 := address.Address.IP().To4(); ipv4 != nil { result.IPv4 = append(result.IPv4, append(net.IP(nil), ipv4...)) @@ -149,7 +154,7 @@ func configureWindowsAddresses(adapter *windowsAdapterInfo, ipv4 net.IP, prefix row.InterfaceLuid = adapter.Luid row.InterfaceIndex = adapter.Index copyRawIPv4(unsafe.Pointer(&row.Address), address) - if code, _, _ := deleteUnicastAddressProc.Call(uintptr(unsafe.Pointer(&row))); code != 0 && syscall.Errno(code) != windowsErrorNotFound { + if code, _, _ := deleteUnicastAddressProc.Call(uintptr(unsafe.Pointer(&row))); code != 0 && syscall.Errno(code) != windows.ERROR_NOT_FOUND { return fmt.Errorf("delete Windows IPv4 address: %w", syscall.Errno(code)) } } @@ -164,7 +169,7 @@ func configureWindowsAddresses(adapter *windowsAdapterInfo, ipv4 net.IP, prefix destination := (*windows.RawSockaddrInet4)(unsafe.Pointer(&route.DestinationPrefix.Prefix)) if route.InterfaceLuid == adapter.Luid && route.DestinationPrefix.PrefixLength == 0 && destination.Addr == [4]byte{} { row := route - if code, _, _ := deleteForwardEntryProc.Call(uintptr(unsafe.Pointer(&row))); code != 0 && syscall.Errno(code) != windowsErrorNotFound { + if code, _, _ := deleteForwardEntryProc.Call(uintptr(unsafe.Pointer(&row))); code != 0 && syscall.Errno(code) != windows.ERROR_NOT_FOUND { return fmt.Errorf("delete Windows default route: %w", syscall.Errno(code)) } } From 73962a7840ac6abcf651eacb90ec66259e9d9a51 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:09:08 +0000 Subject: [PATCH 3/4] Isolate the Windows networking CI gate --- .github/workflows/test.yml | 17 +++++++++++++++++ ...windows_networking_integration_linux_test.go | 3 +++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cbb5a50dd..ac9f7196d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -150,6 +150,23 @@ jobs: done exit 1 + - name: Test Windows networking + run: | + TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" + for attempt in 1 2 3; do + if sudo env \ + "PATH=$TEST_PATH" \ + "CI=true" \ + "HYPEMAN_RUN_WINDOWS_NETWORKING_INTEGRATION=1" \ + "HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \ + "HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \ + go test -count=1 -run '^TestWindowsNetworkingIntegration$' -timeout 2m ./lib/instances; then + exit 0 + fi + test "$attempt" = 3 || sleep 5 + done + exit 1 + # Slash-command runs are maintainer-approved and need authenticated pulls # for images that are not covered by the prewarm cache. - name: Login to Docker Hub diff --git a/lib/instances/windows_networking_integration_linux_test.go b/lib/instances/windows_networking_integration_linux_test.go index d46e3f7c0..8befbec2b 100644 --- a/lib/instances/windows_networking_integration_linux_test.go +++ b/lib/instances/windows_networking_integration_linux_test.go @@ -23,6 +23,9 @@ import ( ) func TestWindowsNetworkingIntegration(t *testing.T) { + if os.Getenv("HYPEMAN_RUN_WINDOWS_NETWORKING_INTEGRATION") != "1" { + t.Skip("run by the dedicated Windows networking CI gate") + } fixture := os.Getenv("HYPEMAN_WINDOWS_TEST_AGENT_PERSONA") if fixture == "" { fixture = "/ci/windows/persona-agent.qcow2" From 6d78a3669d9665457c26323ace99c1ca1c2810ab Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:20:55 +0000 Subject: [PATCH 4/4] Consolidate Windows lifecycle coverage --- .github/workflows/test.yml | 23 +-- docs/windows-networking.md | 15 +- lib/instances/README.md | 6 + ...dows_guest_agent_integration_linux_test.go | 159 ------------------ ...ndows_networking_integration_linux_test.go | 115 +++++++++++-- 5 files changed, 112 insertions(+), 206 deletions(-) delete mode 100644 lib/instances/windows_guest_agent_integration_linux_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ac9f7196d..5e33214c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -132,7 +132,7 @@ jobs: done exit 1 - - name: Test Windows guest control + - name: Test Windows lifecycle run: | make build-embedded TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" @@ -140,27 +140,10 @@ jobs: if sudo env \ "PATH=$TEST_PATH" \ "CI=true" \ - "HYPEMAN_RUN_WINDOWS_GUEST_CONTROL_INTEGRATION=1" \ + "HYPEMAN_RUN_WINDOWS_LIFECYCLE_INTEGRATION=1" \ "HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \ "HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \ - go test -count=1 -run '^TestWindowsGuestAgentIntegration$' -timeout 2m ./lib/instances; then - exit 0 - fi - test "$attempt" = 3 || sleep 5 - done - exit 1 - - - name: Test Windows networking - run: | - TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" - for attempt in 1 2 3; do - if sudo env \ - "PATH=$TEST_PATH" \ - "CI=true" \ - "HYPEMAN_RUN_WINDOWS_NETWORKING_INTEGRATION=1" \ - "HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \ - "HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \ - go test -count=1 -run '^TestWindowsNetworkingIntegration$' -timeout 2m ./lib/instances; then + go test -count=1 -run '^TestWindowsLifecycleIntegration$' -timeout 2m ./lib/instances; then exit 0 fi test "$attempt" = 3 || sleep 5 diff --git a/docs/windows-networking.md b/docs/windows-networking.md index c84fb76fa..ced965f18 100644 --- a/docs/windows-networking.md +++ b/docs/windows-networking.md @@ -2,17 +2,6 @@ Hypeman can attach a Windows 11 QEMU guest to its normal TAP/bridge network. The public instance model remains unchanged: `NetworkEnabled` allocates the address, MAC, gateway, netmask, DNS servers, and TAP device used for Linux guests. -After the Windows guest agent becomes reachable over virtio-vsock, Hypeman sends the allocation through the typed `ReconfigureNetwork` RPC. The Windows agent: +Create and start apply the current allocation before the instance becomes ready. A configuration failure fails the lifecycle operation rather than exposing a guest with partial networking. -1. finds the virtio-net adapter by its allocated MAC address; -2. removes stale IPv4 addresses and default routes; -3. creates the allocated IPv4 address and default route with Windows IP Helper APIs; and -4. applies the allocated DNS servers with `SetInterfaceDnsSettings`. - -Windows never uses the Linux shell-command fallback. Create fails and stops the VM if the typed reconfiguration fails. Start applies the current allocation again, allowing an instance to receive a different address or MAC after it was stopped. - -RDP is not a Hypeman API. A prepared persona may enable RDP, and callers can reach TCP port 3389 through the instance's generic allocated IP after applying their normal ingress policy. - -## Integration fixture - -`TestWindowsNetworkingIntegration` uses the private `HYPEMAN_WINDOWS_TEST_AGENT_PERSONA` fixture (default `/ci/windows/persona-agent.qcow2`). It verifies the address from inside Windows, performs a DNS lookup, checks ICMP, and opens the RDP TCP port over the allocated TAP network. The fixture and its Windows license are not stored in this repository. +RDP is not a Hypeman API. A prepared image may enable RDP, and callers can reach TCP port 3389 through the instance's generic allocated IP after applying their normal ingress policy. diff --git a/lib/instances/README.md b/lib/instances/README.md index d4015ec0c..36278a005 100644 --- a/lib/instances/README.md +++ b/lib/instances/README.md @@ -30,6 +30,12 @@ The launchable Windows image already defines its virtual disk size, so instance A Windows VM remains `Initializing` until its guest agent answers over VioSock. This avoids treating firmware completion as application readiness. +### Windows networking + +Windows uses the same host-side TAP allocation as Linux. Once the guest agent is reachable, the manager sends the complete allocation through the typed `ReconfigureNetwork` RPC. The agent selects the virtio-net adapter by MAC address, replaces stale IPv4 addresses and default routes, and applies DNS through native Windows APIs. It never invokes the Linux shell-command fallback. + +Create treats network configuration as part of readiness and tears down a VM if it fails. Start reapplies the current allocation because a stopped instance may receive a different address or MAC before its next boot. + ### Why Config Disk? (configdisk.go) **What:** Read-only erofs disk with instance configuration diff --git a/lib/instances/windows_guest_agent_integration_linux_test.go b/lib/instances/windows_guest_agent_integration_linux_test.go deleted file mode 100644 index fd5b0259d..000000000 --- a/lib/instances/windows_guest_agent_integration_linux_test.go +++ /dev/null @@ -1,159 +0,0 @@ -//go:build linux && amd64 - -package instances - -import ( - "bytes" - "context" - "os" - "path/filepath" - "testing" - "time" - - "github.com/kernel/hypeman/lib/forkvm" - "github.com/kernel/hypeman/lib/guest" - "github.com/kernel/hypeman/lib/hypervisor" - "github.com/kernel/hypeman/lib/images" - "github.com/kernel/hypeman/lib/paths" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestWindowsGuestAgentIntegration(t *testing.T) { - if os.Getenv("HYPEMAN_RUN_WINDOWS_GUEST_CONTROL_INTEGRATION") != "1" { - t.Skip("run by the dedicated Windows guest-control CI gate") - } - fixture := os.Getenv("HYPEMAN_WINDOWS_TEST_AGENT_IMAGE") - if fixture == "" { - fixture = "/ci/windows/image-agent.qcow2" - } - if _, err := os.Stat(fixture); err != nil { - if os.Getenv("CI") == "true" { - t.Fatalf("required Windows guest-agent fixture is missing: %s", fixture) - } - t.Skipf("Windows guest-agent fixture is unavailable: %s", fixture) - } - acquireHeavyIO(t) - - manager, dataDir := setupTestManagerForQEMU(t) - p := paths.New(dataDir) - const digestHex = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" - image := &images.Image{ - Name: "registry.example/windows/image:guest-agent-integration", - Digest: "sha256:" + digestHex, - Platform: "windows/amd64", - Status: images.StatusReady, - Machine: &images.MachineImage{ - Kind: images.MachineImageWindowsImage, - Base: "registry.example/windows/base@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - TPM: "2.0", - SecureBoot: "required", - VirtualSize: 80 << 30, - }, - } - manager.imageManager = windowsFixtureImageManager{image: image} - imagePath, err := images.GetMachineDiskPath(p, image.Name, image.Digest, image.Machine) - require.NoError(t, err) - require.NoError(t, forkvm.CopyRegularFile(fixture, imagePath)) - require.NoError(t, os.Chmod(imagePath, 0444)) - - ctx := context.Background() - instance, err := manager.CreateInstance(ctx, CreateInstanceRequest{ - Name: "windows-guest-agent-integration", - Image: image.Name, - Platform: "windows/amd64", - Size: 8 << 30, - Vcpus: 4, - Hypervisor: hypervisor.TypeQEMU, - }) - require.NoError(t, err) - t.Cleanup(func() { _ = deleteTestInstanceNow(context.Background(), manager, instance.Id) }) - - require.Eventually(t, func() bool { - current, err := manager.GetInstance(ctx, instance.Id) - return err == nil && current.State == StateRunning - }, 4*time.Minute, time.Second, "Windows guest agent did not become ready") - - dialer, err := manager.GetVsockDialer(ctx, instance.Id) - require.NoError(t, err) - - var stdout, stderr bytes.Buffer - jobStart := time.Now() - exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ - Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `Copy-Item "$env:SystemRoot\System32\ping.exe" "$env:TEMP\hypeman-job-child.exe" -Force; & "$env:TEMP\hypeman-job-child.exe" -n 60 127.0.0.1`}, - Stdout: &stdout, - Stderr: &stderr, - Timeout: 2, - }) - require.NoError(t, err, stderr.String()) - assert.Less(t, time.Since(jobStart), 10*time.Second, "timed out process tree did not terminate promptly") - - time.Sleep(5 * time.Second) - stdout.Reset() - stderr.Reset() - exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ - Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `if (Get-Process hypeman-job-child -ErrorAction SilentlyContinue) { exit 42 }`}, - Stdout: &stdout, - Stderr: &stderr, - Timeout: 15, - }) - require.NoError(t, err, stderr.String()) - require.Equal(t, 0, exit.Code, "job object left a child process running") - - stdout.Reset() - stderr.Reset() - exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ - Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "[Console]::Out.Write('HYPEMAN_SYSTEM_OK')"}, - Stdout: &stdout, - Stderr: &stderr, - Timeout: 30, - }) - require.NoError(t, err, stderr.String()) - require.Equal(t, 0, exit.Code) - assert.Equal(t, "HYPEMAN_SYSTEM_OK", stdout.String()) - - stdout.Reset() - stderr.Reset() - resizes := make(chan *guest.WindowSize, 1) - resizes <- &guest.WindowSize{Rows: 37, Cols: 101} - close(resizes) - exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ - Command: []string{"cmd.exe", "/d", "/c", "ping -n 2 127.0.0.1 >nul & echo HYPEMAN_CONPTY_OK"}, - Stdout: &stdout, - Stderr: &stderr, - TTY: true, - Rows: 31, - Cols: 97, - ResizeChan: resizes, - Timeout: 30, - }) - require.NoError(t, err, stderr.String()) - require.Equal(t, 0, exit.Code) - assert.Contains(t, stdout.String(), "HYPEMAN_CONPTY_OK") - - stdout.Reset() - exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ - Command: []string{"cmd.exe", "/d", "/c", "echo", "HYPEMAN_DESKTOP_OK"}, - Stdout: &stdout, - Session: guest.ExecSession_EXEC_SESSION_DESKTOP, - Timeout: 30, - }) - require.NoError(t, err) - require.Equal(t, 0, exit.Code) - assert.Contains(t, stdout.String(), "HYPEMAN_DESKTOP_OK") - - source := filepath.Join(t.TempDir(), "roundtrip.txt") - require.NoError(t, os.WriteFile(source, []byte("HYPEMAN_COPY_OK"), 0644)) - require.NoError(t, guest.CopyToInstance(ctx, dialer, guest.CopyToInstanceOptions{ - SrcPath: source, - DstPath: `C:\ProgramData\Hypeman\roundtrip.txt`, - })) - destination := t.TempDir() - require.NoError(t, guest.CopyFromInstance(ctx, dialer, guest.CopyFromInstanceOptions{ - SrcPath: `C:\ProgramData\Hypeman\roundtrip.txt`, - DstPath: destination, - })) - contents, err := os.ReadFile(filepath.Join(destination, "roundtrip.txt")) - require.NoError(t, err) - assert.Equal(t, "HYPEMAN_COPY_OK", string(contents)) -} diff --git a/lib/instances/windows_networking_integration_linux_test.go b/lib/instances/windows_networking_integration_linux_test.go index 8befbec2b..0bf2c6f6c 100644 --- a/lib/instances/windows_networking_integration_linux_test.go +++ b/lib/instances/windows_networking_integration_linux_test.go @@ -9,6 +9,7 @@ import ( "net" "os" "os/exec" + "path/filepath" "strings" "testing" "time" @@ -22,13 +23,13 @@ import ( "github.com/stretchr/testify/require" ) -func TestWindowsNetworkingIntegration(t *testing.T) { - if os.Getenv("HYPEMAN_RUN_WINDOWS_NETWORKING_INTEGRATION") != "1" { +func TestWindowsLifecycleIntegration(t *testing.T) { + if os.Getenv("HYPEMAN_RUN_WINDOWS_LIFECYCLE_INTEGRATION") != "1" { t.Skip("run by the dedicated Windows networking CI gate") } - fixture := os.Getenv("HYPEMAN_WINDOWS_TEST_AGENT_PERSONA") + fixture := os.Getenv("HYPEMAN_WINDOWS_TEST_AGENT_IMAGE") if fixture == "" { - fixture = "/ci/windows/persona-agent.qcow2" + fixture = "/ci/windows/image-agent.qcow2" } if _, err := os.Stat(fixture); err != nil { if os.Getenv("CI") == "true" { @@ -42,12 +43,12 @@ func TestWindowsNetworkingIntegration(t *testing.T) { p := paths.New(dataDir) const digestHex = "abababababababababababababababababababababababababababababababab" image := &images.Image{ - Name: "registry.example/windows/persona:networking-integration", + Name: "registry.example/windows/image:networking-integration", Digest: "sha256:" + digestHex, Platform: "windows/amd64", Status: images.StatusReady, Machine: &images.MachineImage{ - Kind: images.MachineImageWindowsPersona, + Kind: images.MachineImageWindowsImage, Base: "registry.example/windows/base@sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", TPM: "2.0", SecureBoot: "required", @@ -55,10 +56,10 @@ func TestWindowsNetworkingIntegration(t *testing.T) { }, } manager.imageManager = windowsFixtureImageManager{image: image} - personaPath, err := images.GetMachineDiskPath(p, image.Name, image.Digest, image.Machine) + imagePath, err := images.GetMachineDiskPath(p, image.Name, image.Digest, image.Machine) require.NoError(t, err) - require.NoError(t, forkvm.CopyRegularFile(fixture, personaPath)) - require.NoError(t, os.Chmod(personaPath, 0444)) + require.NoError(t, forkvm.CopyRegularFile(fixture, imagePath)) + require.NoError(t, os.Chmod(imagePath, 0444)) ctx := context.Background() instance, err := manager.CreateInstance(ctx, CreateInstanceRequest{ @@ -74,17 +75,103 @@ func TestWindowsNetworkingIntegration(t *testing.T) { t.Cleanup(func() { _ = deleteTestInstanceNow(context.Background(), manager, instance.Id) }) require.NotEmpty(t, instance.IP) require.NotEmpty(t, instance.MAC) + require.Eventually(t, func() bool { + current, err := manager.GetInstance(ctx, instance.Id) + return err == nil && current.State == StateRunning + }, 4*time.Minute, time.Second) + assertWindowsGuestControl(t, ctx, manager, instance.Id) assertWindowsNetworkReady(t, ctx, manager, instance.Id, instance.IP) } -func assertWindowsNetworkReady(t *testing.T, ctx context.Context, manager *manager, instanceID, expectedIP string) { +func assertWindowsGuestControl(t *testing.T, ctx context.Context, manager *manager, instanceID string) { t.Helper() - require.Eventually(t, func() bool { - current, err := manager.GetInstance(ctx, instanceID) - return err == nil && current.State == StateRunning - }, 4*time.Minute, time.Second) + dialer, err := manager.GetVsockDialer(ctx, instanceID) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + jobStart := time.Now() + exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `Copy-Item "$env:SystemRoot\System32\ping.exe" "$env:TEMP\hypeman-job-child.exe" -Force; & "$env:TEMP\hypeman-job-child.exe" -n 60 127.0.0.1`}, + Stdout: &stdout, + Stderr: &stderr, + Timeout: 2, + }) + require.NoError(t, err, stderr.String()) + assert.Less(t, time.Since(jobStart), 10*time.Second) + + time.Sleep(5 * time.Second) + stdout.Reset() + stderr.Reset() + exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `if (Get-Process hypeman-job-child -ErrorAction SilentlyContinue) { exit 42 }`}, + Stdout: &stdout, + Stderr: &stderr, + Timeout: 15, + }) + require.NoError(t, err, stderr.String()) + require.Equal(t, 0, exit.Code, "job object left a child process running") + + stdout.Reset() + stderr.Reset() + exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "[Console]::Out.Write('HYPEMAN_SYSTEM_OK')"}, + Stdout: &stdout, + Stderr: &stderr, + Timeout: 30, + }) + require.NoError(t, err, stderr.String()) + require.Equal(t, 0, exit.Code) + assert.Equal(t, "HYPEMAN_SYSTEM_OK", stdout.String()) + + stdout.Reset() + stderr.Reset() + resizes := make(chan *guest.WindowSize, 1) + resizes <- &guest.WindowSize{Rows: 37, Cols: 101} + close(resizes) + exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: []string{"cmd.exe", "/d", "/c", "ping -n 2 127.0.0.1 >nul & echo HYPEMAN_CONPTY_OK"}, + Stdout: &stdout, + Stderr: &stderr, + TTY: true, + Rows: 31, + Cols: 97, + ResizeChan: resizes, + Timeout: 30, + }) + require.NoError(t, err, stderr.String()) + require.Equal(t, 0, exit.Code) + assert.Contains(t, stdout.String(), "HYPEMAN_CONPTY_OK") + stdout.Reset() + exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: []string{"cmd.exe", "/d", "/c", "echo", "HYPEMAN_DESKTOP_OK"}, + Stdout: &stdout, + Session: guest.ExecSession_EXEC_SESSION_DESKTOP, + Timeout: 30, + }) + require.NoError(t, err) + require.Equal(t, 0, exit.Code) + assert.Contains(t, stdout.String(), "HYPEMAN_DESKTOP_OK") + + source := filepath.Join(t.TempDir(), "roundtrip.txt") + require.NoError(t, os.WriteFile(source, []byte("HYPEMAN_COPY_OK"), 0644)) + require.NoError(t, guest.CopyToInstance(ctx, dialer, guest.CopyToInstanceOptions{ + SrcPath: source, + DstPath: `C:\ProgramData\Hypeman\roundtrip.txt`, + })) + destination := t.TempDir() + require.NoError(t, guest.CopyFromInstance(ctx, dialer, guest.CopyFromInstanceOptions{ + SrcPath: `C:\ProgramData\Hypeman\roundtrip.txt`, + DstPath: destination, + })) + contents, err := os.ReadFile(filepath.Join(destination, "roundtrip.txt")) + require.NoError(t, err) + assert.Equal(t, "HYPEMAN_COPY_OK", string(contents)) +} + +func assertWindowsNetworkReady(t *testing.T, ctx context.Context, manager *manager, instanceID, expectedIP string) { + t.Helper() dialer, err := manager.GetVsockDialer(ctx, instanceID) require.NoError(t, err) allocation, err := manager.networkManager.GetAllocation(ctx, instanceID)