Skip to content

Commit 43a01d4

Browse files
committed
Add Windows guest networking
1 parent 0d96de5 commit 43a01d4

12 files changed

Lines changed: 430 additions & 22 deletions

docs/windows-networking.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Windows networking
2+
3+
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.
4+
5+
After the Windows guest agent becomes reachable over virtio-vsock, Hypeman sends the allocation through the typed `ReconfigureNetwork` RPC. The Windows agent:
6+
7+
1. finds the virtio-net adapter by its allocated MAC address;
8+
2. removes stale IPv4 addresses and default routes;
9+
3. creates the allocated IPv4 address and default route with Windows IP Helper APIs; and
10+
4. applies the allocated DNS servers with `SetInterfaceDnsSettings`.
11+
12+
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.
13+
14+
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.
15+
16+
## Integration fixture
17+
18+
`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.

lib/guest/client.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ type ReconfigureNetworkOptions struct {
157157
IPv4 string
158158
Prefix uint32
159159
Gateway string
160+
DNSServers []string
160161
WaitForAgent time.Duration
161162
}
162163

@@ -240,6 +241,7 @@ func reconfigureNetworkOnce(ctx context.Context, dialer hypervisor.VsockDialer,
240241
Ipv4: opts.IPv4,
241242
Prefix: opts.Prefix,
242243
Gateway: opts.Gateway,
244+
DnsServers: opts.DNSServers,
243245
})
244246
finishGuestNetworkStepSpan(span, err)
245247
if err != nil {

lib/guest/guest.pb.go

Lines changed: 12 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/guest/guest.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ message ReconfigureNetworkRequest {
171171
string ipv4 = 3; // New IPv4 address without prefix
172172
uint32 prefix = 4; // IPv4 prefix length
173173
string gateway = 5; // Default gateway IPv4 address
174+
repeated string dns_servers = 6; // DNS server IPv4 addresses
174175
}
175176

176177
// ReconfigureNetworkResponse acknowledges the network reconfiguration request

lib/instances/create.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,20 @@ func (m *manager) createInstance(
574574
log.WarnContext(ctx, "failed to update metadata after VM start", "instance_id", id, "error", err)
575575
}
576576

577+
if windows && netConfig != nil {
578+
networkCtx, networkSpanEnd := m.startLifecycleStep(ctx, "configure_guest_network",
579+
attribute.String("instance_id", id),
580+
attribute.String("hypervisor", string(stored.HypervisorType)),
581+
attribute.String("operation", "configure_guest_network"),
582+
)
583+
if err := reconfigureGuestNetworkConfig(networkCtx, stored, netConfig); err != nil {
584+
networkSpanEnd(err)
585+
_, _ = m.stopInstance(ctx, id)
586+
return nil, fmt.Errorf("configure Windows guest network: %w", err)
587+
}
588+
networkSpanEnd(nil)
589+
}
590+
577591
// Success - release cleanup stack (prevent cleanup)
578592
cu.Release()
579593

lib/instances/restore.go

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,16 @@ func (m *manager) acquireRestoreSlot(ctx context.Context, hvType hypervisor.Type
474474
}
475475

476476
func reconfigureGuestNetwork(ctx context.Context, stored *StoredMetadata, alloc *network.Allocation) error {
477-
cfg, err := guestNetworkReconfigureConfig(alloc)
477+
if alloc == nil {
478+
return fmt.Errorf("missing network allocation")
479+
}
480+
return reconfigureGuestNetworkConfig(ctx, stored, &network.NetworkConfig{
481+
IP: alloc.IP, MAC: alloc.MAC, Gateway: alloc.Gateway, Netmask: alloc.Netmask, DNS: alloc.DNS, TAPDevice: alloc.TAPDevice,
482+
})
483+
}
484+
485+
func reconfigureGuestNetworkConfig(ctx context.Context, stored *StoredMetadata, netConfig *network.NetworkConfig) error {
486+
cfg, err := guestNetworkReconfigureConfig(netConfig)
478487
if err != nil {
479488
return err
480489
}
@@ -484,16 +493,22 @@ func reconfigureGuestNetwork(ctx context.Context, stored *StoredMetadata, alloc
484493
return fmt.Errorf("create vsock dialer: %w", err)
485494
}
486495

496+
interfaceName := "eth0"
497+
if isWindowsPlatform(stored.Platform) {
498+
interfaceName = ""
499+
}
487500
err = guest.ReconfigureNetworkInInstance(ctx, dialer, guest.ReconfigureNetworkOptions{
488-
InterfaceName: "eth0",
501+
InterfaceName: interfaceName,
489502
MAC: cfg.mac,
490503
IPv4: cfg.ip,
491504
Prefix: uint32(cfg.prefix),
492505
Gateway: cfg.gateway,
506+
DNSServers: cfg.dns,
493507
WaitForAgent: 120 * time.Second,
494508
})
495509
if err != nil {
496-
if status.Code(err) == codes.Unimplemented {
510+
if status.Code(err) == codes.Unimplemented && !isWindowsPlatform(stored.Platform) {
511+
alloc := &network.Allocation{IP: netConfig.IP, MAC: netConfig.MAC, Gateway: netConfig.Gateway, Netmask: netConfig.Netmask}
497512
return reconfigureGuestNetworkWithExec(ctx, dialer, alloc)
498513
}
499514
return fmt.Errorf("reconfigure guest network: %w", err)
@@ -528,37 +543,46 @@ type guestNetworkConfig struct {
528543
ip string
529544
mac string
530545
gateway string
546+
dns []string
531547
prefix int
532548
}
533549

534-
func guestNetworkReconfigureConfig(alloc *network.Allocation) (*guestNetworkConfig, error) {
535-
if alloc == nil {
550+
func guestNetworkReconfigureConfig(netConfig *network.NetworkConfig) (*guestNetworkConfig, error) {
551+
if netConfig == nil {
536552
return nil, fmt.Errorf("missing network allocation")
537553
}
538-
ip := strings.TrimSpace(alloc.IP)
554+
ip := strings.TrimSpace(netConfig.IP)
539555
if ip == "" {
540556
return nil, fmt.Errorf("missing network allocation IP")
541557
}
542-
mac := strings.ToLower(strings.TrimSpace(alloc.MAC))
558+
mac := strings.ToLower(strings.TrimSpace(netConfig.MAC))
543559
if mac == "" {
544560
return nil, fmt.Errorf("missing network allocation MAC")
545561
}
546562
if _, err := net.ParseMAC(mac); err != nil {
547-
return nil, fmt.Errorf("invalid network allocation MAC %q: %w", alloc.MAC, err)
563+
return nil, fmt.Errorf("invalid network allocation MAC %q: %w", netConfig.MAC, err)
548564
}
549-
gateway := strings.TrimSpace(alloc.Gateway)
565+
gateway := strings.TrimSpace(netConfig.Gateway)
550566
if gateway == "" {
551567
return nil, fmt.Errorf("missing network allocation gateway")
552568
}
553-
prefix, err := netmaskToPrefix(alloc.Netmask)
569+
prefix, err := netmaskToPrefix(netConfig.Netmask)
554570
if err != nil {
555571
return nil, err
556572
}
557-
return &guestNetworkConfig{ip: ip, mac: mac, gateway: gateway, prefix: prefix}, nil
573+
var dns []string
574+
for _, server := range strings.FieldsFunc(netConfig.DNS, func(r rune) bool { return r == ',' || r == ' ' }) {
575+
server = strings.TrimSpace(server)
576+
if net.ParseIP(server).To4() == nil {
577+
return nil, fmt.Errorf("invalid DNS server %q", server)
578+
}
579+
dns = append(dns, server)
580+
}
581+
return &guestNetworkConfig{ip: ip, mac: mac, gateway: gateway, dns: dns, prefix: prefix}, nil
558582
}
559583

560584
func guestNetworkReconfigureCommand(alloc *network.Allocation) (string, error) {
561-
cfg, err := guestNetworkReconfigureConfig(alloc)
585+
cfg, err := guestNetworkReconfigureConfig(networkConfigFromAllocation(alloc))
562586
if err != nil {
563587
return "", err
564588
}

lib/instances/restore_egress_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ func TestNetworkConfigFromAllocation_PreservesDNS(t *testing.T) {
3030
assert.Equal(t, alloc.TAPDevice, cfg.TAPDevice)
3131
}
3232

33+
func TestGuestNetworkReconfigureConfigParsesDNS(t *testing.T) {
34+
t.Parallel()
35+
36+
cfg, err := guestNetworkReconfigureConfig(&network.NetworkConfig{
37+
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",
38+
})
39+
require.NoError(t, err)
40+
assert.Equal(t, []string{"1.1.1.1", "8.8.8.8"}, cfg.dns)
41+
42+
_, err = guestNetworkReconfigureConfig(&network.NetworkConfig{
43+
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",
44+
})
45+
require.ErrorContains(t, err, "invalid DNS server")
46+
}
47+
3348
func TestGuestNetworkReconfigureCommand_AppliesAllocatedMAC(t *testing.T) {
3449
t.Parallel()
3550

lib/instances/start.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,20 @@ func (m *manager) startInstance(
223223
log.WarnContext(ctx, "failed to update metadata after VM start", "instance_id", id, "error", err)
224224
}
225225

226+
if isWindowsPlatform(stored.Platform) && netConfig != nil {
227+
networkCtx, networkSpanEnd := m.startLifecycleStep(ctx, "configure_guest_network",
228+
attribute.String("instance_id", id),
229+
attribute.String("hypervisor", string(stored.HypervisorType)),
230+
attribute.String("operation", "configure_guest_network"),
231+
)
232+
if err := reconfigureGuestNetworkConfig(networkCtx, stored, netConfig); err != nil {
233+
networkSpanEnd(err)
234+
_, _ = m.stopInstance(ctx, id)
235+
return nil, fmt.Errorf("configure Windows guest network: %w", err)
236+
}
237+
networkSpanEnd(nil)
238+
}
239+
226240
// Return instance state from current metadata without forcing a log scan.
227241
finalInst := m.toInstanceWithoutHydration(ctx, meta)
228242
// Record metrics

lib/instances/windows.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,6 @@ func validateWindowsCreate(req CreateInstanceRequest, image *images.Image, hvTyp
3434
if req.Vcpus != 0 && req.Vcpus < 2 {
3535
return fmt.Errorf("%w: Windows 11 requires at least 2 vCPUs", ErrInvalidRequest)
3636
}
37-
if req.NetworkEnabled {
38-
return fmt.Errorf("%w: Windows networking is added in the networking phase", ErrInvalidRequest)
39-
}
4037
if len(req.Volumes) != 0 || len(req.Devices) != 0 || req.GPU != nil {
4138
return fmt.Errorf("%w: Windows instances do not yet support volumes or device passthrough", ErrInvalidRequest)
4239
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//go:build linux && amd64
2+
3+
package instances
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"fmt"
9+
"net"
10+
"os"
11+
"os/exec"
12+
"testing"
13+
"time"
14+
15+
"github.com/kernel/hypeman/lib/forkvm"
16+
"github.com/kernel/hypeman/lib/guest"
17+
"github.com/kernel/hypeman/lib/hypervisor"
18+
"github.com/kernel/hypeman/lib/images"
19+
"github.com/kernel/hypeman/lib/paths"
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
)
23+
24+
func TestWindowsNetworkingIntegration(t *testing.T) {
25+
fixture := os.Getenv("HYPEMAN_WINDOWS_TEST_AGENT_PERSONA")
26+
if fixture == "" {
27+
fixture = "/ci/windows/persona-agent.qcow2"
28+
}
29+
if _, err := os.Stat(fixture); err != nil {
30+
if os.Getenv("CI") == "true" {
31+
t.Fatalf("required Windows networking fixture is missing: %s", fixture)
32+
}
33+
t.Skipf("Windows networking fixture is unavailable: %s", fixture)
34+
}
35+
acquireHeavyIO(t)
36+
37+
manager, dataDir := setupTestManagerForQEMU(t)
38+
p := paths.New(dataDir)
39+
const digestHex = "abababababababababababababababababababababababababababababababab"
40+
image := &images.Image{
41+
Name: "registry.example/windows/persona:networking-integration",
42+
Digest: "sha256:" + digestHex,
43+
Platform: "windows/amd64",
44+
Status: images.StatusReady,
45+
Machine: &images.MachineImage{
46+
Kind: images.MachineImageWindowsPersona,
47+
Base: "registry.example/windows/base@sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
48+
TPM: "2.0",
49+
SecureBoot: "required",
50+
VirtualSize: 80 << 30,
51+
},
52+
}
53+
manager.imageManager = windowsFixtureImageManager{image: image}
54+
personaPath, err := images.GetMachineDiskPath(p, image.Name, image.Digest, image.Machine)
55+
require.NoError(t, err)
56+
require.NoError(t, forkvm.CopyRegularFile(fixture, personaPath))
57+
require.NoError(t, os.Chmod(personaPath, 0444))
58+
59+
ctx := context.Background()
60+
instance, err := manager.CreateInstance(ctx, CreateInstanceRequest{
61+
Name: "windows-networking-integration",
62+
Image: image.Name,
63+
Platform: "windows/amd64",
64+
Size: 8 << 30,
65+
Vcpus: 4,
66+
NetworkEnabled: true,
67+
Hypervisor: hypervisor.TypeQEMU,
68+
})
69+
require.NoError(t, err)
70+
t.Cleanup(func() { _ = deleteTestInstanceNow(context.Background(), manager, instance.Id) })
71+
require.NotEmpty(t, instance.IP)
72+
require.NotEmpty(t, instance.MAC)
73+
74+
assertWindowsNetworkReady(t, ctx, manager, instance.Id, instance.IP)
75+
}
76+
77+
func assertWindowsNetworkReady(t *testing.T, ctx context.Context, manager *manager, instanceID, expectedIP string) {
78+
t.Helper()
79+
require.Eventually(t, func() bool {
80+
current, err := manager.GetInstance(ctx, instanceID)
81+
return err == nil && current.State == StateRunning
82+
}, 4*time.Minute, time.Second)
83+
84+
dialer, err := manager.GetVsockDialer(ctx, instanceID)
85+
require.NoError(t, err)
86+
var stdout, stderr bytes.Buffer
87+
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)
88+
exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
89+
Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command},
90+
Stdout: &stdout,
91+
Stderr: &stderr,
92+
Timeout: 30,
93+
})
94+
require.NoError(t, err, stderr.String())
95+
require.Equal(t, 0, exit.Code, stderr.String())
96+
assert.Equal(t, expectedIP, stdout.String())
97+
98+
require.Eventually(t, func() bool {
99+
conn, err := net.DialTimeout("tcp", net.JoinHostPort(expectedIP, "3389"), time.Second)
100+
if err != nil {
101+
return false
102+
}
103+
_ = conn.Close()
104+
return true
105+
}, 2*time.Minute, time.Second, "RDP did not become reachable over the allocated network")
106+
107+
ping := exec.Command("ping", "-c", "3", "-W", "2", expectedIP)
108+
require.NoError(t, ping.Run(), "allocated Windows IP did not answer ICMP")
109+
}

0 commit comments

Comments
 (0)