server: STUN, TCP echo, observations API, delayed-echo + connect-back actions
- 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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
507a8bfc1f
commit
7b676e666e
@@ -0,0 +1,266 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package stun
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bindingRequest builds a minimal RFC 5389 binding request.
|
||||
func bindingRequest(txid [12]byte, change byte) []byte {
|
||||
var attrs []byte
|
||||
if change != 0 {
|
||||
attrs = append(attrs, attrHeader(attrChangeRequest, 4)...)
|
||||
attrs = append(attrs, 0, 0, 0, change)
|
||||
}
|
||||
msg := make([]byte, 20, 20+len(attrs))
|
||||
binary.BigEndian.PutUint16(msg[0:2], typeBindingRequest)
|
||||
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...)
|
||||
}
|
||||
|
||||
// parseXorMapped extracts XOR-MAPPED-ADDRESS from a binding success.
|
||||
func parseXorMapped(t *testing.T, resp []byte, txid [12]byte) netip.AddrPort {
|
||||
t.Helper()
|
||||
if binary.BigEndian.Uint16(resp[0:2]) != typeBindingSuccess {
|
||||
t.Fatalf("type = %#x, want binding success", resp[0:2])
|
||||
}
|
||||
msgLen := int(binary.BigEndian.Uint16(resp[2:4]))
|
||||
for off := 20; off+4 <= 20+msgLen; {
|
||||
at := binary.BigEndian.Uint16(resp[off : off+2])
|
||||
al := int(binary.BigEndian.Uint16(resp[off+2 : off+4]))
|
||||
if at == attrXorMapped {
|
||||
v := append([]byte(nil), resp[off+4:off+4+al]...)
|
||||
port := binary.BigEndian.Uint16(v[2:4]) ^ 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]
|
||||
}
|
||||
if v[1] == 0x01 {
|
||||
return netip.AddrPortFrom(netip.AddrFrom4([4]byte(v[4:8])), port)
|
||||
}
|
||||
return netip.AddrPortFrom(netip.AddrFrom16([16]byte(v[4:20])), port)
|
||||
}
|
||||
off += 4 + al + (4-al%4)%4
|
||||
}
|
||||
t.Fatal("no XOR-MAPPED-ADDRESS in response")
|
||||
return netip.AddrPort{}
|
||||
}
|
||||
|
||||
func TestBindingAndChangePort(t *testing.T) {
|
||||
// Two loopback "addresses" is not possible portably, so exercise one
|
||||
// address (basic binding + change-port); the change-IP path needs the
|
||||
// two-address grid of a real deployment.
|
||||
srv, err := Listen([]string{"127.0.0.1:0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// port 0 twice would collide at 1; rebind explicitly on free ports
|
||||
srv.Close()
|
||||
base := freePort(t)
|
||||
srv, err = Listen([]string{netip.AddrPortFrom(netip.MustParseAddr("127.0.0.1"), base).String()})
|
||||
if err != nil {
|
||||
t.Skipf("cannot bind %d/%d: %v", base, base+1, err)
|
||||
}
|
||||
defer srv.Close()
|
||||
go srv.Serve()
|
||||
|
||||
client, err := net.DialUDP("udp", nil, srv.socks[0][0].conn.LocalAddr().(*net.UDPAddr))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
client.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
txid := NewTxID()
|
||||
client.Write(bindingRequest(txid, 0))
|
||||
buf := make([]byte, 1500)
|
||||
n, err := client.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mapped := parseXorMapped(t, buf[:n], txid)
|
||||
want := client.LocalAddr().(*net.UDPAddr).AddrPort()
|
||||
if mapped.Port() != want.Port() {
|
||||
t.Fatalf("mapped port %d, want %d", mapped.Port(), want.Port())
|
||||
}
|
||||
|
||||
// CHANGE-REQUEST(port): response must come from the alternate port.
|
||||
// Dial-connected sockets drop packets from other sources, so use an
|
||||
// unconnected socket and inspect the reply's source.
|
||||
uc, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer uc.Close()
|
||||
uc.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
txid2 := NewTxID()
|
||||
uc.WriteToUDPAddrPort(bindingRequest(txid2, changePort), srv.socks[0][0].addr)
|
||||
n, from, err := uc.ReadFromUDPAddrPort(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if from.Port() != srv.socks[0][1].addr.Port() {
|
||||
t.Fatalf("change-port reply came from %v, want alt port %d", from, srv.socks[0][1].addr.Port())
|
||||
}
|
||||
parseXorMapped(t, buf[:n], txid2)
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) uint16 {
|
||||
t.Helper()
|
||||
// Find two adjacent free ports for the primary/alternate pair.
|
||||
for tries := 0; tries < 20; tries++ {
|
||||
l, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p := l.LocalAddr().(*net.UDPAddr).Port
|
||||
l.Close()
|
||||
l2, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: p + 1})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
l2.Close()
|
||||
return uint16(p)
|
||||
}
|
||||
t.Skip("no adjacent free UDP ports found")
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user