Pushing routes per client: iroutes, MAC maps, and virtual subnets
Pushing routes per client: iroutes, MAC maps, and virtual subnets
A site-to-site client needs its subnet routed back through the tunnel. iroute, a MAC-to-router map, and a virtual-subnet table are the three records that make per-client topology declarative.
The decision this framework is for
You run a VPN that connects branch routers, not just laptops. Each branch sits behind a small router that advertises a downstream LAN — 10.8.23.0/27, 192.168.1.0/27, whatever the site happens to use. Other peers on the VPN need to reach those LANs, which means OpenVPN has to learn, per connector, "traffic for this subnet exits through that client's tunnel." In OpenVPN terms that is an iroute, and the classic way to set one is a per-client CCD file on disk.
CCD files do not scale when topology changes at runtime. An operator pins a router through a web UI, a branch reboots and re-advertises a LAN that collides with the VPN pool, two sites independently claim 192.168.1.0/27. A file-on-disk approach turns every one of those into an SSH session and a service reload. The decision this framework answers is narrow: where does per-client routing topology live so that the connect path can read it, the admin tool can write it, and a restart can rebuild it without anyone editing a file?
In vpn-control-plane — a Node + TypeScript control plane bridging the OpenVPN Management Interface — the answer is rows in three tables. Not config files, not env vars. Rows.
The framework
Call it the three-table routing model. Per-client topology decomposes into three independent records, each with a single owner and a single trigger:
- The iroute table — what LANs does this connector advertise? One CN, many subnets. Written by the admin tool, read on connect.
- The MAC→router map — which client tunnel did this MAC address arrive through? Learned from OpenVPN's
--learn-addresshook, wiped every boot. - The virtual-subnet registry — when an advertised LAN would collide with something already on the VPN, what stand-in CIDR do we hand out instead? Allocated once, sticky forever.
The fourth piece is not a table at all — it is a pure function that turns a VPN IP into the subnet block it belongs to, so the connect handler can decide who is allowed to see whose routes. The framework's claim is that if you separate these four concerns cleanly, the connect handler stays a reader, the admin UI stays a writer, and nothing on disk needs editing to reshape the network.
The discipline that makes this work is ownership. Each table has exactly one writer and one read trigger, and they do not overlap. The iroute table is written by the admin tool and read on connect; the MAC map is written by a kernel hook and read by the live forwarding index; the virtual-subnet registry is written by the collision resolver and read by both the connect path and the boot-time NAT reconciler. When you can name the single writer of a piece of state, you can reason about what a restart loses and what it keeps — and that is the whole reason topology lives in rows here instead of in a directory of files no single component owns.
Each step with one paragraph of explanation
The iroute table is plural-per-CN and idempotent. A single branch router can advertise more than one downstream LAN, so the topology cannot live as a column on the allocation row — it needs its own table keyed on (cn, network). In vpn-control-plane that table is DevClientIroute, and the wrapper treats the operator's input as the source of truth rather than an append-only log:
// src/decision/devClientIroutes.ts
export interface Iroute {
network: string;
netmask: string;
}
export async function replaceIroutesForCn(cn: string, iroutes: Iroute[]): Promise<void> {
const db = await getDb();
const now = Date.now();
await db.$transaction(async (tx) => {
await tx.devClientIroute.deleteMany({ where: { cn } });
if (iroutes.length === 0) return;
await tx.devClientIroute.createMany({
data: iroutes.map((r) => ({ cn, network: r.network, netmask: r.netmask, createdAt: now })),
});
});
}
replaceIroutesForCn deletes the CN's existing rows and re-inserts the supplied set inside one transaction. An empty list clears all rows. That is the whole contract: the admin textarea is what's true, and re-submitting it converges to that state regardless of what was there before. No diffing, no merge logic, no "did I already add this route" check. On connect, the decision engine calls listIroutesForCn(cn) for any source=static allocation and emits the iroute <network> <netmask> lines through the management interface — the same effect a CCD file would have had, minus the file.
The MAC→router map is ephemeral by design. A MAC-to-CN mapping is only valid while the OpenVPN session that produced it is alive, so persisting it across restarts would be a lie. The table records pairs learned from OpenVPN's hook and is wiped on every boot:
// src/dhcp/MacRouterMap.ts
export class MacRouterMap {
async apply(action: LearnAction, mac: string, cn: string, instanceId: string): Promise<MacRouterEntry | null> {
const m = normalize(mac);
if (!m) throw new Error(`MacRouterMap: invalid MAC ${mac}`);
const db = await getDb();
if (action === 'delete') {
await db.macRouterMap.deleteMany({ where: { mac: m } });
return null;
}
const now = Date.now();
await db.macRouterMap.upsert({
where: { mac: m },
create: { mac: m, cn, instanceId, lastSeen: now },
update: { cn, instanceId, lastSeen: now },
});
return { mac: m, cn, instanceId, lastSeen: now };
}
}
The hook fires with add, update, or delete, and apply upserts or removes a single row keyed on the MAC. The map answers one question at runtime: a frame for MAC aa:bb:... showed up — which connector tunnel does it belong behind? On disconnect, deleteByCn(cn) drops every MAC that was reachable through the departing router. The table is intentionally not authoritative topology; it is a live index that the boot sequence resets to empty, because a MAC learned in a previous session tells you nothing about the current one.
The virtual-subnet registry resolves collisions stickily. Two failure modes break a naive iroute push. First, a branch LAN that overlaps the VPN pool — push 10.8.23.0/27 to every peer while the pool itself covers 10.8/16, and you shadow real pool users. Second, two different sites that both advertise 192.168.1.0/27. OpenVPN cannot hold two connectors fighting for the same prefix. The registry mints a stand-in CIDR from carrier-grade NAT space and records it durably so other clients' routing tables never churn on a reconnect.
The subnet-block function decides visibility. Whether peer A should even see peer B's routes depends on which subnet block they share. That is a pure calculation, covered below.
Walk the framework through a real artifact in the target repo
The collision resolver is where the framework earns its keep. VirtualCidrAllocator.resolveOwnerCidr decides what a connector should actually advertise for a given LAN:
// src/ipam/VirtualCidrAllocator.ts
async resolveOwnerCidr(cn: string, realCidr: string, isPoolOverlap: boolean): Promise<string> {
const db = await getDb();
// Sticky path — this CN already has a verdict for this realCidr.
const existing = await db.virtualSubnet.findUnique({
where: { cn_realCidr: { cn, realCidr } },
select: { virtualCidr: true },
});
if (existing) return existing.virtualCidr;
if (isPoolOverlap) {
// Pool-overlap LAN — mint a virtual unconditionally.
return this.getOrAllocate(cn, realCidr);
}
// Pool-out LAN — first-claim wins. Look for any prior owner of this realCidr.
const priorClaim = await db.virtualSubnet.findFirst({
where: { realCidr },
select: { cn: true, virtualCidr: true },
});
if (priorClaim) {
return this.getOrAllocate(cn, realCidr);
}
// First claim — record a sentinel row where virtualCidr === realCidr.
await db.virtualSubnet.create({
data: { cn, realCidr, virtualCidr: realCidr, allocatedAt: Date.now() },
});
return realCidr;
}
Three branches, one table. If (cn, realCidr) already has a verdict, return it — that is the stickiness guarantee, and it is why a router reconnecting does not reshuffle anyone else's routes. If the LAN overlaps the pool, mint a virtual unconditionally. If it does not overlap, first-claim wins: the first connector to advertise 192.168.1.0/27 gets a sentinel row where the virtual equals the real, and any second comer gets a freshly allocated stand-in. OpenVPN never sees two connectors claiming the same prefix because the table arbitrates before the push ever happens.
The allocation itself walks CGN space — RFC 6598's 100.64.0.0/10 — in fixed /27 carve-outs:
// src/ipam/VirtualCidrAllocator.ts
const CGN_BASE = '100.64.0.0';
const CGN_PREFIX = 10; // 100.64.0.0/10 — 4M IPs
const PER_TENANT_PREFIX = 27; // /27 carve-outs (32 addrs each)
const PER_TENANT_BLOCK = 1 << (32 - PER_TENANT_PREFIX); // 32 addrs
async getOrAllocate(cn: string, realCidr: string): Promise<string> {
// ...
const used = new Set<number>();
const rows = await db.virtualSubnet.findMany({ select: { virtualCidr: true } });
for (const r of rows) used.add(ipToInt(r.virtualCidr.split('/')[0]!));
const baseInt = ipToInt(CGN_BASE);
const totalBlocks = (1 << (32 - CGN_PREFIX)) / PER_TENANT_BLOCK; // 131072
let pickedInt: number | null = null;
for (let i = 0; i < totalBlocks; i++) {
const candidate = baseInt + i * PER_TENANT_BLOCK;
if (!used.has(candidate)) { pickedInt = candidate; break; }
}
// ... persist (cn, realCidr, virtualCidr) and return
}
It loads every virtual CIDR currently in virtual_subnets, builds a Set of used block bases, and linearly scans the 131,072 available /27 blocks for the first free one. The result is written back with allocatedAt so a restart can reconcile the server's NAT rules straight from the table. That /27 sizing is deliberate and recent: the project rewrote its subnet model from /24 to /27 blocks in commit 6979b2e ("/24 → /27 subnet grubu, NetworkRegistry saf hesaplama olarak yeniden yaz"), shrinking each carve-out from 256 to 32 addresses and turning NetworkRegistry into pure calculation in the same move.
That rewrite is the fourth piece — the visibility function:
// src/networks/NetworkRegistry.ts
export class NetworkRegistry {
private readonly mask: number;
constructor(private readonly subnetPrefix: number) {
this.mask = (-1 << (32 - subnetPrefix)) >>> 0;
}
lookupByIp(ip: string): string {
const base = (ipToInt(ip) & this.mask) >>> 0;
return `${intToIp(base)}/${this.subnetPrefix}`;
}
}
No database, no cache, no I/O — just a mask and an AND. Given a VPN IP it returns the canonical block CIDR, e.g. 10.8.1.32/27, and the connect handler uses that to scope route pushes so only peers in the same /27 see each other's downstream LANs. The test file pins the behavior across /24, /27, and /30 prefixes and asserts the obvious invariants: 10.8.1.31 and 10.8.1.32 land in different blocks, the same input always yields the same output. Making it a pure function means the visibility rule is unit-testable without a fixture database, and the block size is a single constructor argument rather than a constant smeared across the codebase. That is the entire payoff of the /24 → /27 rewrite: the boundary became data, not code.
Where the framework fails
The honest edges. The MAC→router map being wiped on every boot is correct for session validity but means there is a window after restart where you know an allocation exists but not which live tunnel reaches a given MAC, until the --learn-address hooks re-fire. For a control plane that is fine; for anything trying to make a forwarding decision in that window it is a gap.
The virtual-subnet first-claim rule is first-come-first-served, and "first" is whoever connected first, not whoever should own the LAN. Two legitimately separate sites that both use 192.168.1.0/27 are handled — the second gets a virtual — but there is no notion of priority or operator override baked into resolveOwnerCidr. If the wrong site claims the real CIDR first, fixing it means releasing the sentinel row, not flipping a flag.
The linear scan in getOrAllocate walks up to 131,072 blocks and loads every existing virtual into memory on each allocation. At the scale this control plane targets — branch routers, not millions of tenants — that is a non-issue. At four-million-IP saturation it would be a full table read per allocate. The code names the exhaustion case explicitly (CGN range exhausted) but does not index or paginate the free-block search, because it does not need to yet. Knowing that ceiling exists is the point; pretending the scan is free would be the mistake.
And the NetworkRegistry purity that makes visibility testable also means it holds no state about which blocks are claimed — it only computes which block an IP falls into. Claiming is the iroute and virtual-subnet tables' job. Conflate the two and you get a registry that both calculates and persists, which is exactly what the rewrite tore apart.
Trade-off
Putting topology in rows instead of CCD files buys runtime mutability and a clean reconcile-on-boot story, and it costs you the filesystem's free durability and OpenVPN's native understanding of those files. The control plane now has to translate every row into a management-interface command and own the lifecycle — write on generate, read on connect, clear on revoke, reset the ephemeral pieces on boot. That is more moving parts than a directory of text files. The bet is that a network whose shape changes from a web UI is worth the translation layer, and for a multi-site VPN with operators pinning routers at runtime, it is. For a static three-site mesh that never changes, CCD files would be less code and you would not need this framework at all.
Business impact
Reshaping the network stops being an SSH-and-reload chore and becomes a database write an operator can make from a panel. A new branch's LAN gets routed by submitting a form; a collision with the pool resolves itself by minting a virtual CIDR no human picked; a restart rebuilds NAT rules from virtual_subnets instead of from someone's memory. For a small team running a multi-tenant VPN, that is the difference between topology changes that need an engineer on a terminal and topology changes a support person can do safely — and between a reboot that loses state and one that reconstructs it deterministically.
What to do next
If you have per-client routing living in CCD files today, pick one question to answer on paper before touching code: when two clients advertise the same subnet, who wins, and is that decision a row you can change or a file you have to edit? The shape of that answer tells you whether you need the three-table model or whether your topology is static enough to leave alone. If you want a concrete starting point, the (cn, network) composite key on the iroute table and the (cn, realCidr) key on the virtual-subnet table are the two schema decisions everything else hangs off — copy those keys, and the idempotent-replace and sticky-allocate behaviors follow almost for free.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox