Skip to content

A DHCPv4 server that only wakes up in bridged TAP mode

11/27/2025Backend DevelopmentOpenVPN Control Plane12 min read

A DHCPv4 server that only wakes up in bridged TAP mode

Routed TUN clients get their address pushed. Bridged TAP clients expect DHCP on the wire. One module, conditionally bound to UDP/67, handles the mode the daemon cannot.

The output the reader sees from the outside

From the outside, a VPN control plane looks like one process that hands out IP addresses. It does, but it does it two completely different ways depending on how the tunnel is built, and only one of those ways involves a DHCP server.

When a roaming laptop connects over a routed tunnel, the VPN daemon pushes the address. There is no broadcast, no lease negotiation — the client is told "you are 10.x.y.z, here is your route" as part of the tunnel handshake. The control plane never speaks DHCP to that client because the client never asks. A point-to-point routed link has no shared L2 segment for a DHCP broadcast to travel across.

A bridged site-to-site link is the opposite shape. A device behind a remote router shows up on the same Ethernet segment as the server, brings its own MAC address, and broadcasts a DISCOVER like it would on any office LAN. Nothing is pushing it an address. If something does not answer on UDP/67, that device sits there with a self-assigned 169.254.x.x and never reaches anything.

So this codebase ships a real DHCPv4 server. It binds to UDP/67, decodes wire packets, runs an OFFER / REQUEST / ACK state machine, and quarantines misbehaving MACs. The interesting part is the condition under which it does any of that: it only starts when two flags line up, and it shares its identity with a Linux bridge that may or may not exist yet. The rest of this post walks the decision tree that gates it.

The decision tree behind the output

There are five real branch points, and all of them are checked at boot before a single packet is read:

  1. Is this deployment bridged at all? If site-to-site is routed, no bridge, no TAP, no DHCP. The bridge setup returns null immediately.
  2. Is DHCP explicitly enabled on top of bridged? Bridged and DHCP are separate flags. Both must be true or the server never binds the socket.
  3. Does a bridge exist to receive the broadcasts? The DHCP server's own server-ID is the bridge IP. Bring-up of the bridge is idempotent and runs first.
  4. For an incoming MAC, which remote router brought it in? In multi-router site-to-site, the handler refuses to OFFER until it can resolve the router behind the MAC.
  5. Is this MAC allowed, and is it flapping or quarantined? Decline-quarantine and flap windows drop packets before allocation.

Nodes 1 and 2 are the gate. Nodes 3 through 5 are what the server does once the gate is open. The whole design rests on the gate: the DHCP server is dead weight in routed mode, so it must never bind UDP/67 there.

Walk each node with a real artifact in the repo

The gate is two if guards in the boot wiring, in src/boot/services/network.ts. The bridge guard is the first node:

export async function setupBridge(registry: LoadedRegistry): Promise<BridgeManager | null> {
  if (!config.s2s.bridged) return null;
  const bridgedTapDevs = registry.enabled
    .filter((w) => w.spec.mode === 'bridged-tap')
    .map((w) => w.spec.dev);
  const tapIfaces = bridgedTapDevs.length > 0
    ? bridgedTapDevs
    : (config.bridge.tapIface ? [config.bridge.tapIface] : []);
  // ...
  const bridge = new BridgeManager(bcfg);
  await bridge.ensure();
  return bridge;
}

If config.s2s.bridged is false, this returns before constructing anything. No bridge, no TAP devices, and — because of the next guard — no DHCP server. The TAP interface list is derived from the registry of enabled tunnel instances filtered down to those whose spec.mode is bridged-tap. Routed instances never appear in that list, so they never get a TAP enslaved to the bridge.

The DHCP guard, in the same file, is the second node and is stricter — it requires both flags:

export async function setupDhcp(deps: DhcpDeps): Promise<{ ... } | null> {
  if (!(config.s2s.bridged && config.dhcp.enabled)) return null;
  const macRouterMap = new MacRouterMap();
  const handler = new DhcpHandler(/* serverId, netmask, gateway, dns, lease times... */,
    deps.ipPool,
    deps.upstream,
    deps.cache,
    macRouterMap,
  );
  const server = new DhcpServer(
    { bindAddress: config.dhcp.bindAddress, port: config.dhcp.port, /* ... */ },
    handler,
  );
  await server.start();
  return { server, handler, macRouterMap };
}

if (!(config.s2s.bridged && config.dhcp.enabled)) return null; is the whole point of the article in one line. Bridged-but-DHCP-disabled is a valid configuration — you might let an upstream appliance own DHCP on that segment. Routed-with-DHCP-enabled is incoherent, and the && makes it impossible to bind the socket in that case. The server is only ever constructed when both conditions hold.

Once setupDhcp does construct the server, the actual UDP/67 bind lives in src/dhcp/DhcpServer.ts:

async start(): Promise<void> {
  if (this.sock || this.starting) return;
  this.starting = true;
  const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true });
  this.sock = sock;
  sock.on('message', (msg, rinfo) => { void this.onMessage(msg, rinfo); });
  await new Promise<void>((resolve, reject) => {
    sock.once('error', reject);
    sock.bind({ address: this.cfg.bindAddress, port: this.cfg.port }, () => {
      sock.setBroadcast(true);
      sock.off('error', reject);
      resolve();
    });
  });
  this.starting = false;
}

It is a plain node:dgram UDP4 socket with reuseAddr and setBroadcast(true) — DHCP replies frequently go to 255.255.255.255 because the client has no IP yet. The bindAddress is typically 0.0.0.0; the server listens on all interfaces and relies on the bridge being the only L2 segment that carries DHCP traffic. There is no per-interface filtering in the socket layer because the gate above already guarantees the bridge is the only place those broadcasts come from.

Node 3 — the bridge has to exist before the server's server-ID means anything — is enforced by ordering and idempotency in src/bridge/BridgeManager.ts:

async ensure(): Promise<void> {
  await this.ensureBridge();
  for (const tap of this.cfg.tapIfaces) {
    await this.ensureTap(tap);
  }
  if (this.cfg.lanIface) await this.attachIface(this.cfg.lanIface);
}

private async ensureTap(tap: string): Promise<void> {
  if (!(await this.ifaceExists(tap))) {
    await this.ip(['tuntap', 'add', 'dev', tap, 'mode', 'tap', 'user', 'root']);
  }
  await this.attachIface(tap);
  await this.ip(['link', 'set', tap, 'up']);
  await this.enableProxyArp(tap);
}

Every step checks existence before acting. ensureTap only runs ip tuntap add if ifaceExists(tap) returns false; attachIface only re-enslaves if the interface's current master is not already the bridge. That is what makes a restart safe — the bridge and TAPs survive process restarts, and ensure() can run again on a half-built state without errors. There is deliberately no destroy() on this manager: L2 state and active DHCP leases are left alone across restarts so a daemon bounce does not knock devices offline.

Node 4 is the one that surprises people. In a multi-router site-to-site setup, several remote routers feed the same bridge, and a downstream device's IP is supposed to land in the /24 of whichever router carried it in. The handler will not OFFER until it knows that router. From src/dhcp/handler.ts, inside onDiscover:

let viaRouterCn: string | undefined;
if (this.macRouterMap) {
  const entry = await this.macRouterMap.lookup(mac);
  if (!entry) {
    this.stats.routerUnknown++; metrics.dhcpDropped.inc({ reason: 'router_unknown' });
    lg.info('dhcp: router unknown for MAC (learn-address not yet recorded) — drop');
    return null;
  }
  viaRouterCn = entry.cn;
  const routerAlloc = await this.ipPool.lookup('cn', entry.cn);
  if (!routerAlloc) {
    this.stats.routerUnknown++; metrics.dhcpDropped.inc({ reason: 'router_unknown' });
    return null;
  }
  // ... constrain the allocation to the router's /24 via groupId
}

The MAC-to-router mapping is fed by the VPN daemon's --learn-address hook, persisted in SQLite (src/dhcp/MacRouterMap.ts). If the hook has not fired yet, the lookup misses and the DISCOVER is dropped rather than answered with a wrong-subnet address. The device retries every few seconds; meanwhile the hook catches up. Dropping is the correct move here — a fast wrong answer in DHCP is worse than a slow right one, because the client will commit to the bad lease.

That learn-address hook also explains a small normalization detail. In TAP mode the hook emits MACs in aa:bb:cc:dd:ee:ff@vid form, so MacRouterMap strips the VLAN suffix before validating:

function normalize(mac: string): string | null {
  const m = mac.trim().toLowerCase().replace(/@\d+$/, '');
  return MAC_RE.test(m) ? m : null;
}

Without that replace(/@\d+$/, ''), every VLAN-tagged MAC would fail the regex and every lookup would miss — the DHCP server would silently refuse to offer anything, and you would spend an afternoon staring at router_unknown counters. The handler keys all its in-memory state on this same lowercase colon form, so normalization has to happen at the edge.

Node 5 — quarantine and flap protection — runs at the very top of onDiscover, before any allocation. A MAC that recently sent a DECLINE is held in an in-memory quarantine map and gets no OFFER until the cooldown expires; a MAC that re-DISCOVERs inside flapWindowMs is rate-limited and dropped. Both maps are intentionally in-memory and lost on restart, because they are short-lived corrective mechanisms, not lease state. The actual lease state lives in IpPool, which every transaction re-reads, so the handler stays stateless across requests and survives restarts and concurrency.

The thing that almost went differently

The tempting shortcut is to always run the DHCP server and just point it at the bridge interface. Bind UDP/67 unconditionally, let routed deployments ignore it. It would have been fewer lines: drop the &&, drop one of the flags, always construct the server.

That falls apart on the first routed-only box. UDP/67 is a privileged, contended port. If the control plane grabs it on a host where some other tool — or a future bridged segment — expects to own DHCP, you get a bind conflict at best and a rogue DHCP server answering broadcasts it should never see at worst. A rogue DHCP server on a segment is a genuine outage: it hands out leases with the wrong gateway and quietly blackholes a subnet. The config.dhcp.enabled flag exists precisely so a bridged deployment can opt out when an upstream appliance owns DHCP on that wire. Collapsing the two flags into one would have removed that escape hatch.

The other near-miss was answering DISCOVER optimistically when the router was unknown — hand out a best-guess IP from the configured pool range and fix it later. That is how you get a device wedged on a lease in the wrong router's /24, unreachable, with no signal in the logs except confused users. Dropping the DISCOVER and letting the client retry is slower by a few seconds but never wrong, and the router_unknown metric makes the wait visible on the dashboard.

What you would change if starting over

Two things. First, the quarantine and flap maps are plain in-memory Maps on the handler. That is fine for a single process, and the comments are honest that the loss-on-restart behavior is by design. But the moment this runs as more than one replica behind the same bridge, two handlers would have inconsistent flap views and a declined MAC could be re-offered by the other instance. If I expected horizontal scale, I would push those into the same cache layer that already backs the MAC ALLOW/DENY lookups rather than leaving them local.

Second, the learn-address-then-DISCOVER race is handled by dropping and retrying, which works but spends real seconds per device on cold start. A small bounded wait — hold the DISCOVER for a beat and re-check the map once before dropping — would smooth first-connect latency without weakening the correctness guarantee. It is a latency-versus-simplicity call, and the current code chose simplicity, which is defensible for a setup where devices reconnect rarely.

Trade-off

The conditional bind buys correctness and operational safety at the cost of one more configuration axis. An operator now has to understand that "bridged" and "DHCP enabled" are two separate switches, and that routed deployments have no DHCP at all. That is a real cognitive load — a misread of the two flags means either no addresses handed out or, worse, an unexpected DHCP server on a segment. The mitigation is that the guard makes the incoherent combination (routed + DHCP) impossible to actually run, so the only mistakes left are the recoverable kind: forgetting to enable DHCP on a segment that needs it, which shows up immediately as devices that cannot get an address.

Business impact

For a network operator, the difference between pushed addresses and DHCP-on-the-wire is the difference between "the remote office plugs in a device and it just works" and "someone has to statically configure every device or drive out to site." Supporting bridged TAP segments — with a DHCP server that only exists where it belongs — means a customer's existing LAN equipment, printers, badge readers, PLCs that expect DHCP, drops onto the VPN with no per-device setup. The conditional binding is what keeps that capability from becoming a liability on the routed deployments that make up the rest of the fleet: one codebase, one binary, no rogue DHCP servers, and no port conflicts on the boxes that never wanted DHCP in the first place.

What to do next

If you run any system that behaves differently depending on a deployment mode, look at where the mode is decided. The strongest version of this pattern is a single guard, evaluated once at boot, that returns null instead of constructing the optional subsystem — and a hard requirement (the && here) that makes the incoherent combination unbuildable rather than merely discouraged. Grep your own boot path for subsystems that start unconditionally and then check a flag internally; those are the ones that bind ports or grab resources they did not need. Pull the check up to the construction site instead.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox