themsaid.com

Building an IP Address Manager in Go

2025-03-03

Recently I've been looking into managing a large network with wide ranges of IP addresses. This article explains a solution I've developed in Go to allocate unique CIDR blocks to various VPCs in the network.

IP Address Management Crash Course

IPv4 addresses are designed to uniquely identify devices on a network. They consist of four octets, with each octet comprising 8 bits, totaling 32 bits in length. These addresses are typically represented in dotted decimal notation, where each octet is separated by a period:

192.168.1.1

This IP can be represented in binary format as:

11000000.10101000.00000001.00000001

A cloud provider offers a range of IP addresses for each account, this range is usually interrupted by reserved ranges for the provider's backend operations.

For example, a provider might offer the following range:

10.0.0.010.255.255.255

And reserve some ranges in between:

10.244.0.0 - 10.244.255.255
10.245.0.0 - 10.245.255.255

A network is often divided into smaller, isolated networks known as Virtual Private Clouds (VPCs). This segmentation helps organize and secure the infrastructure by creating distinct environments within a larger network.

Each VPC serves as a private, logically isolated network where resources like virtual machines, databases, and other services can interact with one another freely and securely. Servers within the same VPC are able to communicate directly. However, the VPC's isolation prevents access from external servers or networks.

IP Ranges & CIDR Blocks

Representing IP address ranges is done through a method called CIDR (Classless Inter-Domain Routing). This representation consists of two parts:

  1. A base IP address: The starting address of the range.
  2. A prefix length: The number of bits used for the network portion of the address.

An IP range can be represented in a CIDR block format like this:

BASE_IP/PREFIX_LENGTH

For example, the range 10.0.0.0 – 10.255.255.255 can be represented as follow:

10.0.0.0/8

Here, the prefix length indicates that the first 8 bits, the first octet, is the network portion of the address. That means, all remaining bits can be used for servers on that network. In other words, this network can host a number of hosts that is equal to 2 to the power of 24 (remaining bits), which is 16,777,216.

Another example:

10.0.0.0/24 

This CIDR block has a prefix length of 24, meaning that only the last octet is available for use. As a result, the range can accommodate up to 2^8 (256) IP addresses, which can be assigned to servers or other network devices.

The first IP in the CIDR block of a sub-network, a VPC within a network, must be a multiple of its size length. For example:

This means that if we have a VPC defined with the CIDR block 10.0.0.16/28, the next VPC must begin at a boundary that aligns with its subnet size. For example, if the next VPC has a prefix length of /24, it must start at an address that is a multiple of 256. The closest valid starting address would be 10.0.1.0, as 10.0.0.17 is not a multiple of 256 and does not align with a /24 boundary.

Usable IP Addresses Within a VPC

In a VPC, as in any network, there are a few special addresses that are reserved for specific purposes. These reserved addresses are not assigned to servers or devices but have important roles in routing and network management. Two key reserved addresses are the network address and the broadcast address.

The network address is the first IP address in a VPC. It represents the entire network and is used for routing purposes, helping the network identify itself within the larger network infrastructure.

The broadcast address is the last IP address in a VPC, and is used to send a message to all devices in the network. In other words, devices within the network use the broadcast address to send messages to all other devices.

In addition to the network and broadcast addresses, cloud providers typically reserve three other addresses for internal use. As a result, the number of available IP addresses for hosts within a network is reduced by five, leaving you with the total size of the IP range minus these five reserved addresses.

Building an IP Address Manager

Taking into account the global IP range available to an account and the reserved IP ranges within the network, let's design a CIDR block manager that avoids collisions and minimizes waste by recycling freed CIDR blocks for reuse.

The net standard library package in Go ships with several types, methods, and functions that'll help us build the allocator. Let's start with the three main types we're going to use:

type IPNet struct {
	IP   IP
	Mask IPMask
}

The net.IPNet type represents an IP network and consists of two key fields: IP and Mask:

Together, these fields allow net.IPNet to represent a CIDR block, where the IP defines the network address, and the Mask specifies its size.

In addition to these types, we'll use the net.ParseCIDR() function to parse a CIDR block into a net.IPNet.

We'll also use the bigEndian type of the encoding/binary standard library package to convert IP addresses into their integer form. This makes it possible to perform arithmetic operations, such as incrementing an address to determine the next available one efficiently.

The Allocator Type

We'll begin by defining an IPAllocator type in our package:

type IPAllocator struct {
	baseIPNet      *net.IPNet
	lastIPNet      *net.IPNet
	freedBlocks    map[uint8][]string
	reservedIPNets []*net.IPNet
	mutex          sync.Mutex
}

The Constructor

We'll declare an NewIPAllocator function within the package to construct an IPAllocator instance:

func NewIPAllocator(baseBlock string, reservedBlocks []string) (*IPAllocator, error) {
	_, ipNet, err := net.ParseCIDR(baseBlock)
	if err != nil {
		return nil, fmt.Errorf("invalid base block: %v", err)
	}

	var reservedIpNets []*net.IPNet
	for _, block := range reservedBlocks {
		_, reservedIpNet, err := net.ParseCIDR(block)
		if err != nil {
			return nil, fmt.Errorf("invalid reserved block [%s]: %v", block, err)
		}
		reservedIpNets = append(reservedIpNets, reservedIpNet)
	}

	return &IPAllocator{
		baseIPNet:      ipNet,
        freedBlocks:    make(map[uint8][]string),
		reservedIPNets: reservedIpNets,
	}, nil
}

Inside the constructor, we'll parse the base CIDR block as well as the CIDR blocks of each of the reserved ranges. We'll store the former into the baseIPNet field and the latter into the reservedIPNets field.

Helper Functions

To allocate the next available block, we first need to convert IP addresses into their integer form. For this conversion, let's declare a new package function:

func ipToInt(ip net.IP) uint32 {
	return binary.BigEndian.Uint32(
		ip.To4(),
	)
}

The ipToInt function converts an IPv4 address into a 32-bit unsigned integer representation by calling the Uint32 method on the binary.BigEndian type. The method extracts each byte from the IP address and shifts it to its appropriate position within a 32-bit integer. The first byte is shifted 24 bits to the left, placing it in the most significant position. The second byte is shifted 16 bits, the third byte is shifted 8 bits, and the fourth byte remains in its original position. This ensures that each byte contributes correctly to forming the final integer representation of the IP address.

If we use 192.168.1.10 as an example, the result of the conversion will be as follows:

ip[0] => 11000000 << 24 = 11000000 00000000 00000000 00000000
ip[1] => 10101000 << 16 = 00000000 10101000 00000000 00000000
ip[2] => 00000001 << 8  = 00000000 00000000 00000001 00000000
ip[3] => 00001010       = 00000000 00000000 00000000 00001010

Thus, 192.168.1.10 is converted to its uint32 representation as 3232235786:

11000000 10101000 00000001 00001010 = 3232235786

Next, we need a function to convert an IP address from its integer form back into a byte slice. To achieve this, we'll declare an intToIP function, which reverses the operation performed by the ipToInt function. It extracts individual bytes from the integer and assigns them to their respective positions in the IP byte slice.

func intToIP(ipInt uint32) net.IP {
	buf := make([]byte, 4)

	binary.BigEndian.PutUint32(buf, ipInt)

	return buf
}

Next, we need a function that takes a net.IPNet and returns the last IP address in the network in integer form. This function will help determine the last address in the last used CIDR block or a reserved range, allowing us to identify the next available address:

func lastIpInBlockInt(block *net.IPNet) uint32 {
	prefixLength, _ := block.Mask.Size()
	capacity := 1 << (32 - prefixLength)

	return ipToInt(block.IP) + uint32(capacity) - 1
}

lastIpInBlockInt determines the capacity of the range and adds it to the first address, giving us the last IP address in that range.

Given a prefix length of 24, for example, we shift the integer 1 by 8 bits (32 - 24 = 8):

00000000000000000000000000000001 // 1
00000000000000000000000100000000 // Shift by 8 bits. Becomes 256.

Finally, we need a function that rounds an IP address up to the nearest address that aligns with the block size:

func alignIpToBlockSize(ip uint32, base uint32, size uint32) uint32 {
	offset := ip - base
	roundedOffset := ((offset + size - 1) / size) * size

	return base + roundedOffset
}

As we mentioned earlier, it's crucial to align IP addresses to the correct boundaries. The mathematical formula in this function calculates the offset of the candidate IP from the base IP (ip - base) and then rounds it up to the nearest multiple of the block size. Then, it adds the result to the base IP to get the rounded IP.

To simplify the formula, imagine we want to round 12 up to the nearest multiple of 5:

(12 + 5 - 1) / 5 = int(3.2) * 5 = 15

Another example with 18:

(18 + 5 - 1) / 5 = int(4.4) * 5 = 20

Allocating Blocks

Under the IPAllocator type, we'll declare a AllocateCIDR method:

func (a *IPAllocator) AllocateCIDR(prefixLen uint8) (string, error) {
	a.mutex.Lock()
	defer a.mutex.Unlock()

    // ...
}

This method receives a prefix length and returns the next available CIDR block in our network. We'll acquire a lock at the beginning of the method and release it once the method returns. This ensures the method is atomic.

Checking Freed Blocks

The first step is to check whether there is a previously freed block with the same prefix length that can be reused. This helps optimize IP allocation by minimizing fragmentation and reducing the need to allocate new blocks unnecessarily:

freedBlocks, ok := a.freedBlocks[prefixLen]
if ok && len(freedBlocks) > 0 {
    allocatedBlock := freedBlocks[0]
    a.freedBlocks[prefixLen] = freedBlocks[1:]

    return allocatedBlock, nil
}

If a freed block is found, we remove it from the freedBlocks list and return it.

Getting the next available IP address

Next, we'll calculate the required block size and convert the base block's IP address to an integer form:

blockSize := uint32(1 << (32 - prefixLen))

baseIPInt := ipToInt(a.baseIPNet.IP)

Then, we'll check if there were any blocks previously allocated. If that's the case, we'll call our lastIpInBlockInt helper to get the integer value of the last IP address in that block and increment it. Otherwise, if there aren't any previously allocated blocks, we'll use the base IP address:

candidateIPInt := baseIPInt

if a.lastIPNet != nil {
    candidateIPInt = lastIpInBlockInt(a.lastIPNet) + 1
}

Once we have a candidate IP, we use our alignIpToBlockSize function to round it up:

candidateIPInt = alignIpToBlockSize(candidateIPInt, baseIPInt, blockSize)

Validating the new CIDR block

Next, we will perform a couple of checks on the candidate CIDR block to:

  1. Ensure it's within the base CIDR range.
  2. Ensure it's not within any of the reserved ranges.

We'll start a for loop to run our checks:

for {
    candidateIP := intToIP(candidateIPInt)
    candidateNet := &net.IPNet{
        IP:   candidateIP,
        Mask: net.CIDRMask(int(prefixLen), 32),
    }
    candidateEndIP := intToIP(lastIpInBlockInt(candidateNet))
    
    // ...
}

At the beginning of the iteration, we'll use the candidate IP to extract:

  1. The candidate CIDR block in net.IPNet form.
  2. The last IP address of the candidate block.

Now that we have this information, we can start our first check:

// Check if within bounds
if !a.baseIPNet.Contains(candidateIP) || !a.baseIPNet.Contains(candidateEndIP) {
    return "", errors.New("allocation exceeds base CIDR range")
}

Next, we'll loop over the reserved ranges and check if the new block overlaps with any of them. If so, we skip the reserved range and generate a new candidate. Otherwise, we mark the candidate CIDR as used and return it:

for {
    candidateIP := intToIP(candidateIPInt)
    candidateNet := &net.IPNet{
        IP:   candidateIP,
        Mask: net.CIDRMask(int(prefixLen), 32),
    }
    candidateEndIP := intToIP(lastIpInBlockInt(candidateNet))
    
    // ...

    skip := false

    for _, reservedIPNet := range a.reservedIPNets {
        if reservedIPNet.Contains(candidateIP) || reservedIPNet.Contains(candidateEndIP) {
            candidateIPInt = lastIpInBlockInt(reservedIPNet) + 1
            candidateIPInt = alignIpToBlockSize(candidateIPInt, baseIPInt, blockSize)

            skip = true

            break
        }
    }

    if skip {
        continue
    }

    a.lastIPNet = candidateNet

    return candidateNet.String(), nil
}

These two nested for loops ensure that we keep looking for a candidate that doesn't overlap with a reserved IP range and doesn't exceed the base block. If the candidate overlaps with a reserved range, we skip the range and generate a new candidate that's rounded up to the nearest block size using the formula we explained earlier.

Releasing Blocks

The final part in this package is to add a method that we can use to release a CIDR block back to the available range. This method will extract the prefix length from a given block and use it to place the released CIDR in the freedBlocks map.

func (a *IPAllocator) ReleaseCIDR(block string) error {
	a.mutex.Lock()
	defer a.mutex.Unlock()

	_, ipNet, err := net.ParseCIDR(block)
	if err != nil {
		return fmt.Errorf("failed to parse CIDR block %s: %v", block, err)
	}

	prefixLen, _ := ipNet.Mask.Size()

	if a.freedBlocks[uint8(prefixLen)] == nil {
		a.freedBlocks[uint8(prefixLen)] = []string{}
	}

	a.freedBlocks[uint8(prefixLen)] = append(a.freedBlocks[uint8(prefixLen)], block)

	return nil
}

Wrapping Up

In a real-world scenario, the allocator relies on a database to store and retrieve information about the last allocated IP address and the freed addresses. This persistence ensures that the allocator continues to function correctly even after system restarts, preventing data loss and maintaining consistency.

Additionally, various methods are available to track and manage IP allocation efficiently. These include retrieving the total number of available IP addresses, identifying freed (previously used but now available) addresses, and monitoring currently allocated addresses. Beyond these, the allocator also provides other useful metrics, such as utilization rates, fragmentation levels, and allocation history, which help optimize resource management and troubleshooting.

Developing this solution in Go has been an enjoyable experience, particularly thanks to the powerful net and encoding/binary standard library packages. The ability to work with the uint32 type and perform bitwise operations has made tasks like IP manipulation and CIDR block calculations both efficient and intuitive. These built-in features have greatly streamlined the implementation, giving me firsthand experience of why Go is widely regarded as an excellent choice for network-related programming.

Hi, I'm Mohamed Said.

I'm a software engineer, writer, and open-source contributor. You can reach me on Twitter/X @themsaid. Or send me an email at themsaid@gmail.com.