diff --git a/lib/instances/restore.go b/lib/instances/restore.go index d848ee48..9f1a3034 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -379,7 +379,7 @@ func (m *manager) restoreFromSnapshot( } func reconfigureGuestNetwork(ctx context.Context, stored *StoredMetadata, alloc *network.Allocation) error { - prefix, err := netmaskToPrefix(alloc.Netmask) + cmd, err := guestNetworkReconfigureCommand(alloc) if err != nil { return err } @@ -389,11 +389,6 @@ func reconfigureGuestNetwork(ctx context.Context, stored *StoredMetadata, alloc return fmt.Errorf("create vsock dialer: %w", err) } - cmd := fmt.Sprintf( - "ip -4 addr flush dev eth0 scope global && ip addr add %s/%d dev eth0 && ip link set dev eth0 up && ip route replace default via %s dev eth0", - alloc.IP, prefix, alloc.Gateway, - ) - var stdout, stderr bytes.Buffer exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ Command: []string{"sh", "-c", cmd}, @@ -411,6 +406,49 @@ func reconfigureGuestNetwork(ctx context.Context, stored *StoredMetadata, alloc return nil } +func guestNetworkReconfigureCommand(alloc *network.Allocation) (string, error) { + if alloc == nil { + return "", fmt.Errorf("missing network allocation") + } + ip := strings.TrimSpace(alloc.IP) + if ip == "" { + return "", fmt.Errorf("missing network allocation IP") + } + mac := strings.ToLower(strings.TrimSpace(alloc.MAC)) + if mac == "" { + return "", fmt.Errorf("missing network allocation MAC") + } + if _, err := net.ParseMAC(mac); err != nil { + return "", fmt.Errorf("invalid network allocation MAC %q: %w", alloc.MAC, err) + } + gateway := strings.TrimSpace(alloc.Gateway) + if gateway == "" { + return "", fmt.Errorf("missing network allocation gateway") + } + prefix, err := netmaskToPrefix(alloc.Netmask) + if err != nil { + return "", err + } + + return fmt.Sprintf( + // Bring eth0 down so Linux permits changing the interface MAC. + "ip link set dev eth0 down && "+ + // Replace the snapshotted MAC with the MAC allocated for this fork. + "ip link set dev eth0 address %s && "+ + // Remove the snapshotted IPv4 address from the source/starter guest. + "ip -4 addr flush dev eth0 scope global && "+ + // Add the IPv4 address allocated for this fork. + "ip addr add %s/%d dev eth0 && "+ + // Bring the interface back up after applying the new identity. + "ip link set dev eth0 up && "+ + // Ensure outbound traffic uses the fork's allocated gateway. + "ip route replace default via %s dev eth0 && "+ + // Drop snapshotted ARP/neighbor entries so peers are rediscovered. + "(ip neigh flush dev eth0 || true)", + mac, ip, prefix, gateway, + ), nil +} + func networkConfigFromAllocation(alloc *network.Allocation) *network.NetworkConfig { if alloc == nil { return nil diff --git a/lib/instances/restore_egress_test.go b/lib/instances/restore_egress_test.go index 3d4423f9..f65b0eb1 100644 --- a/lib/instances/restore_egress_test.go +++ b/lib/instances/restore_egress_test.go @@ -30,6 +30,38 @@ func TestNetworkConfigFromAllocation_PreservesDNS(t *testing.T) { assert.Equal(t, alloc.TAPDevice, cfg.TAPDevice) } +func TestGuestNetworkReconfigureCommand_AppliesAllocatedMAC(t *testing.T) { + t.Parallel() + + alloc := &network.Allocation{ + IP: "10.102.146.62", + MAC: "02:00:00:85:17:c8", + Gateway: "10.102.0.1", + Netmask: "255.255.0.0", + } + + cmd, err := guestNetworkReconfigureCommand(alloc) + require.NoError(t, err) + assert.Contains(t, cmd, "ip link set dev eth0 down") + assert.Contains(t, cmd, "ip link set dev eth0 address 02:00:00:85:17:c8") + assert.Contains(t, cmd, "ip addr add 10.102.146.62/16 dev eth0") + assert.Contains(t, cmd, "ip route replace default via 10.102.0.1 dev eth0") + assert.Contains(t, cmd, "(ip neigh flush dev eth0 || true)") + assert.NotContains(t, cmd, "cat /sys/class/net/eth0/address") +} + +func TestGuestNetworkReconfigureCommand_RequiresAllocatedMAC(t *testing.T) { + t.Parallel() + + _, err := guestNetworkReconfigureCommand(&network.Allocation{ + IP: "10.102.146.62", + Gateway: "10.102.0.1", + Netmask: "255.255.0.0", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing network allocation MAC") +} + func TestRequiresRestoreConfigDiskRefresh(t *testing.T) { t.Parallel() diff --git a/lib/network/README.md b/lib/network/README.md index 63c6e4d0..5e7bb887 100644 --- a/lib/network/README.md +++ b/lib/network/README.md @@ -194,7 +194,7 @@ sudo setcap 'cap_net_admin,cap_net_bind_service=+eip' /path/to/hypeman 1. Get default network details 2. Check name uniqueness globally 3. Allocate next available IP (starting from .2, after gateway at .1) -4. Generate MAC (02:00:00:... format - locally administered) +4. Generate unused MAC (02:00:00:... format - locally administered) 5. Generate TAP name (tap-{first8chars-of-instance-id}) 6. Create TAP device and attach to bridge diff --git a/lib/network/allocate.go b/lib/network/allocate.go index e85a8e65..8f6badfc 100644 --- a/lib/network/allocate.go +++ b/lib/network/allocate.go @@ -49,10 +49,10 @@ func (m *manager) CreateAllocation(ctx context.Context, req AllocateRequest) (*N return nil, fmt.Errorf("allocate IP: %w", err) } - // 3. Generate MAC (02:00:00:... format - locally administered) - mac, err := generateMAC() + // 3. Generate unused MAC (02:00:00:... format - locally administered) + mac, err := m.allocateUniqueMAC(ctx) if err != nil { - return nil, fmt.Errorf("generate MAC: %w", err) + return nil, fmt.Errorf("allocate MAC: %w", err) } // 4. Generate TAP name (tap-{first8chars-of-id}) @@ -312,6 +312,51 @@ func incrementIP(ip net.IP, n int) net.IP { return result } +const ( + macAllocationRandomAttempts = 5 + macSuffixSpace = 1 << 24 +) + +// allocateUniqueMAC picks an unused locally administered MAC address. +func (m *manager) allocateUniqueMAC(ctx context.Context) (string, error) { + allocations, err := m.ListAllocations(ctx) + if err != nil { + return "", fmt.Errorf("list allocations: %w", err) + } + + usedMACs := make(map[string]bool) + for _, alloc := range allocations { + mac := strings.ToLower(strings.TrimSpace(alloc.MAC)) + if mac != "" { + usedMACs[mac] = true + } + } + + return allocateUniqueMACFromSet(usedMACs, generateMAC) +} + +func allocateUniqueMACFromSet(usedMACs map[string]bool, generate func() (string, error)) (string, error) { + for attempt := 0; attempt < macAllocationRandomAttempts; attempt++ { + mac, err := generate() + if err != nil { + return "", err + } + mac = strings.ToLower(strings.TrimSpace(mac)) + if !usedMACs[mac] { + return mac, nil + } + } + + for suffix := 0; suffix < macSuffixSpace; suffix++ { + mac := formatMACSuffix(suffix) + if !usedMACs[mac] { + return mac, nil + } + } + + return "", fmt.Errorf("no available MAC addresses after %d random attempts and full scan", macAllocationRandomAttempts) +} + // generateMAC generates a random MAC address with local administration bit set func generateMAC() (string, error) { // Generate 6 random bytes @@ -331,6 +376,11 @@ func generateMAC() (string, error) { buf[0], buf[1], buf[2], buf[3], buf[4], buf[5]), nil } +func formatMACSuffix(suffix int) string { + return fmt.Sprintf("02:00:00:%02x:%02x:%02x", + byte(suffix>>16), byte(suffix>>8), byte(suffix)) +} + // saveClassID persists the tc class ID for an instance so it survives restarts. func (m *manager) saveClassID(instanceID, classID string) error { return os.WriteFile(m.paths.InstanceDir(instanceID)+"/classid", []byte(classID), 0644) diff --git a/lib/network/manager_test.go b/lib/network/manager_test.go index 8b4b708a..8d21226d 100644 --- a/lib/network/manager_test.go +++ b/lib/network/manager_test.go @@ -28,6 +28,44 @@ func TestGenerateMAC(t *testing.T) { } } +func TestAllocateUniqueMACFromSetRetriesCollisions(t *testing.T) { + used := map[string]bool{ + "02:00:00:00:00:01": true, + } + candidates := []string{ + "02:00:00:00:00:01", + "02:00:00:00:00:02", + } + calls := 0 + + mac, err := allocateUniqueMACFromSet(used, func() (string, error) { + candidate := candidates[calls] + calls++ + return candidate, nil + }) + + require.NoError(t, err) + assert.Equal(t, "02:00:00:00:00:02", mac) + assert.Equal(t, 2, calls) +} + +func TestAllocateUniqueMACFromSetFallsBackToSequentialScan(t *testing.T) { + used := map[string]bool{ + "02:00:00:00:00:00": true, + "02:00:00:00:00:01": true, + } + calls := 0 + + mac, err := allocateUniqueMACFromSet(used, func() (string, error) { + calls++ + return "02:00:00:00:00:01", nil + }) + + require.NoError(t, err) + assert.Equal(t, "02:00:00:00:00:02", mac) + assert.Equal(t, macAllocationRandomAttempts, calls) +} + func TestGenerateTAPName(t *testing.T) { tests := []struct { name string