- stun: RFC 5389 binding responder + RFC 5780 attributes (OTHER-ADDRESS,
RESPONSE-ORIGIN, CHANGE-REQUEST) on a primary/alt-port socket grid per
address; advertises stun-5780 with >=2 same-family addrs, else
stun-basic. Unmodified framing for tooling interop. Tested.
- tcpecho: JSON greeting with observed src + TCP_INFO MSS/options
(Linux getsockopt; zeroed elsewhere via build tags), then byte echo.
- session: per-packet UDP observations + connect-back results, ByID lookup.
- control: GET /v1/sessions/{id}/observations, POST .../actions
(delayed_echo → DELAYED_ECHO at the observed data-plane source;
connect_back → dial the control-plane source, record connected/refused/
timeout+rtt). Capabilities computed from what is actually wired.
- config/main: comma-separated STUN listeners; all planes bind explicit
addresses; graceful shutdown of the new listeners.
Full flow smoke-tested; go test green (stun binding/change-port,
dataplane wire format).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
6.8 KiB
Go
267 lines
6.8 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
||
// Package stun implements an unmodified RFC 5389 STUN binding responder with
|
||
// the RFC 5780 NAT-behavior-discovery attributes (OTHER-ADDRESS,
|
||
// RESPONSE-ORIGIN, CHANGE-REQUEST) when alternate addresses are available.
|
||
// No custom framing — interop with existing STUN tooling is a feature
|
||
// (spec §4). Each configured primary address gets two sockets: the given
|
||
// port and port+1 (the RFC 5780 alternate-port convention).
|
||
package stun
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/binary"
|
||
"log/slog"
|
||
"net"
|
||
"net/netip"
|
||
)
|
||
|
||
const (
|
||
magicCookie = 0x2112A442
|
||
|
||
typeBindingRequest = 0x0001
|
||
typeBindingSuccess = 0x0101
|
||
|
||
attrChangeRequest = 0x0003
|
||
attrXorMapped = 0x0020
|
||
attrSoftware = 0x8022
|
||
attrResponseOrigin = 0x802B
|
||
attrOtherAddress = 0x802C
|
||
|
||
changeIP = 0x04
|
||
changePort = 0x02
|
||
)
|
||
|
||
// sock is one bound socket, addressable by (address index, port index).
|
||
type sock struct {
|
||
conn *net.UDPConn
|
||
addr netip.AddrPort
|
||
}
|
||
|
||
// Server holds the socket grid: addrs × {primary, alternate} ports.
|
||
type Server struct {
|
||
// socks[i][0] = primary port, socks[i][1] = alt port for address i.
|
||
socks [][2]*sock
|
||
}
|
||
|
||
// Listen binds primary+alternate sockets for every address. Addresses are
|
||
// "ip:port" specs; the alternate port is port+1.
|
||
func Listen(addrs []string) (*Server, error) {
|
||
s := &Server{}
|
||
for _, spec := range addrs {
|
||
ap, err := netip.ParseAddrPort(spec)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var pair [2]*sock
|
||
for i, port := range []uint16{ap.Port(), ap.Port() + 1} {
|
||
bind := netip.AddrPortFrom(ap.Addr(), port)
|
||
conn, err := net.ListenUDP("udp", net.UDPAddrFromAddrPort(bind))
|
||
if err != nil {
|
||
s.Close()
|
||
return nil, err
|
||
}
|
||
pair[i] = &sock{conn: conn, addr: bind}
|
||
}
|
||
s.socks = append(s.socks, pair)
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
func (s *Server) Close() {
|
||
for _, pair := range s.socks {
|
||
for _, sk := range pair {
|
||
if sk != nil {
|
||
sk.conn.Close()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Has5780 reports whether any address family has ≥2 addresses — the
|
||
// prerequisite for full NAT behavior discovery.
|
||
func (s *Server) Has5780() bool {
|
||
var v4, v6 int
|
||
for _, pair := range s.socks {
|
||
if pair[0].addr.Addr().Is4() || pair[0].addr.Addr().Is4In6() {
|
||
v4++
|
||
} else {
|
||
v6++
|
||
}
|
||
}
|
||
return v4 >= 2 || v6 >= 2
|
||
}
|
||
|
||
// Serve starts one read loop per socket and blocks until the first error.
|
||
func (s *Server) Serve() error {
|
||
errCh := make(chan error, len(s.socks)*2)
|
||
for ai := range s.socks {
|
||
for pi := range s.socks[ai] {
|
||
go func(ai, pi int) { errCh <- s.loop(ai, pi) }(ai, pi)
|
||
}
|
||
}
|
||
return <-errCh
|
||
}
|
||
|
||
func (s *Server) loop(ai, pi int) error {
|
||
sk := s.socks[ai][pi]
|
||
buf := make([]byte, 1500)
|
||
for {
|
||
n, raddr, err := sk.conn.ReadFromUDPAddrPort(buf)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
s.handle(ai, pi, buf[:n], raddr)
|
||
}
|
||
}
|
||
|
||
// otherAddr finds the "diagonal" alternate for RFC 5780: different address
|
||
// (same family), different port. Returns nil when there is none.
|
||
func (s *Server) other(ai int, sameFamily bool, fam4 bool) int {
|
||
for i, pair := range s.socks {
|
||
if i == ai {
|
||
continue
|
||
}
|
||
is4 := pair[0].addr.Addr().Is4() || pair[0].addr.Addr().Is4In6()
|
||
if !sameFamily || is4 == fam4 {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func (s *Server) handle(ai, pi int, pkt []byte, raddr netip.AddrPort) {
|
||
if len(pkt) < 20 || binary.BigEndian.Uint16(pkt[0:2]) != typeBindingRequest {
|
||
return
|
||
}
|
||
if binary.BigEndian.Uint32(pkt[4:8]) != magicCookie {
|
||
return
|
||
}
|
||
msgLen := int(binary.BigEndian.Uint16(pkt[2:4]))
|
||
if 20+msgLen > len(pkt) {
|
||
return
|
||
}
|
||
var txid [12]byte
|
||
copy(txid[:], pkt[8:20])
|
||
|
||
// Parse CHANGE-REQUEST if present (RFC 5780 §7.2).
|
||
var change byte
|
||
for off := 20; off+4 <= 20+msgLen; {
|
||
at := binary.BigEndian.Uint16(pkt[off : off+2])
|
||
al := int(binary.BigEndian.Uint16(pkt[off+2 : off+4]))
|
||
if off+4+al > len(pkt) {
|
||
break
|
||
}
|
||
if at == attrChangeRequest && al >= 4 {
|
||
change = pkt[off+7]
|
||
}
|
||
off += 4 + al + (4-al%4)%4 // attributes are 32-bit aligned
|
||
}
|
||
|
||
// Pick the responding socket per CHANGE-REQUEST.
|
||
fam4 := raddr.Addr().Is4() || raddr.Addr().Is4In6()
|
||
rai, rpi := ai, pi
|
||
if change&changeIP != 0 {
|
||
if o := s.other(ai, true, fam4); o >= 0 {
|
||
rai = o
|
||
} else {
|
||
return // cannot honor — RFC says error response; silence is safer for a probe target
|
||
}
|
||
}
|
||
if change&changePort != 0 {
|
||
rpi = 1 - pi
|
||
}
|
||
responder := s.socks[rai][rpi]
|
||
|
||
resp := buildResponse(txid, raddr, responder.addr, s.otherAddress(ai, fam4))
|
||
if _, err := responder.conn.WriteToUDPAddrPort(resp, raddr); err != nil {
|
||
slog.Debug("stun write failed", "to", raddr, "err", err)
|
||
}
|
||
}
|
||
|
||
// otherAddress computes the OTHER-ADDRESS attribute value (alt IP, alt port)
|
||
// for the client's family, or an invalid AddrPort when unavailable.
|
||
func (s *Server) otherAddress(ai int, fam4 bool) netip.AddrPort {
|
||
if o := s.other(ai, true, fam4); o >= 0 {
|
||
return s.socks[o][1].addr
|
||
}
|
||
return netip.AddrPort{}
|
||
}
|
||
|
||
func buildResponse(txid [12]byte, mapped, origin, other netip.AddrPort) []byte {
|
||
attrs := xorMappedAttr(attrXorMapped, mapped, txid)
|
||
attrs = append(attrs, addrAttr(attrResponseOrigin, origin)...)
|
||
if other.IsValid() {
|
||
attrs = append(attrs, addrAttr(attrOtherAddress, other)...)
|
||
}
|
||
sw := []byte("echolot")
|
||
attrs = append(attrs, attrHeader(attrSoftware, len(sw))...)
|
||
attrs = append(attrs, pad4(sw)...)
|
||
|
||
msg := make([]byte, 20, 20+len(attrs))
|
||
binary.BigEndian.PutUint16(msg[0:2], typeBindingSuccess)
|
||
binary.BigEndian.PutUint16(msg[2:4], uint16(len(attrs)))
|
||
binary.BigEndian.PutUint32(msg[4:8], magicCookie)
|
||
copy(msg[8:20], txid[:])
|
||
return append(msg, attrs...)
|
||
}
|
||
|
||
func attrHeader(typ uint16, valLen int) []byte {
|
||
h := make([]byte, 4)
|
||
binary.BigEndian.PutUint16(h[0:2], typ)
|
||
binary.BigEndian.PutUint16(h[2:4], uint16(valLen))
|
||
return h
|
||
}
|
||
|
||
func pad4(b []byte) []byte {
|
||
for len(b)%4 != 0 {
|
||
b = append(b, 0)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// addrValue encodes the RFC 5389 address structure (family, port, address).
|
||
func addrValue(ap netip.AddrPort) []byte {
|
||
addr := ap.Addr().Unmap()
|
||
if addr.Is4() {
|
||
v := make([]byte, 8)
|
||
v[1] = 0x01
|
||
binary.BigEndian.PutUint16(v[2:4], ap.Port())
|
||
a4 := addr.As4()
|
||
copy(v[4:], a4[:])
|
||
return v
|
||
}
|
||
v := make([]byte, 20)
|
||
v[1] = 0x02
|
||
binary.BigEndian.PutUint16(v[2:4], ap.Port())
|
||
a16 := addr.As16()
|
||
copy(v[4:], a16[:])
|
||
return v
|
||
}
|
||
|
||
func addrAttr(typ uint16, ap netip.AddrPort) []byte {
|
||
v := addrValue(ap)
|
||
return append(attrHeader(typ, len(v)), v...)
|
||
}
|
||
|
||
// xorMappedAttr encodes XOR-MAPPED-ADDRESS (RFC 5389 §15.2).
|
||
func xorMappedAttr(typ uint16, ap netip.AddrPort, txid [12]byte) []byte {
|
||
v := addrValue(ap)
|
||
binary.BigEndian.PutUint16(v[2:4], ap.Port()^uint16(magicCookie>>16))
|
||
var key [16]byte
|
||
binary.BigEndian.PutUint32(key[0:4], magicCookie)
|
||
copy(key[4:], txid[:])
|
||
for i := 4; i < len(v); i++ {
|
||
v[i] ^= key[i-4]
|
||
}
|
||
return append(attrHeader(typ, len(v)), v...)
|
||
}
|
||
|
||
// NewTxID is exported for tests and client code.
|
||
func NewTxID() [12]byte {
|
||
var t [12]byte
|
||
_, _ = rand.Read(t[:])
|
||
return t
|
||
}
|