Skip to content

Address translation lives in the connection, not the microflow

Read out of the vendor's headers and confirmed against the appliance, 2026-10-05, while preparing to accelerate a flow that transits the box rather than one addressed to it.

This is the fact that separates a demonstration from a product. On an ordinary appliance every connection out to the internet is translated, so a flow forwarded by the coprocessor without its translation leaves with a private source address and is dropped by the first router it meets. The flow that was accelerated in a-frame-was-accelerated.md was addressed to the appliance itself and needed no translation, which is why it could be accelerated by programming almost nothing.

Where it is

struct usfp_conn_entry, not struct usfp_mflow_entry. That is why FLOW_CREATE_FP carries a whole connection and not just two identities, and it is the reason a microflow has no address fields of its own: the microflow says which flow, the connection says what to do to it.

struct usfp_nat_info {
    __be32 ipv4_orig_src;
    __be32 ipv4_orig_dest;

    __be32 ipv4_nat_src;
    __be32 ipv4_nat_dest;

    __be16 orig_dest_port;
    __be16 orig_src_port;

    __be16 nat_dest_port;
    __be16 nat_src_port;
};

Note the order of the ports: destination before source, in both pairs, which is the opposite of the addresses above them. A block filled in the obvious order is a block with every port in the wrong place, and every field in it is a plausible port number.

Two bits in the connection's flags word choose the direction: do_dnat at bit 19 and do_snat at bit 20, beside verdict at 21 and state at 30. Those four positions were confirmed by writing a connection and reading it back with LO_CONN_READ: rev_num 0x1234, verdict 1 and state 2 all came back where they were put.

The offset, by two routes that had to agree

The NAT block sits at offset 84 in a FLOW_CREATE_FP request. That number was not taken from one calculation.

Measured. mflow_valid is read at request offset 112 and programming a microflow there works, so the connection ahead of it occupies 4 + 108. LO_CONN_READ returns 108 bytes for one entry, independently.

Computed. atomic 8 + session_id 4 + qos[2] 8 + tcp 60 + nat 24 + entry_lock 4 = 108.

And the computation nearly went wrong by eight bytes, which is why the measurement mattered. The vendor's header comments struct usfp_tcp_info as /* 13 LW */, which is 52 and would put the NAT block at 76. It is 60: usfp_tcp_seq is __attribute__((packed)) and 48 bytes of its own - seen[2] alone is 32, being two usfp_tcp_seq_td of four words each - then usfp_fin_state is 8, and four bytes of bitfields follow. The comment is stale by exactly the eight bytes that the measured total already said were missing.

rpc.conn_nat_off is settable anyway. It costs nothing, and the next structure whose comment disagrees with its members will be found with it rather than argued about.

The host does not have to compute the mapping

pf already holds it. Every state has two keys - key[PF_SK_WIRE] and key[PF_SK_STACK] - one side as the packet appears on the wire and the other as the host sees it. For a translated connection they differ, and the difference is exactly what usfp_nat_info wants. Identical keys mean the connection is not translated.

So dp.pf_state now prints both keys for the last punted frame, as raw 32-bit values rather than dotted quads - because that is the form rpc.conn_nat_src and its five neighbours take. They want network order, which is what the frame holds and what pf holds, so a number read out of the diagnostic can be written into the staging sysctl unchanged. A dotted quad would have to be converted by whoever read it, and converted the wrong way half the time.

What is not settled

Nothing here has been exercised. The NAT block is written only when rpc.conn_snat or rpc.conn_dnat is set, so until then it is twenty-four bytes of somebody else's structure that this driver does not touch - which is deliberate, and is why the staging could be merged before the experiment.

Whether the fast path applies the translation correctly, whether the checksums are fixed up by hardware or have to be handed to it, and whether a TCP flow survives being handed over mid-stream are all open.

Lesson

A structure's comment is documentation and its members are the truth, and when they disagree the members win - but only if somebody notices. Two routes to the same 108 is what made this safe: the arithmetic alone would have been eight bytes out, and the measurement alone would have given a total without saying which member was bigger than it claimed. The habit worth keeping is not "read the header" or "measure it" but make them meet, because the disagreement is where the bug would have been.

Measured: a real translated flow, accelerated

Done on the appliance with its owner's agreement, on his own live traffic, because a flow that transits the box cannot be manufactured from the box - traffic the host originates takes the FROM_KN path, which has no microflow gate at all.

The subject chose itself. With the offload gate open, dp.pf_state was read until a punted frame turned out to belong to a translated connection - an inbound HTTPS flow, the reply direction of a connection the appliance had already translated on the way out:

frame 1600  proto 6  <a server>:443 -> <the WAN address>:3116
  wire   0xa5429522:443  0x2e46a8c0:3116
  stack  0xa5429522:443  0x7864a8c0:60838
  translated: the two keys differ

and dp.rx_prefix gave that same frame's slot and revision. The fast path's own entry for the slot confirmed it was the right flow before anything was written:

entry 60
  lif 4096  proto 6  dport 3116  sport 443
  src <the server>  dst <the WAN address>
  rev 0  fw_valid 1  host_valid 0

lif 4096 is (1 << 12), so the WAN port's interface index is 1 - not 3, which its name oxp3 would suggest. The name does not follow the index, and reading it rather than assuming it saved a next hop pointed at the wrong port.

Then one FLOW_CREATE_FP: the connection with do_dnat, the original pair and the translated pair; the microflow at state 2, action 1, pointing at a next hop resolved to the PC on interface 10.

FPCNTR_RX_WIRE              +17
FPCNTR_FROM_WIRE_TO_WIRE     +5   (0 -> 5)
FPCNTR_TX_KN                +12
FPCNTR_..._MFLOW_NOT_ACTIVE +12

12 punted before the write landed, 5 forwarded by the coprocessor, 17 arrived. And LO_CONN_READ on connection 7 shows every one of the eight NAT fields where it was put:

[ 11] 0x2e46a8c0a5429522    orig_src <the server>   orig_dest <the WAN address>
[ 12] 0x7864a8c0a5429522    nat_src  <the server>   nat_dest  <the PC>
[ 13] 0xbb01a6edbb012c0c    orig 3116/443           nat 60838/443
[  1] 0x40280001            rev 1, do_dnat set, do_snat clear, verdict 1, state 1

which puts the block at entry offset 80, measured, where the arithmetic said it would be.

Afterwards: the connection was still ESTABLISHED in pf, 120 states live, and 0% loss on the LAN, the WAN and the PC's own segment.

What this does and does not prove

Proven: a real, translated, transiting flow can be handed to the coprocessor, and it forwards it without the host seeing it. Every field of the connection entry including the NAT block lands where the handler reads it. The connection survived.

Not proven: that the translation was applied to the bytes on the wire. There is no counter for it - the whole counter table has no NAT counter - and the forwarded frames never enter the host, so no capture on the appliance can see them. A frame forwarded without its translation would arrive at the PC addressed to the WAN address, be discarded there, and be retransmitted by the server, which leaves the connection ESTABLISHED and the counters exactly as they are above. The evidence is consistent with the translation working and does not distinguish it from five dropped frames.

What would settle it is a flow accelerated for long enough that retransmission alone could not carry it - a sustained download, watched for a throughput collapse at the moment the flow is programmed. That is a bigger test on live traffic and is the next one worth asking for.

Discussion

The discussion of this page is held in the project's GitHub Discussions. Showing it here loads giscus from giscus.app and GitHub, and writing in it needs a GitHub account - what that means.