- Go 96.8%
- Dockerfile 2.7%
- Bru 0.5%
| cmd/server | ||
| internal | ||
| mac-pool-service | ||
| Dockerfile | ||
| go.mod | ||
| README.md | ||
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:
- allocate a MAC - find the lowest available MAC address in the pool and allocate it
- revoke a MAC - revoke a MAC and place it back into the available pool
- status - return the status of a MAC as either available for allocation, allocated, or not in pool
- 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:
- The new state is serialised to a temp file in the same directory.
os.Renamereplaces 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
Allocateis O(1) (pop front) andRevokeis O(log n) binary-search insert. -
Concurrency: a single
sync.RWMutexguards 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/SIGTERMand drains in-flight requests with a 15-second timeout before exiting.