If you run a home lab with more than a handful of containers, you've probably had the experience of SSH-ing into what you thought was your monitoring stack and finding yourself staring at someone's media server. IP addresses are fine for machines; they're not fine for humans.
I've been running a Proxmox cluster (three nodes — Xenon, PVE8, PVE1, plus a couple of remote machines) for a couple of years now, and the container count has crept up to around 80. At some point I got tired of either memorising subnets or running nmap every time I wanted to find something. So I settled on a convention, and more recently wrote a script to enforce it.
The Convention
For containers on Xenon that need to be on the home LAN (routable across the network, rather than isolated in an Incus bridge subnet), the rule is:
LXC ID = X, IP = 10.140.3.X, where 100 < X < 254
That's it. Container 110 lives at 10.140.3.110. Container 169 lives at 10.140.3.169. If you know the ID, you know the IP. If you know the IP, you know the ID.
This sounds obvious, but there are two ways it breaks down in practice. First, Proxmox LXC IDs are cluster-wide — so ID 108 might already be taken by a container on a different node entirely (I discovered this the hard way while trying to create a new container and getting "CT 108 already exists on node 'pve8'"). Second, older containers on Xenon use a 7XXX ID scheme and their IPs are already assigned in the 10.140.3.x range. LXC 7128 lives at 10.140.3.128, so you can't pick X=128 without checking both tables.
The Script
So I wrote agents/pve_free_slots.py, which does three things:
- SSHes to Xenon and queries the Proxmox cluster API (
pvesh get /cluster/resources) to pull every VM and LXC ID across all nodes. - SSHes to the OpenWRT router to pull DHCP leases and static reservations in the 10.140.3.x subnet.
- Accounts for 7XXX and 9XXX LXCs — ID 7128 occupies IP
10.140.3.128, so X=128 is blocked even though LXC ID 128 is technically free.
A slot is only free if both the ID is unused cluster-wide and the IP is unoccupied.
#!/usr/bin/env python3
"""
Find available Proxmox LXC ID / IP slots following the convention:
LXC ID = X, IP = 10.140.3.X, 100 <= X <= 253
A slot is free when both:
- X is not in use as a cluster-wide VM/LXC ID (any node)
- 10.140.3.X is not in any DHCP lease or static reservation
Sources:
- Proxmox cluster: pvesh on Xenon (covers all nodes via cluster API)
- DHCP leases: OpenWRT router at 10.140.2.6 (/tmp/dhcp.leases + uci static)
- 7XXX LXC pattern: IDs 7100-7253 on Xenon map to IPs 10.140.3.100-253
Usage:
python3 pve_free_slots.py # show available slots
python3 pve_free_slots.py --taken # also list what's occupied
python3 pve_free_slots.py --min 150 # restrict range floor
# Author: Matthew / Claude
"""
import argparse, json, subprocess, sys
RANGE_MIN, RANGE_MAX = 100, 253
XENON_DIRECT = "root@10.140.3.82"
XENON_CF = "root@xenon-ssh.mattsouthgate.co.uk"
XENON_CF_HOSTNAME = "xenon-ssh.mattsouthgate.co.uk"
ROUTER = "root@10.140.2.6"
def ssh(target, cmd, *, proxy_hostname=None, timeout=15):
args = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "BatchMode=yes", "-o", f"ConnectTimeout={timeout}"]
if proxy_hostname:
args += ["-o", f"ProxyCommand=cloudflared access ssh --hostname {proxy_hostname}"]
args += [target, cmd]
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout + 5)
return r.stdout, r.returncode == 0
except subprocess.TimeoutExpired:
return "", False
def xenon_ssh(cmd, timeout=15):
out, ok = ssh(XENON_DIRECT, cmd, timeout=timeout)
if ok:
return out, True
return ssh(XENON_CF, cmd, proxy_hostname=XENON_CF_HOSTNAME, timeout=timeout + 10)
def get_cluster_ids():
out, ok = xenon_ssh("pvesh get /cluster/resources --type vm --output-format json")
if not ok or not out.strip():
return None, "Could not reach Xenon cluster API"
try:
return {int(r["vmid"]): f"{r.get('name','?')} ({r['node']}, {r['type']})"
for r in json.loads(out)}, None
except (json.JSONDecodeError, KeyError) as e:
return None, f"Parse error: {e}"
def get_dhcp_taken():
taken = {}
out, ok = ssh(ROUTER, "cat /tmp/dhcp.leases", timeout=8)
if ok:
for line in out.splitlines():
parts = line.split()
if len(parts) >= 3 and parts[2].startswith("10.140.3."):
x = int(parts[2].split(".")[-1])
taken[x] = f"DHCP lease ({parts[3] if len(parts) > 3 else '?'})"
out, ok = ssh(ROUTER,
"uci show dhcp 2>/dev/null | grep '\\.ip=' | grep '10\\.140\\.3\\.'", timeout=8)
if ok:
for line in out.splitlines():
if "10.140.3." in line:
ip = line.split("=")[-1].strip("'\"")
if ip.startswith("10.140.3."):
x = int(ip.split(".")[-1])
taken[x] = f"DHCP static ({ip})"
return taken, ok
(The full script with argument parsing and range notation is at agents/pve_free_slots.py.)
Test Run
Running it against the cluster today (26 June 2026, from the university — so the router was unreachable and DHCP data wasn't available, but the cluster query still worked):
Querying Proxmox cluster IDs...
81 resources across cluster
Querying OpenWRT DHCP leases (10.140.3.x)...
Router unreachable — DHCP data unavailable (may be off home LAN)
==========================================================
Free slots (ID=X, IP=10.140.3.X) range 100–253
==========================================================
110
118
122
125
129
131
133
136
139–142
145
147
151–164
166–168
171–173
175–179
181–185
188–198
202–253
105 free, 49 occupied
With --taken, the occupied section shows why each slot is blocked — whether it's an ID conflict on a specific node, or an IP conflict from a 7XXX container:
Occupied — ID conflict:
100 pialert (pve8, lxc)
101 openwrt (pve1, qemu)
102 claude-cli (pve8, lxc)
103 caddy (pve8, lxc)
...
108 smokeping (pve8, lxc) ← this is the one that bit me
...
Occupied — IP conflict only (ID is free):
114 IP taken by 7114 (paperless-ngx (xenon, lxc))
117 IP taken by 7117 (cockpit (xenon, lxc))
119 IP taken by 7119 (rtsptoweb (xenon, lxc))
...
The net result: I ran the script, picked ID 110 (first clean slot after 108), created the container (pct clone ... 110), and set the IP to 10.140.3.110. Done. No nmap, no guessing, no collisions.
The .lan Side
The IP convention handles the "where is it" problem. The hostname convention handles the "what do I call it" problem. All routable containers get a .lan DNS entry through Caddy (CT103 on PVE8), so litellm.lan resolves to 10.140.3.110. When a container moves or gets replaced, you update the Caddyfile, not every script that talks to it. The OpenWRT router handles .lan resolution for the rest of the network via dnsmasq.
Gaps and Future Work
A few things I haven't sorted yet:
- DHCP reservations aren't automated. New containers still get a DHCP lease by default — I assign the static reservation separately in OpenWRT. The script catches any conflicts, but doesn't create the reservation when a container is provisioned.
- Some containers have DHCP IPs. The Xenon vLLM container (LXC 8003) is currently at
10.140.1.166, which is fine until the lease rotates. There's an open todo to pin it. - The 7XXX naming scheme is a historical accident and I haven't migrated those containers. They work, so the motivation to touch them is low. The script handles them correctly, they just look a bit odd in the occupied list.
- PVE8 and PVE1 containers predate the convention — CT103 is Caddy at
10.140.3.156, which has nothing to do with ID 103. The convention only applies to new Xenon containers going forward.
Not a perfect system, but it does mean that when an AI agent or a slightly-sleepy human needs to create a new container, the available slots are a single command away.
I hope you find this helpful. Ta ta for now,
Matt
No comments:
Post a Comment