Shape it, don't meter it: fairness with no per-peer record

A saturated link raises two questions that look like one: unequal service, and who caused it. The second is a logging question, and here the answer is no. There is no per-peer traffic record, so a saturated tier cannot be diagnosed after the fact and per-byte billing is off the table.

That is not the end of fairness, because fairness comes out of the data path. The file that says so starts with a comment:

1
2
// WHY A SHAPER AND NOT METERING: the no-log posture forbids keeping a per-peer
// traffic record ... Fairness therefore has to come out of the data path itself

That is internal/tiershape/tiershape.go in the clientsv repository.

Scheduling is a decision, not a ledger

HTB builds a hierarchy of classes, and a parent’s rate is shared among its children by weight. The property that matters is that HTB is work-conserving: an idle member is never dequeued to, so its share goes to the busy ones per packet. Three members saturating and one idle, and the busy ones get the idle member’s share — because the scheduler picked the next packet off whichever queue had one.

There is no counter to persist, which is exactly why the design survives a no-log posture. And you cannot report per-peer bytes from it. Asked to graph who used what, the answer is that this design does not know, and cannot be made to know without adding the record it exists to avoid.

One tree on one device

The cap a customer buys is per account, and an account may hold several devices on several tunnels, so a tree per tunnel cannot express it. The tree therefore lives on a single shaper device, an IFB, and both tunnels are funnelled into it.

Linux can only shape traffic it can enqueue, which is why an egress qdisc polices egress and a tunnel’s ingress — the client-to-server, upload direction — used to be unshaped. Redirecting both directions into a virtual device puts them under one trunk, one account class and one device class:

1
2
3
Rate       string   // guaranteed rate of one account
Ceil       string   // the cap one account's devices share
RootCeil   string   // trunk ceiling (the instance's capacity)

Classes are keyed on the peer’s assigned tunnel address — /32, matched as src_ip for upload and dst_ip for download — because when traffic leaves a tunnel the peer’s public key is long gone and the tunnel address is the only stable identity left. Classid tokens are base 16 with a stride of ten, so 120 is account 12’s class and 123 its third device.

What a rate is not

A guaranteed rate is a long-run average enforced by the scheduler, not a hard per-instant cap. An account may burst well above its guaranteed rate while the trunk is idle, and it can always use its guarantee even when the trunk is busy. The code pins the quantum to 1500 bytes for exactly this reason: left at tc’s default, a 1.8 Gbit trunk gets a multi-megabyte quantum — larger than the queue itself — and the scheduler then interleaves accounts per burst instead of per packet. Hitting the ceiling does not produce a row; it produces a queue that drains slower.

Checking the discipline on a live host

The answer has to come from the kernel, not the config repository. Read it in this order:

  1. tc qdisc show — all devices, no dev filter. A real tree shows up here even when the interface you asked about looks bare.
  2. Per interface: tc qdisc show dev $I, tc class show dev $I, tc filter show dev $I.
  3. ip -br link show type ifb, then dump the tree on that device too — the mirror target is where a cross-interface policy actually lives.

WireGuard and AmneziaWG devices default to noqueue, so an empty tc class output on a tunnel is not proof of “unshaped”: a cross-interface policy shows there as a clsact qdisc plus filters that mirred ... redirect into the IFB. Grep the filter output for mirred before concluding. And tc -j reports HTB rates in bytes per second — rate 100mbit reads back as "rate":12500000 — so comparing against a bit value makes every class look drifted forever.

The reconciler reads the kernel back after applying: -dry-run prints the commands and changes nothing, -check reports drift as exit code 3 so a timer can alarm without mutating the tree.

What you can check

For the policy side — what the service records and what it refuses to — see what “no logs” can and cannot promise, and for the rest of the network posture, three DNS postures, one tunnel.

󰔟 Running the same kind of stack?
The tunnel this site is written from is the one I sell: WireGuard to a gateway in Montréal, three DNS postures, one price — and the same "check it yourself" habit as everything above.
[ CREATE ACCOUNT ] [ WHAT IS THE VPN ]
󰣨 ymrtech@ymrtech | 󰌠 NixOS | 󰍢 UTF-8