A symmetric UDP tunnel that duplicates a stream across every network link that can reach a destination, and deduplicates it on the far side — in both directions. Useful for making a latency-sensitive UDP flow (telemetry, video, control channels) survive the loss of any single link: every packet travels over all available paths, and the other end forwards whichever copy arrives first.
sender host receiver host
app ◀─udp─▶ udpdup send ══╦═ eth0 ═══╗
╠═ wwan0 ══╬══▶ udpdup recv ◀─udp─▶ app
╚═ wlan0 ══╝ (dedup)
Both directions are duplicated and deduplicated; each direction has its own sequence space.
Listens on a UDP port for the local app. Each received datagram gets a
20-byte header (magic, version, type, random epoch, sequence number)
and is transmitted to -dest once per eligible link, using a socket
bound to that link with SO_BINDTODEVICE so the kernel cannot collapse
everything onto the default route.
A link is used when it is up, running, not loopback (unless explicitly
listed in -ifaces), passes the -ifaces/-exclude filters, and has
a route to the destination (the per-device connect fails fast when it
does not). Links are re-evaluated every -refresh (default 5s), so
interfaces coming up or going down are picked up automatically.
Reverse path: packets the far side sends back arrive on the per-link
sockets, are deduplicated, and the payload is delivered to the local
app's last-seen address on the listen socket. A keepalive is sent over
every link each -keepalive interval (default 2s) so the far side
always knows the current per-link source addresses — this is what keeps
the reverse path working across NATs, link re-dials, and idle periods.
udpdup send -listen :5000 -dest 192.0.2.10:5001
udpdup send -listen 127.0.0.1:5000 -dest 192.0.2.10:5001 -ifaces eth0,wwan0SO_BINDTODEVICE requires CAP_NET_RAW: run as root, grant the binary
the capability (sudo setcap cap_net_raw+ep ./udpdup), or use
AmbientCapabilities in the systemd unit as below.
Listens on a UDP port for udpdup traffic, strips the header, drops
duplicates, and forwards each unique payload to -forward.
Reverse path: every source address udpdup traffic arrives from (one per
sender link) is tracked as a peer, expiring after -peer-timeout
(default 15s) of silence. When the local app replies — by writing back
to the source address its packets came from, i.e. the forward socket —
each reply is headered and transmitted to every live peer, one copy
per sender link. The sender deduplicates and delivers to its app.
udpdup recv -listen :5001 -forward 127.0.0.1:6000Two methods are available via -dedup; both ends must use the same
one (a mismatch shows up as a climbing bad_header counter on the
seq end, or headers delivered as payload on the other).
Each packet gets a 20-byte header carrying a random per-run epoch and a
sequence number. The receiver keeps a sliding bitmap window of
-window sequence numbers (default 8192) behind the highest seen, per
epoch — so out-of-order arrival within the window is handled, a
restarted peer starts clean, and legitimately repeated payloads
(heartbeats, fixed telemetry) are never wrongly dropped. Packets older
than the window are dropped as stale.
No header: packets travel byte-identical to what the app sent, and the
receiver suppresses payloads whose (seeded 64-bit) hash was already
seen within -hash-window (default 250ms — cover your worst inter-link
delay skew with margin). Keepalives become zero-length datagrams.
This is semantically safe for any app that is correct over plain UDP, because UDP itself already permits duplication and loss: a wrongly suppressed identical repeat is indistinguishable from packet loss, and a copy delayed past the window is ordinary duplication. For traffic that is unique per packet anyway (RTP, QUIC, WireGuard, anything encrypted) it is just as exact as seq mode. Caveats:
- A steady stream of byte-identical payloads is thinned to roughly one per window (never starved entirely).
- Protocols that retransmit identical packets for loss recovery (DNS retries, TFTP) can have a retransmit eaten if it lands inside the window; keep the window well below their retransmit timeout.
- Zero-length app datagrams are treated as keepalives and not carried.
Upside: zero overhead, no MTU loss, and either end interoperates with plain UDP peers.
Everything seq mode does, plus forward error correction: packets are
grouped, and after every k data packets, r Reed-Solomon parity
packets are transmitted (-fec k:r, default 4:1). Any k of a
group's k+r packets reconstruct the whole group, so up to r data
packets whose every link copy was lost are recovered — protection
beyond what duplication alone provides, at the cost of r/k extra
bandwidth and a 22-byte header.
Latency is unaffected: data packets are forwarded immediately on first arrival, and parity is only consulted when something is missing. Recovered packets are delivered as soon as enough of the group has arrived (so they may be reordered — normal UDP behavior), and a real copy arriving after its packet was recovered is dropped as a duplicate.
If traffic pauses mid-group, the partial group's parity is flushed
after -fec-flush (default 20ms; 0 disables) so the last packets
before an idle period are still protected. The group shape rides in
every header, so the receive direction adapts to the sender's -fec
setting automatically; each side's own flag governs the direction it
transmits.
Pick k:r by loss pattern: 4:1 tolerates one loss per 4-packet
group (25% overhead); 8:2 the same average overhead but survives
two losses in a burst. Larger groups amortize overhead better but need
more packets in flight before recovery can happen.
- Per-packet overhead: seq 20 bytes, fec 22 bytes (24 for the largest payload's parity), hash zero. Oversized datagrams are dropped and counted; keep payloads under the path MTU minus the overhead to avoid fragmentation.
- Packets are forwarded in arrival order, not sequence order; udpdup removes duplicates but does not reorder. UDP consumers should already tolerate reordering.
- Reverse delivery on the send side goes to the last local address that sent to the listen port — one local app per send instance.
- The app behind
recvmust reply to the source address its packets arrive from (normal request/response UDP behavior). - Both modes log counters every
-statsinterval (default 30s;0disables) covering both directions: received, copies sent, forwarded, duplicates, stale/malformed, and drops with no usable link/peer/app.
go build -o udpdup .
go test ./...The test suite includes a full bidirectional pipeline test over
loopback; it is skipped automatically where SO_BINDTODEVICE needs
privileges the test run doesn't have.
/etc/systemd/system/udpdup-send.service on the sender:
[Unit]
Description=udpdup sender
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/udpdup send -listen 127.0.0.1:5000 -dest 192.0.2.10:5001
AmbientCapabilities=CAP_NET_RAW
DynamicUser=yes
Restart=on-failure
[Install]
WantedBy=multi-user.target/etc/systemd/system/udpdup-recv.service on the receiver:
[Unit]
Description=udpdup receiver
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/udpdup recv -listen :5001 -forward 127.0.0.1:6000
DynamicUser=yes
Restart=on-failure
[Install]
WantedBy=multi-user.target