diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cbb5a50d..5e33214c 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,10 +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 + 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 new file mode 100644 index 00000000..ced965f1 --- /dev/null +++ b/docs/windows-networking.md @@ -0,0 +1,7 @@ +# 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. + +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. + +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/guest/client.go b/lib/guest/client.go index c5d95f8f..cfdee83b 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 4554f413..d520602e 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 41b55771..205b4c47 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/README.md b/lib/instances/README.md index d4015ec0..36278a00 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/create.go b/lib/instances/create.go index 3d31f1a0..109ea461 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 85d7c01b..1d6c9169 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -474,26 +474,47 @@ 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 } + 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 { 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 +549,50 @@ 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 } +func parseWindowsDNSServers(value string) ([]string, error) { + var dns []string + 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 Windows DNS server %q", server) + } + dns = append(dns, server) + } + return dns, 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 f65b0eb1..c84eab71 100644 --- a/lib/instances/restore_egress_test.go +++ b/lib/instances/restore_egress_test.go @@ -30,6 +30,22 @@ func TestNetworkConfigFromAllocation_PreservesDNS(t *testing.T) { assert.Equal(t, alloc.TAPDevice, cfg.TAPDevice) } +func TestWindowsDNSServerParsingDoesNotAffectLinuxConfig(t *testing.T) { + t.Parallel() + + 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"}, dns) + _, err = parseWindowsDNSServers("2606:4700:4700::1111") + require.ErrorContains(t, err, "invalid Windows DNS server") + + 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.NoError(t, err) + assert.Empty(t, cfg.dns) +} + func TestGuestNetworkReconfigureCommand_AppliesAllocatedMAC(t *testing.T) { t.Parallel() diff --git a/lib/instances/start.go b/lib/instances/start.go index cc02476e..4c07a804 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 6a37c6c3..3a767a69 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_guest_agent_integration_linux_test.go b/lib/instances/windows_networking_integration_linux_test.go similarity index 60% rename from lib/instances/windows_guest_agent_integration_linux_test.go rename to lib/instances/windows_networking_integration_linux_test.go index fd5b0259..0bf2c6f6 100644 --- a/lib/instances/windows_guest_agent_integration_linux_test.go +++ b/lib/instances/windows_networking_integration_linux_test.go @@ -5,8 +5,12 @@ package instances import ( "bytes" "context" + "fmt" + "net" "os" + "os/exec" "path/filepath" + "strings" "testing" "time" @@ -19,9 +23,9 @@ import ( "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") +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_IMAGE") if fixture == "" { @@ -29,23 +33,23 @@ func TestWindowsGuestAgentIntegration(t *testing.T) { } if _, err := os.Stat(fixture); err != nil { if os.Getenv("CI") == "true" { - t.Fatalf("required Windows guest-agent fixture is missing: %s", fixture) + t.Fatalf("required Windows networking fixture is missing: %s", fixture) } - t.Skipf("Windows guest-agent fixture is unavailable: %s", fixture) + t.Skipf("Windows networking fixture is unavailable: %s", fixture) } acquireHeavyIO(t) manager, dataDir := setupTestManagerForQEMU(t) p := paths.New(dataDir) - const digestHex = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + const digestHex = "abababababababababababababababababababababababababababababababab" image := &images.Image{ - Name: "registry.example/windows/image:guest-agent-integration", + Name: "registry.example/windows/image:networking-integration", Digest: "sha256:" + digestHex, Platform: "windows/amd64", Status: images.StatusReady, Machine: &images.MachineImage{ Kind: images.MachineImageWindowsImage, - Base: "registry.example/windows/base@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + Base: "registry.example/windows/base@sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", TPM: "2.0", SecureBoot: "required", VirtualSize: 80 << 30, @@ -59,22 +63,30 @@ func TestWindowsGuestAgentIntegration(t *testing.T) { 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, + 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) 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") + }, 4*time.Minute, time.Second) + + assertWindowsGuestControl(t, ctx, manager, instance.Id) + assertWindowsNetworkReady(t, ctx, manager, instance.Id, instance.IP) +} - dialer, err := manager.GetVsockDialer(ctx, instance.Id) +func assertWindowsGuestControl(t *testing.T, ctx context.Context, manager *manager, instanceID string) { + t.Helper() + dialer, err := manager.GetVsockDialer(ctx, instanceID) require.NoError(t, err) var stdout, stderr bytes.Buffer @@ -86,7 +98,7 @@ func TestWindowsGuestAgentIntegration(t *testing.T) { 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") + assert.Less(t, time.Since(jobStart), 10*time.Second) time.Sleep(5 * time.Second) stdout.Reset() @@ -157,3 +169,35 @@ func TestWindowsGuestAgentIntegration(t *testing.T) { 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) + 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 }; $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, + 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 130d7b16..87f71e7c 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 9ebff6cb..ecca89ed 100644 --- a/lib/system/guest_agent/network_windows.go +++ b/lib/system/guest_agent/network_windows.go @@ -4,12 +4,221 @@ 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 +) + +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") + convertInterfaceLuidProc = ipHelperDLL.NewProc("ConvertInterfaceLuidToGuid") +) + +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.InterfaceGUID, req.DnsServers); err != nil { + return nil, err + } + } + return &pb.ReconfigureNetworkResponse{}, nil +} + +type windowsAdapterInfo struct { + Luid uint64 + Index uint32 + InterfaceGUID 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 == windows.ERROR_BUFFER_OVERFLOW { + 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} + 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...)) + } + } + 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) != windows.ERROR_NOT_FOUND { + 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) != windows.ERROR_NOT_FOUND { + 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()) }