clusterbook is a GitOps-friendly IP Address Management (IPAM) service for Kubernetes clusters. It tracks IP pools in YAML or a Kubernetes CRD, exposes them over gRPC / REST / HTMX, and can wire allocations to DNS (PowerDNS or DD-WRT) and leased lifecycles with auto-reclaim. A Crossplane provider is included for declarative, CR-driven IP reservations.
| Feature | Description |
|---|---|
| IP Address Management | Allocate and track IPs across Kubernetes clusters |
| CIDR-aware Allocation | Define pools as CIDR ranges (e.g. 10.31.103.0/24) with auto-expansion |
| Auto-assignment | /reserve endpoint picks the next free IP in a pool |
| IP Leases & Auto-reclaim | Optional TTL on assignments — expired leases are reclaimed in the background (with DNS cleanup) |
| gRPC API | Programmatic access on port :50051 |
| REST API | JSON endpoints on port :8080 |
| HTMX Dashboard | Web UI for IP pool visualization and management |
| Dual Storage | Filesystem (YAML) or Kubernetes CRD backend |
| PowerDNS Integration | Optional DNS record management for IP assignments |
| DD-WRT Integration | Optional DNS via DD-WRT router (SSH + dnsmasq) |
| Crossplane Provider | Declarative IPAssignment / Network managed resources (see provider/) |
| KCL Manifests | Type-safe Kubernetes deployment with KCL |
KCL (RECOMMENDED)
cd kcl && kcl runkcl run -D config.image=ghcr.io/stuttgart-things/clusterbook:v1.6.0 \
-D config.namespace=clusterbook \
-D config.configName=networks-labulkcl run -D config.httpRouteEnabled=true \
-D config.httpRouteParentRefName=my-gateway \
-D config.httpRouteHostname=clusterbook.example.comcd kcl && kcl run | kubectl apply -f -RUN LOCALLY
LOAD_CONFIG_FROM=disk CONFIG_LOCATION=tests CONFIG_NAME=config.yaml go run .LOAD_CONFIG_FROM=cr CONFIG_LOCATION=clusterbook CONFIG_NAME=networks-labul go run .Create a .env file (see example below), then:
task runtask run-webThis runs the web UI with disk config from tests/config.yaml on port 8080. Override defaults with:
task run-web CONFIG_LOCATION=mydir CONFIG_NAME=myconfig.yaml HTTP_PORT=9090WEB UI (HTMX)
The HTMX dashboard is available on port :8080 (configurable via HTTP_PORT).
- Dashboard:
http://localhost:8080/- overview of all network pools - Network Detail:
http://localhost:8080/network/10.31.103- IP table with inline assign/release - Health:
http://localhost:8080/health-{"status":"ok"}
Behind sthings-portal set BASE_PATH=/clusterbook. The portal forwards
/clusterbook/... unchanged; the pages answer under the prefix and resolve
their links against <base href>. /health and /api/v1 also stay at the
root, so the Crossplane provider and in-cluster clients keep their URL.
Inside the portal's frame the banner gives way to one compact line, and a
request through the portal (X-Forwarded-Prefix) links the portal's design
tokens (/static/homerun2-tokens.css). Opened on its own the page keeps its
full banner.
REST API
curl http://localhost:8080/api/v1/networkscurl http://localhost:8080/api/v1/networks/10.31.103/ipscurl -X POST http://localhost:8080/api/v1/networks/10.31.103/assign \
-H "Content-Type: application/json" \
-d '{"ip": "10.31.103.6", "cluster": "my-cluster", "status": "ASSIGNED"}'curl -X POST http://localhost:8080/api/v1/networks/10.31.103/reserve \
-H "Content-Type: application/json" \
-d '{"cluster": "my-cluster", "create_dns": true}'
# → {"ip":"10.31.103.6","digit":"6","status":"ASSIGNED","cluster":"my-cluster"}Pass ip to claim one address only if it is free. Unlike /assign, this never
overwrites:
curl -X POST http://localhost:8080/api/v1/networks/10.31.103/reserve \
-H "Content-Type: application/json" \
-d '{"cluster": "my-cluster", "ip": "10.31.103.230"}'| Answer | When |
|---|---|
200 |
The address was free and is now reserved |
409 |
Taken — the body names the current cluster and status |
404 |
Not in the pool; add "add_to_pool": true to record it anyway |
400 |
ip is not in network {key} |
/assign keeps overwriting whatever holds the address, but reports
previous_cluster/previous_status and withdraws that cluster's DNS record. It
rejects an ip outside {key}.
curl -X POST http://localhost:8080/api/v1/networks/10.31.103/assign \
-H "Content-Type: application/json" \
-d '{"ip": "10.31.103.6", "cluster": "ephemeral-cluster", "lease_duration_seconds": 3600}'curl -X POST http://localhost:8080/api/v1/networks/10.31.103/ips/10.31.103.6/renew \
-H "Content-Type: application/json" \
-d '{"lease_duration_seconds": 3600}'curl -X POST http://localhost:8080/api/v1/networks/10.31.103/release \
-H "Content-Type: application/json" \
-d '{"ip": "10.31.103.6"}'# Creates a /24 pool with 252 usable IPs (excludes .0, .1, .2, .255)
curl -X POST http://localhost:8080/api/v1/networks/cidr \
-H "Content-Type: application/json" \
-d '{"cidr": "10.31.105.0/24", "reserved": ["1", "2"]}'The existing POST /api/v1/networks endpoint also accepts CIDR:
curl -X POST http://localhost:8080/api/v1/networks \
-H "Content-Type: application/json" \
-d '{"cidr": "10.31.105.0/28"}'CIDR ranges spanning multiple /24 blocks (e.g. /23) automatically create multiple network entries.
curl -X POST http://localhost:8080/api/v1/networks/10.31.103/assign \
-H "Content-Type: application/json" \
-d '{"ip": "10.31.103.6", "cluster": "my-cluster", "status": "ASSIGNED", "create_dns": true}'Every endpoint that touches DNS (/assign, /reserve, /release, /ips/{ip} edit
and delete) reports what happened to the record:
dns |
Meaning |
|---|---|
ok |
The record was written or withdrawn on every enabled provider |
failed |
At least one provider failed — dns_error carries the detail |
skipped |
No DNS work was requested |
The IP operation itself is persisted either way, so the status code stays 200
and the DNS verdict lives in the body. If the ledger could not be saved, the
request fails instead — 500, or 409 when another writer changed the config
concurrently (retry) — and no DNS record is touched. An automated caller should check it:
{
"ip": "10.31.103.6",
"cluster": "my-cluster",
"status": "ASSIGNED:DNS",
"dns": "failed",
"dns_error": "ddwrt reload dnsmasq (nvram committed, running dnsmasq still serving old records): ..."
}A status ending in :DNS is equivalent to passing create_dns: true, and the
marker is never appended twice.
gRPC
# Reserve IPs for a cluster — picks and records them in one step
grpcurl -plaintext localhost:50051 ipservice.IpService/ReserveIpAddresses \
-d '{"countIpAddresses": 2, "networkKey": "10.31.103", "clusterName": "my-cluster"}'
# Assign specific IPs to a cluster
grpcurl -plaintext localhost:50051 ipservice.IpService/SetClusterInfo \
-d '{"ipAddressRange": "10.31.103.6", "clusterName": "my-cluster", "status": "ASSIGNED"}'
# Preview free IPs — does NOT reserve them
grpcurl -plaintext localhost:50051 ipservice.IpService/GetIpAddressRange \
-d '{"countIpAddresses": 2, "networkKey": "10.31.103"}'GetIpAddressRange only reads: two callers can receive the same addresses, and
nothing is recorded until SetClusterInfo. Use ReserveIpAddresses to allocate.
DNS records are managed over HTTP only, so a :DNS status is rejected here.
A write that loses a race against another replica (cr mode) returns ABORTED;
retry the call.
DAGGER MODULE
A Dagger module is available at stuttgart-things/dagger/clusterbook for pipeline integration.
# List all networks
dagger call list-networks --server="clusterbook.example.com:8080"
# Create network from CIDR
dagger call create-network-from-cidr --server="localhost:8080" --cidr="10.31.105.0/24" --reserved="1"
# Assign IP with DNS
dagger call assign-ip --server="localhost:8080" --network-key="10.31.103" \
--ip="10.31.103.6" --cluster="my-cluster" --status="ASSIGNED" --create-dns
# Reserve the next free IP with DNS (idempotent -- safe to re-run)
dagger call reserve-ip --server="localhost:8080" --network-key="10.31.103" \
--cluster="my-cluster" --create-dns
# Release IP
dagger call release-ip --server="localhost:8080" --network-key="10.31.103" --ip="10.31.103.6"
# List clusters
dagger call list-clusters --server="localhost:8080"CLI (machineshop)
machineshop get \
--system=ips \
--destination=clusterbook.172.18.0.5.nip.io \
--path=10.31.103 \
--output=2machineshop push \
--target=ips \
--destination=clusterbook.172.18.0.5.nip.io \
--artifacts="10.31.103.9;10.31.103.10" \
--assignee=app1CREATE CR
kubectl apply -f - <<EOF
---
apiVersion: github.stuttgart-things.com/v1
kind: NetworkConfig
metadata:
name: networks-labul
namespace: clusterbook
spec:
networks:
10.31.101:
- 6:ASSIGNED:rahul-andre-rke2
- "7"
- "9"
- "10"
- 5:ASSIGNED:rancher-mgmt
10.31.102:
- "5"
- "6"
- "7"
- 8:ASSIGNED:unknown
- "9"
- "10"
10.31.103:
- 4:ASSIGNED:homerun-int2
- 5:ASSIGNED:labul-automation
- 6:ASSIGNED:labul-automation
- "17"
- "18"
- 19:ASSIGNED:labul-automation
- 8:ASSIGNED:fluxdev-3
- 9:ASSIGNED:fluxdev-3
- 16:ASSIGNED:homerun-dev
10.31.104:
- "5"
- "6"
- "7"
- "8"
- "9"
- "10"
EOF| Environment Variable | Description | Default |
|---|---|---|
LOAD_CONFIG_FROM |
Config source: disk or cr |
- |
CONFIG_LOCATION |
File path or K8s namespace | - |
CONFIG_NAME |
File name or resource name | - |
SERVER_PORT |
gRPC server port | 50051 |
HTTP_PORT |
HTTP/HTMX server port | 8080 |
BASE_PATH |
Prefix of web UI and API behind sthings-portal, e.g. /clusterbook (KCL config.basePath); /health and /api/v1 also stay at the root |
- |
RECLAIMER_INTERVAL |
Lease reclaimer scan interval (Go duration, e.g. 60s, 5m). Set 0 to disable. |
60s |
KUBECONFIG |
K8s config path (for CR backend) | - |
PDNS_ENABLED |
Enable PowerDNS integration | false |
PDNS_URL |
PowerDNS API URL | - |
PDNS_TOKEN |
PowerDNS API token | - |
PDNS_ZONE |
PowerDNS zone for records | - |
DDWRT_ENABLED |
Enable DD-WRT DNS integration | false |
DDWRT_HOST |
DD-WRT router IP/hostname | - |
DDWRT_USER |
SSH user for DD-WRT | - |
DDWRT_PASSWORD |
SSH password for DD-WRT | - |
DDWRT_ZONE |
DNS zone (e.g. sthings.lab) |
- |
Both DNS providers are optional and can run simultaneously. Enable them via env vars. Records are created/deleted when create_dns: true is passed during assign/release.
A provider failure never fails the IP operation silently — the response carries a
dns field stating whether the record actually landed.
POWERDNS
Creates wildcard A records (*.cluster.zone) via the PowerDNS REST API.
PDNS_ENABLED=true
PDNS_URL=http://pdns.sthings.lab:8081
PDNS_TOKEN=your-api-key
PDNS_ZONE=sthings.lab.DD-WRT
Manages dnsmasq address entries on a DD-WRT router via SSH + nvram. Creates entries like address=/cluster.zone/ip.
DDWRT_ENABLED=true
DDWRT_HOST=192.168.1.1
DDWRT_USER=root
DDWRT_PASSWORD=your-router-password
DDWRT_ZONE=sthings.labCredential handling in Kubernetes:
apiVersion: v1
kind: Secret
metadata:
name: clusterbook-ddwrt
type: Opaque
stringData:
DDWRT_ENABLED: "true"
DDWRT_HOST: "192.168.1.1"
DDWRT_USER: "root"
DDWRT_PASSWORD: "your-router-password"
DDWRT_ZONE: "sthings.lab"Reference in the deployment:
envFrom:
- secretRef:
name: clusterbook-ddwrtThe provider/ directory contains a Crossplane provider that wraps clusterbook's REST API as declarative managed resources. Point a ProviderConfig at a running clusterbook instance, then create Network and IPAssignment CRs to drive pool creation and IP allocation from Git.
Its controller image is ghcr.io/stuttgart-things/clusterbook-provider, built with
task provider-build-ko (tagged with the repo version and latest). Not to be
confused with stuttgart-things/provider-clusterbook,
a separate provider (IPReservation) published as ghcr.io/stuttgart-things/provider-clusterbook.
EXAMPLE
apiVersion: clusterbook.stuttgart-things.com/v1alpha1
kind: ProviderConfig
metadata:
name: default
spec:
url: http://clusterbook.clusterbook-system:8080
---
apiVersion: clusterbook.stuttgart-things.com/v1alpha1
kind: IPAssignment
metadata:
name: my-cluster-ip
spec:
forProvider:
networkKey: "10.31.103"
clusterName: "my-cluster"
createDNS: true
providerConfigRef:
name: defaultAssignments can carry a TTL via lease_duration_seconds on /assign or /reserve. A background reclaimer periodically scans for expired leases, frees the IPs, and deletes any associated DNS records. The interval is controlled by RECLAIMER_INTERVAL (default 60s; 0 disables the reclaimer). Leases can be extended via the /renew endpoint.
task: Available tasks for this project:
* branch: Create branch from main
* build: Install
* build-ko: Build image w/ KO
* commit: Commit + push code into branch
* lint: Lint Golang
* pr: Create pull request into main
* proto: Generate Go code from proto
* run: Run
* test: Test codePatrick Hermann, stuttgart-things 09/2024ENV FILE
.env file needed for Taskfile
cat <<EOF > .env
#LOAD_CONFIG_FROM=disk
#CONFIG_LOCATION=tests
#CONFIG_NAME=config.yaml
LOAD_CONFIG_FROM=cr
CONFIG_LOCATION=clusterbook #namespace
CONFIG_NAME=networks-labul #resource-name
SERVER_PORT=50051
HTTP_PORT=8080
# DD-WRT DNS (optional)
#DDWRT_ENABLED=true
#DDWRT_HOST=192.168.1.1
#DDWRT_USER=root
#DDWRT_PASSWORD=secret
#DDWRT_ZONE=sthings.lab
#CLUSTERBOOK_SERVER=localhost:50051
#SECURE_CONNECTION=false
CLUSTERBOOK_SERVER=clusterbook.rke2.sthings-vsphere.labul.sva.de:443
SECURE_CONNECTION=true
EOFLicensed under the Apache License, Version 2.0 (the "License").
You may obtain a copy of the License at apache.org/licenses/LICENSE-2.0.
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" basis, without WARRANTIES or conditions of any kind, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
