A lightweight Go microservice that manages a pool of IEEE 802 MAC addresses within a configurable range, with atomic JSON-file persistence.
  • Go 96.8%
  • Dockerfile 2.7%
  • Bru 0.5%
Find a file
2026-06-01 15:56:44 -07:00
cmd/server add persistence JSON store for MAC pool 2026-06-01 12:27:58 -07:00
internal add persistence JSON store for MAC pool 2026-06-01 12:27:58 -07:00
mac-pool-service initial commit 2026-05-31 22:07:50 -07:00
Dockerfile add persistence JSON store for MAC pool 2026-06-01 12:27:58 -07:00
go.mod add persistence JSON store for MAC pool 2026-06-01 12:27:58 -07:00
README.md Update README 2026-06-01 15:56:44 -07:00

MAC Allocator Microservice

Golang microservice to handle MAC address allocation.

When configuring for example a Linux router for a homelab, which may have a large number of interfaces which may occasionally change.

In order to make sure that the MAC address is constant for the Hyper-V guest, it must be assigned manually:

Set-VMNetworkAdapter `
  -VMName $VMToAssign `
  -Name $Adapter.Name `
  -StaticMacAddress 00:15:5D:D3:00:00

On the VM side, you can then associate the Hyper-V adapter with a defined systemd-networkd interface using a .link file:

# 10-ethernet.link
# ensures name remains lan0
[Match]
MACAddress = 00:15:5D:D3:00:00

[Link]
Name = lan0

Without this type of link, if you add or remove virtual network adapters in Hyper-V, the actual Hyper-V device associated with what is configured as 'lan0' in the guest's systemd-networkd configuration can and likely will change, basically breaking everything.

It is obviously inconvenient to manually keep track of which MAC addresses have been assigned already. One solution I explored was using a tool like NetBox. Eventually I might want to use it, however for this basic problem it seemed like too much.

The basic issue is a need for what is called a "single source of truth" for configuration management tools like Ansible or Terraform to be able to consult and interact with when provisioning or de-provisioning machines. You would think this kind of functionality would be considered essential enough to be built in to either the hypervisor (Hyper-V), or as a feature of the configuration management tools.

We need to be able to easily obtain a valid, unused MAC address from a pool within a specified address range, such as the Hyper-V reserved range that starts with "00:15:5D".

Likewise, if we're de-provisioning machines, we should be able to 'return' their MAC addresses to be reused.

The best I could achieve with Ansible was generating random MACs within a given range. Yes the range contains over 65K addresses, but I don't like the idea of it being random. I also investigated with Terraform and it seemed able to allocate addresses incrementally from a given base address. But neither of these systems had a mechanism for de-allocating and re-using addresses.

I decided to use Claude.ai for this project, a tool which has so far had impressive results for my projects far beyond my expectations. Not only is it helpful in generating code and documentation, it also provides a kind of pedantic or canonical best-practice guidance to projects, probably something it gleans from working with thousands of developers on thousands of projects. In other words, it not only understands what you want to achieve, it seems to be able to suggest solutions which follow established principles and best practices and even anticipates things that might otherwise be overlooked.

Claude Command

Create a golang microservice app. The app will manage a pool of MAC addresses for a configured range, such as '00:15:5D:D3:00:00'. It's API will provide the following functions:

  1. allocate a MAC - find the lowest available MAC address in the pool and allocate it
  2. revoke a MAC - revoke a MAC and place it back into the available pool
  3. status - return the status of a MAC as either available for allocation, allocated, or not in pool
  4. server status - list the allocation pool range, how many addresses are allocated, how many addresses unallocated

Response:

A lightweight Go microservice that manages a pool of Hyper-V / IEEE 802 MAC addresses within a configurable range, with atomic JSON-file persistence.

Below is the full project and documentation Claude.ai generated

Project Overview

The service manages all addresses that share the upper four octets of a given base MAC. The last two octets are the variable portion, yielding up to 65 536 addresses per pool (e.g. 00:15:5D:D3:00:00 → 00:15:5D:D3:FF:FF).

Allocation always returns the lowest available address. All operations are safe for concurrent use. State is persisted to a JSON file on every allocation or revocation using an atomic write (temp file + rename), so a crash can never leave the file corrupt.

Quick Start

mkdir mac-pool-service
cd mac-pool-service
go mod init <git URL>

Run with Go

go run ./cmd/server \
  -base-mac 00:15:5D:D3:00:00 \
  -listen :8080 \
  -store /data/mac-pool.json

Run with Docker

docker build -t mac-pool-service .

docker run -p 0.0.0.0:8112:8080 \
  -v mac-pool-data:/data \
  -e MAC_POOL_BASE=00:15:5D:D3:00:00 \
  mac-pool-service

Run tests

go test ./...

Configuration

Flag Env. variable Default Description
-base-mac MAC_POOL_BASE 00:15:5D:D3:00:00 Starting MAC address of the pool
-listen MAC_POOL_LISTEN :8080 TCP address the HTTP server binds
-store MAC_POOL_STORE /data/mac-pool.json Path to the JSON state file (empty = disabled)

Persistence

On every Allocate or Revoke, the full set of allocated addresses is written to the state file atomically:

  1. The new state is serialised to a temp file in the same directory.
  2. os.Rename replaces the live file in a single syscall — crash-safe on Linux.

On startup the file is read back and the pool is restored to its previous state. A missing file is treated as a fresh pool (no error). If the file's base_mac does not match the configured base MAC the service refuses to start.

The state file is human-readable JSON:

{
  "base_mac": "00:15:5D:D3:00:00",
  "allocated": [0, 1, 5, 255]
}

API reference

All responses are JSON. Error responses have the shape { "error": "..." }

POST /mac/allocate

  • Allocate the lowest available MAC address

Response 201 Created

{
  "mac": "00:15:5D:D3:00:00"
}

Response 409 Conflict — pool exhausted

{
  "error": "pool exhausted: no available MAC addresses"
}

Example

curl -s -X POST http://localhost:8080/mac/allocate | jq

DELETE /mac/{mac}

  • Revoke an allocated MAC address and return it to the pool.

  • Path parameter: mac — the address to revoke, e.g. 00:15:5D:D3:00:00

Response 200 OK

{
  "mac": "00:15:5D:D3:00:00",
  "message": "MAC address revoked and returned to pool"
}

Response 400 Bad Request — address not allocated or not in pool

{
  "error": "MAC 00:15:5D:D3:00:00 is not currently allocated"
}

Example

curl -s -X DELETE http://localhost:8080/mac/00:15:5D:D3:00:00 | jq

GET /mac/{mac}/status

  • Return the status of a specific MAC address.

  • Path parameter: mac — the address to query

Response 200 OK

{
  "mac": "00:15:5D:D3:00:00",
  "status": "available"
}
status value Meaning
available In the pool, not yet allocated
allocated Currently allocated
not_in_pool Outside the managed range

Example

curl -s http://localhost:8080/mac/00:15:5D:D3:00:00/status | jq

GET /status

  • Return a summary of the pool's current state.

Response 200 OK

{
  "base_mac":   "00:15:5D:D3:00:00",
  "pool_start": "00:15:5D:D3:00:00",
  "pool_end":   "00:15:5D:D3:FF:FF",
  "range_size": 65536,
  "allocated":  3,
  "available":  65533
}

Example

curl -s http://localhost:8080/status | jq

Project Structure

mac-pool-service/
├── cmd/server/main.go              # Entrypoint
├── internal/
│   ├── api/
│   │   ├── handler.go              # HTTP handlers
│   │   └── handler_test.go
│   ├── config/
│   │   └── config.go               # Flag / env config loader
│   ├── pool/
│   │   ├── pool.go                 # Thread-safe MAC pool + persistence hooks
│   │   └── pool_test.go
│   └── store/
│       ├── store.go                # Atomic JSON file read/write
│       └── store_test.go
├── Dockerfile
├── go.mod
└── README.md

Design Notes

  • Sorted free list: the available-address list is kept sorted, so Allocate is O(1) (pop front) and Revoke is O(log n) binary-search insert.

  • Concurrency: a single sync.RWMutex guards all pool state. Read-only operations (Status, Stats) acquire a read lock; mutating operations (Allocate, Revoke) acquire a write lock.

  • No external dependencies: the service uses only the Go standard library.

  • Graceful shutdown: the server listens for SIGINT/SIGTERM and drains in-flight requests with a 15-second timeout before exiting.