Go and TUN/TAP: Building a UDP Relay with YAML Configuration
Go developers can build network relays that work with TUN interfaces and UDP tunnels. The architecture uses a YAML configuration to define ingress-egress chains. Each relay handles bidirectional traffic—between TUN and UDP, and back again—enabling complex routing without hardcoding logic.
At its core, the implementation uses io.ReadWriteCloser for all nodes. The logic is straightforward: read packets from ingress, forward them to egress, and process the reverse flow.
YAML Configuration Structure
The config defines relays as an array of ingress/egress pairs. Parameters include interface type, IP addressing, NAT rules, and authentication.
Example: TUN-to-TUN
relays:
- ingress:
type: tun
name: tun10
cidr: "10.0.0.2/24"
peer: "10.0.0.1"
egress:
type: tun
name: tun11
cidr: "10.0.1.2/24"
peer: "10.0.1.1"
nat:
forward:
src: "10.0.1.1"
backward:
dst: "10.0.0.2"
For a UDP relay, add client and server endpoints:
relays:
- ingress:
type: tun
name: tun10
cidr: "10.0.0.2/24"
peer: "10.0.0.1"
egress:
type: udp
dial: "localhost:4000"
password: "pass"
- ingress:
type: udp
listen: "localhost:4000"
password: "pass"
egress:
type: tun
name: tun11
cidr: "10.0.1.2/24"
peer: "10.0.1.1"
nat:
forward:
src: "10.0.1.1"
backward:
dst: "10.0.0.2"
This creates a chain: tun10 → UDP client → UDP server → tun11. Traffic stays local.
UDP Tunnel Protocol
A header is added to each IP packet: 4 bytes timestamp (uint32 BigEndian) + 16 bytes MD5 hash. The hash is computed from password + timestamp + first 64 bytes of the packet.
On reception, the system checks:
- Packet size ≥ HeaderSize (20 bytes)
- Timestamp within 10 seconds of current time
- Hash matches
Unpacking code:
func (i *Ingress) Read(b []byte) (int, error) {
n, raddr, err := i.conn.ReadFrom(b)
if err != nil {
return 0, err
}
data, err := unpack(b[:n:n], i.pass)
if err != nil {
return 0, err
}
i.raddr = raddr
copy(b, data)
return len(data), nil
}
func unpack(packet []byte, pass string) ([]byte, error) {
if len(packet) < HeaderSize {
return nil, ErrSmallPacket
}
rtimestamp := binary.BigEndian.Uint32(packet[0:4])
rhash := [HashSize]byte(packet[4 : 4+HashSize])
payload := packet[HeaderSize:]
timestamp := uint32(time.Now().Unix())
if timestamp-rtimestamp > MaxTimeDiff && rtimestamp-timestamp > MaxTimeDiff {
return nil, ErrStalePacket
}
hash, err := calcHash(pass, payload, rtimestamp)
if err != nil {
return nil, fmt.Errorf("calc hash: %w", err)
}
if rhash != hash {
return nil, ErrWrongPass
}
return payload, nil
}
Packing is symmetric: calcHash + binary.BigEndian + send.
Performance Testing
Local tests using iperf3 over tun10-tun11:
Server:
iperf3 -s -B 10.0.1.2
Client:
iperf3 -c 10.0.1.2 -B 10.0.0.2
Results (10 seconds):
| Interval | Transfer | Bitrate |
|----------|----------|---------|
| 0-1s | 113 MB | 941 Mbit/s |
| 1-2s | 109 MB | 921 Mbit/s |
| ... | ... | ... |
| Total | 1.08 GB | 925 Mbit/s (sender) |
~922 Mbit/s on receiver. CPU: relay ~250% on M1, iperf3 ~10–30%. Optimization possible via batching, zero-copy, or SIMD hashing.
Key metrics for analysis:
- Throughput: 925 Mbit/s average
- CPU load: High single-thread usage
- Latency: 10-second timestamp window (sufficient for local testing)
- Reliability: Hash + timestamp prevent replay and injection attacks
Key Takeaways
- YAML config enables mixing TUN/UDP without recompilation
- Protocol is minimal: 20-byte overhead, MD5 + timestamp
- Performance ~900+ Mbit/s locally, but CPU-bound
- Extensible via
io.ReadWriteCloser: easy to add WireGuard, QUIC - NAT rules in config simplify peer-to-peer setups
— Editorial Team
No comments yet.