server: Go skeleton — control plane, UDP data plane, Docker + systemd modes
Pure stdlib. Implements the spec's core: enrollment (single-use tokens), profile (SPKI pin, only real capabilities advertised), sessions with the §2.4 HKDF-SHA256 key schedule; UDP data plane with the 32-byte ELT1 header, 4-byte HMAC gate, 1024-wide anti-replay window, ECHO_RESP with observation block, TIMESYNC, and the §3.4 anti-amplification cap. Wire format has tests (roundtrip + silent-drop cases); enroll→profile→session smoke-tested live. Modes: container (autodetect /.dockerenv|/run/.containerenv|cgroup, or --docker/ECHOLOT_DOCKER=1; config via ECHOLOT_* env; distroless image; network_mode host required — Docker NAT would falsify observed sources) and native (--install-systemd/--uninstall-systemd with a hardened unit, opt-in --self-update from Gitea releases; refused in containers). CI: tests on any server/ push; server-v* tags build+push the image to the Gitea registry and attach linux amd64/arm64 binaries + SHA256SUMS to a release — the artifact self-update consumes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ee66648e3c
commit
8a80026d49
@@ -0,0 +1,171 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package dataplane implements the binary UDP probe protocol (spec §3):
|
||||
// 32-byte header, HMAC gate, anti-replay, ECHO with observation block.
|
||||
// Skeleton scope: ECHO_REQ/ECHO_RESP and TIMESYNC only; trains, MTU probes
|
||||
// and delayed echo land with the corresponding client tests.
|
||||
package dataplane
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"echo-lot.app/server/internal/session"
|
||||
)
|
||||
|
||||
const (
|
||||
Magic = "ELT1"
|
||||
HeaderSize = 32
|
||||
|
||||
TypeEchoReq = 0x01
|
||||
TypeEchoResp = 0x02
|
||||
TypeTimesyncReq = 0x07
|
||||
TypeTimesyncRsp = 0x08
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Sessions *session.Manager
|
||||
// Epoch for server-side t_rx/t_tx: process start; observation consumers
|
||||
// only need differences plus the timesync exchange, not absolute time.
|
||||
start time.Time
|
||||
}
|
||||
|
||||
func (s *Server) Serve(conn *net.UDPConn) error {
|
||||
s.start = time.Now()
|
||||
buf := make([]byte, 65535)
|
||||
oob := make([]byte, 0)
|
||||
_ = oob // TODO: recvmsg w/ IP_RECVTOS+IP_RECVTTL via golang.org/x/net for TTL/DSCP/ECN observation
|
||||
for {
|
||||
n, raddr, err := conn.ReadFromUDPAddrPort(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tRx := time.Since(s.start).Nanoseconds()
|
||||
s.handle(conn, raddr, buf[:n], tRx)
|
||||
}
|
||||
}
|
||||
|
||||
// handle enforces spec §3.1/§3.4: unknown prefix, bad HMAC, expired session,
|
||||
// replayed seq → silent drop, never a response.
|
||||
func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRxNs int64) {
|
||||
if len(pkt) < HeaderSize || string(pkt[0:4]) != Magic {
|
||||
return
|
||||
}
|
||||
typ := pkt[4]
|
||||
payloadLen := binary.BigEndian.Uint16(pkt[6:8])
|
||||
if int(HeaderSize+payloadLen) > len(pkt) {
|
||||
return
|
||||
}
|
||||
var prefix [8]byte
|
||||
copy(prefix[:], pkt[8:16])
|
||||
seq := binary.BigEndian.Uint32(pkt[16:20])
|
||||
|
||||
sess := s.Sessions.ByWirePrefix(prefix)
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
mac := hmac.New(sha256.New, sess.Key[:])
|
||||
mac.Write(pkt[0:28])
|
||||
mac.Write(pkt[HeaderSize : HeaderSize+int(payloadLen)])
|
||||
if !hmac.Equal(mac.Sum(nil)[:4], pkt[28:32]) {
|
||||
return
|
||||
}
|
||||
if !sess.CheckSeq(seq) {
|
||||
return
|
||||
}
|
||||
sess.NoteDataSource(raddr)
|
||||
|
||||
switch typ {
|
||||
case TypeEchoReq:
|
||||
s.echoResp(conn, raddr, sess, pkt, seq, tRxNs)
|
||||
case TypeTimesyncReq:
|
||||
s.timesyncResp(conn, raddr, sess, pkt, seq, tRxNs)
|
||||
default:
|
||||
slog.Debug("unhandled data-plane type", "type", typ)
|
||||
}
|
||||
}
|
||||
|
||||
// Observation block (spec §3.3), fixed 40 bytes appended to the RESP header:
|
||||
// 0 8 t_rx_ns (server clock, process epoch)
|
||||
// 8 8 t_tx_ns
|
||||
// 16 16 observed source IP (v4-mapped when v4)
|
||||
// 32 2 observed source port
|
||||
// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs)
|
||||
// 35 1 received DSCP/ECN byte (0xFF = not observed)
|
||||
// 36 4 received size
|
||||
func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int) []byte {
|
||||
b := make([]byte, 40)
|
||||
binary.BigEndian.PutUint64(b[0:8], uint64(tRxNs))
|
||||
binary.BigEndian.PutUint64(b[8:16], uint64(tTxNs))
|
||||
a16 := src.Addr().As16()
|
||||
copy(b[16:32], a16[:])
|
||||
binary.BigEndian.PutUint16(b[32:34], src.Port())
|
||||
b[34], b[35] = 0xFF, 0xFF
|
||||
binary.BigEndian.PutUint32(b[36:40], uint32(rcvd))
|
||||
return b
|
||||
}
|
||||
|
||||
// echoResp mirrors the request header (type flipped), appends the observation
|
||||
// block, and re-HMACs with the session key. Anti-amplification: the response
|
||||
// is capped at the request size (spec §3.4) — the observation block replaces
|
||||
// padding rather than growing the datagram; if the request was smaller than
|
||||
// header+observation, the block is truncated to fit.
|
||||
func (s *Server) echoResp(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, req []byte, seq uint32, tRxNs int64) {
|
||||
obs := observation(tRxNs, time.Since(s.start).Nanoseconds(), raddr, len(req))
|
||||
max := len(req)
|
||||
if max < HeaderSize {
|
||||
return
|
||||
}
|
||||
payload := obs
|
||||
if HeaderSize+len(payload) > max {
|
||||
payload = payload[:max-HeaderSize]
|
||||
}
|
||||
s.send(conn, raddr, sess, TypeEchoResp, seq, payload)
|
||||
}
|
||||
|
||||
// timesyncResp: payload = client t1 (echoed back) + t2 (rx) + t3 (tx), spec §3.2.
|
||||
func (s *Server) timesyncResp(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, req []byte, seq uint32, tRxNs int64) {
|
||||
payload := make([]byte, 24)
|
||||
copy(payload[0:8], req[20:28]) // client's t_ns from the request header
|
||||
binary.BigEndian.PutUint64(payload[8:16], uint64(tRxNs))
|
||||
binary.BigEndian.PutUint64(payload[16:24], uint64(time.Since(s.start).Nanoseconds()))
|
||||
s.send(conn, raddr, sess, TypeTimesyncRsp, seq, payload)
|
||||
}
|
||||
|
||||
func (s *Server) send(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, typ byte, seq uint32, payload []byte) {
|
||||
pkt := make([]byte, HeaderSize+len(payload))
|
||||
copy(pkt[0:4], Magic)
|
||||
pkt[4] = typ
|
||||
binary.BigEndian.PutUint16(pkt[6:8], uint16(len(payload)))
|
||||
idBytes := sess.ID[:16] // hex chars of the 8-byte prefix
|
||||
for i := 0; i < 8; i++ {
|
||||
pkt[8+i] = hexByte(idBytes[i*2], idBytes[i*2+1])
|
||||
}
|
||||
binary.BigEndian.PutUint32(pkt[16:20], seq)
|
||||
binary.BigEndian.PutUint64(pkt[20:28], uint64(time.Since(s.start).Nanoseconds()))
|
||||
copy(pkt[HeaderSize:], payload)
|
||||
mac := hmac.New(sha256.New, sess.Key[:])
|
||||
mac.Write(pkt[0:28])
|
||||
mac.Write(payload)
|
||||
copy(pkt[28:32], mac.Sum(nil)[:4])
|
||||
_, _ = conn.WriteToUDPAddrPort(pkt, raddr)
|
||||
}
|
||||
|
||||
func hexByte(hi, lo byte) byte {
|
||||
h := func(c byte) byte {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0'
|
||||
case c >= 'a' && c <= 'f':
|
||||
return c - 'a' + 10
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return h(hi)<<4 | h(lo)
|
||||
}
|
||||
Reference in New Issue
Block a user