Files
echolot/server/internal/stun/stun.go
T
mrambossekandClaude Opus 5 7b676e666e
server-test / test (push) Successful in 27s
server-release / image (push) Successful in 14s
server-release / release (push) Successful in 27s
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>
2026-07-31 19:53:36 +02:00

267 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}