[relay] Negotiate the protocol version on the server side (#8231)

This commit is contained in:
Zoltan Papp
2026-10-21 12:27:44 +02:00
committed by GitHub
parent aa0f569677
commit 1e63b68c4d
10 changed files with 1128 additions and 91 deletions
+37 -17
View File
@@ -8,6 +8,8 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric"
"github.com/netbirdio/netbird/shared/relay/messages"
) )
const ( const (
@@ -17,16 +19,17 @@ const (
type Metrics struct { type Metrics struct {
metric.Meter metric.Meter
TransferBytesSent metric.Int64Counter TransferBytesSent metric.Int64Counter
TransferBytesRecv metric.Int64Counter TransferBytesRecv metric.Int64Counter
AuthenticationTime metric.Float64Histogram AuthenticationTime metric.Float64Histogram
PeerStoreTime metric.Float64Histogram PeerStoreTime metric.Float64Histogram
peerReconnections metric.Int64Counter peerReconnections metric.Int64Counter
peers metric.Int64UpDownCounter peerProtocolVersions metric.Int64Counter
peerActivityChan chan string peers metric.Int64UpDownCounter
peerLastActive map[string]time.Time peerActivityChan chan string
mutexActivity sync.Mutex peerLastActive map[string]time.Time
ctx context.Context mutexActivity sync.Mutex
ctx context.Context
} }
func NewMetrics(ctx context.Context, meter metric.Meter) (*Metrics, error) { func NewMetrics(ctx context.Context, meter metric.Meter) (*Metrics, error) {
@@ -88,14 +91,22 @@ func NewMetrics(ctx context.Context, meter metric.Meter) (*Metrics, error) {
return nil, err return nil, err
} }
peerProtocolVersions, err := meter.Int64Counter("relay_peer_protocol_versions_total",
metric.WithDescription("Total number of peer connections by advertised and selected protocol version"),
)
if err != nil {
return nil, err
}
m := &Metrics{ m := &Metrics{
Meter: meter, Meter: meter,
TransferBytesSent: bytesSent, TransferBytesSent: bytesSent,
TransferBytesRecv: bytesRecv, TransferBytesRecv: bytesRecv,
AuthenticationTime: authTime, AuthenticationTime: authTime,
PeerStoreTime: peerStoreTime, PeerStoreTime: peerStoreTime,
peers: peers, peers: peers,
peerReconnections: peerReconnections, peerReconnections: peerReconnections,
peerProtocolVersions: peerProtocolVersions,
ctx: ctx, ctx: ctx,
peerActivityChan: make(chan string, 10), peerActivityChan: make(chan string, 10),
@@ -151,6 +162,15 @@ func (m *Metrics) RecordPeerReconnection() {
m.peerReconnections.Add(m.ctx, 1) m.peerReconnections.Add(m.ctx, 1)
} }
// RecordProtocolVersion counts a new peer connection by the protocol version the client advertised
// and the version the server selected for it
func (m *Metrics) RecordProtocolVersion(advertised, selected messages.ProtocolVersion) {
m.peerProtocolVersions.Add(m.ctx, 1, metric.WithAttributes(
attribute.Int("advertised", int(advertised)),
attribute.Int("selected", int(selected)),
))
}
// PeerActivity increases the active connections // PeerActivity increases the active connections
func (m *Metrics) PeerActivity(peerID string) { func (m *Metrics) PeerActivity(peerID string) {
select { select {
+42 -17
View File
@@ -19,20 +19,9 @@ type Validator interface {
Validate(any) error Validate(any) error
} }
// preparedMsg contains the marshalled success response message // preparedMsg contains the marshalled success response message for every supported protocol version
type preparedMsg struct { type preparedMsg struct {
responseAuthMsg []byte responseAuthMsg map[messages.ProtocolVersion][]byte
}
func newPreparedMsg(instanceURL string) (*preparedMsg, error) {
ram, err := messages.MarshalAuthResponse(instanceURL)
if err != nil {
return nil, fmt.Errorf("failed to marshal auth response msg: %w", err)
}
return &preparedMsg{
responseAuthMsg: ram,
}, nil
} }
type handshake struct { type handshake struct {
@@ -40,7 +29,35 @@ type handshake struct {
validator Validator validator Validator
preparedMsg *preparedMsg preparedMsg *preparedMsg
peerID *messages.PeerID peerID *messages.PeerID
advertisedVersion messages.ProtocolVersion
selectedVersion messages.ProtocolVersion
}
func newPreparedMsg(instanceURL string) (*preparedMsg, error) {
responses := make(map[messages.ProtocolVersion][]byte, messages.CurrentProtocolVersion)
for version := messages.ProtocolV1; ; version++ {
ram, err := messages.NewEncoder(version).AuthResponse(instanceURL)
if err != nil {
return nil, fmt.Errorf("marshal auth response for version %d: %w", version, err)
}
responses[version] = ram
if version == messages.CurrentProtocolVersion {
break
}
}
return &preparedMsg{
responseAuthMsg: responses,
}, nil
}
func (p *preparedMsg) authResponse(version messages.ProtocolVersion) ([]byte, error) {
msg, ok := p.responseAuthMsg[version]
if !ok {
return nil, fmt.Errorf("no prepared auth response for protocol version %d", version)
}
return msg, nil
} }
func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, error) { func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, error) {
@@ -52,9 +69,9 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err
buf = buf[:n] buf = buf[:n]
_, err = messages.ValidateVersion(buf) advertised, err := messages.ReadVersion(buf)
if err != nil { if err != nil {
return nil, fmt.Errorf("validate version from %s: %w", h.conn.RemoteAddr(), err) return nil, fmt.Errorf("read version from %s: %w", h.conn.RemoteAddr(), err)
} }
msgType, err := messages.DetermineClientMessageType(buf) msgType, err := messages.DetermineClientMessageType(buf)
@@ -70,12 +87,20 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err
if err != nil { if err != nil {
return peerID, err return peerID, err
} }
h.peerID = peerID h.peerID = peerID
h.advertisedVersion = advertised
h.selectedVersion = messages.SelectVersion(advertised, messages.CurrentProtocolVersion)
return peerID, nil return peerID, nil
} }
func (h *handshake) handshakeResponse(ctx context.Context) error { func (h *handshake) handshakeResponse(ctx context.Context) error {
if _, err := h.conn.Write(ctx, h.preparedMsg.responseAuthMsg); err != nil { msg, err := h.preparedMsg.authResponse(h.selectedVersion)
if err != nil {
return err
}
if _, err := h.conn.Write(ctx, msg); err != nil {
return fmt.Errorf("handshake response write to %s (%s): %w", h.peerID, h.conn.RemoteAddr(), err) return fmt.Errorf("handshake response write to %s (%s): %w", h.peerID, h.conn.RemoteAddr(), err)
} }
+378
View File
@@ -0,0 +1,378 @@
package server
import (
"context"
"encoding/binary"
"errors"
"net"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/relay/auth/allow"
"github.com/netbirdio/netbird/shared/relay/messages"
)
const (
testWaitTimeout = 2 * time.Second
// authTokenLenOffset is where the envelope's token length starts: header(2) + magic(4) + peer ID(36).
authTokenLenOffset = 2 + 4 + 36
)
// scriptedConn is a listener.Conn whose inbound frames are queued by the test and whose
// outbound frames are recorded.
type scriptedConn struct {
reads chan []byte
closed chan struct{}
closeOnce sync.Once
onWrite func([]byte)
mu sync.Mutex
writes [][]byte
}
type recordingValidator struct {
err error
mu sync.Mutex
payloads [][]byte
}
func newScriptedConn() *scriptedConn {
return &scriptedConn{
reads: make(chan []byte, 16),
closed: make(chan struct{}),
}
}
func (c *scriptedConn) Read(ctx context.Context, b []byte) (int, error) {
select {
case msg := <-c.reads:
return copy(b, msg), nil
case <-c.closed:
return 0, net.ErrClosed
case <-ctx.Done():
return 0, ctx.Err()
}
}
func (c *scriptedConn) Write(_ context.Context, b []byte) (int, error) {
select {
case <-c.closed:
return 0, net.ErrClosed
default:
}
frame := append([]byte(nil), b...)
c.mu.Lock()
c.writes = append(c.writes, frame)
c.mu.Unlock()
if c.onWrite != nil {
c.onWrite(frame)
}
return len(b), nil
}
func (c *scriptedConn) RemoteAddr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1}
}
func (c *scriptedConn) Close() error {
c.closeOnce.Do(func() { close(c.closed) })
return nil
}
func (c *scriptedConn) Protocol() string {
return "test"
}
func (c *scriptedConn) deliver(t *testing.T, frame []byte) {
t.Helper()
select {
case c.reads <- frame:
case <-time.After(testWaitTimeout):
t.Fatal("scripted connection read queue is full")
}
}
func (c *scriptedConn) isClosed() bool {
select {
case <-c.closed:
return true
default:
return false
}
}
func (c *scriptedConn) frames() [][]byte {
c.mu.Lock()
defer c.mu.Unlock()
return append([][]byte(nil), c.writes...)
}
func (c *scriptedConn) waitFrames(t *testing.T, n int) [][]byte {
t.Helper()
deadline := time.Now().Add(testWaitTimeout)
for {
frames := c.frames()
if len(frames) >= n {
return frames
}
if time.Now().After(deadline) {
t.Fatalf("expected %d written frames, got %d", n, len(frames))
}
time.Sleep(5 * time.Millisecond)
}
}
func (c *scriptedConn) waitClosed(t *testing.T) {
t.Helper()
select {
case <-c.closed:
case <-time.After(testWaitTimeout):
t.Fatal("connection was not closed")
}
}
func (v *recordingValidator) Validate(credentials any) error {
v.mu.Lock()
defer v.mu.Unlock()
payload, _ := credentials.([]byte)
v.payloads = append(v.payloads, append([]byte(nil), payload...))
return v.err
}
func (v *recordingValidator) calls() [][]byte {
v.mu.Lock()
defer v.mu.Unlock()
return append([][]byte(nil), v.payloads...)
}
func TestRelay_AuthV1ResponseIsUnchanged(t *testing.T) {
validator := &recordingValidator{}
relay := newTestRelay(t, validator)
peerID := messages.HashID("v1-client")
token := []byte("v1-token")
auth, err := messages.MarshalAuthMsg(peerID, token)
require.NoError(t, err)
conn := newScriptedConn()
conn.deliver(t, auth)
relay.Accept(conn)
frames := conn.waitFrames(t, 1)
want, err := messages.MarshalAuthResponse(relay.instanceURL.String())
require.NoError(t, err)
assert.Equal(t, want, frames[0], "a v1 client must receive the same response bytes as today")
assert.Equal(t, [][]byte{token}, validator.calls(), "the validator must receive the v1 token bytes")
assert.False(t, conn.isClosed(), "an authenticated v1 connection must stay open")
item, ok := relay.store.Peer(peerID)
require.True(t, ok, "the peer must be stored after the response")
assert.Equal(t, messages.ProtocolV1, item.(*Peer).Version(), "a v1 client selects v1")
}
func TestRelay_AuthEnvelopeSelectsV1(t *testing.T) {
cases := []struct {
name string
version messages.ProtocolVersion
ext []byte
}{
{"v2 without extension", 2, nil},
{"v2 with json extension", 2, []byte(`{"cred2":"value"}`)},
{"v255 with opaque extension", 255, []byte("opaque-extension-bytes")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
validator := &recordingValidator{}
relay := newTestRelay(t, validator)
peerID := messages.HashID("future-client")
token := []byte("future-token")
auth, err := messages.MarshalAuthEnvelope(tc.version, peerID, token, tc.ext)
require.NoError(t, err)
conn := newScriptedConn()
conn.deliver(t, auth)
relay.Accept(conn)
frames := conn.waitFrames(t, 1)
want, err := messages.MarshalAuthResponse(relay.instanceURL.String())
require.NoError(t, err)
assert.Equal(t, want, frames[0], "the response must carry the selected v1 header")
assert.Equal(t, [][]byte{token}, validator.calls(), "the validator must receive the token without the extension")
assert.False(t, conn.isClosed(), "a higher advertised version must not be rejected")
item, ok := relay.store.Peer(peerID)
require.True(t, ok, "the peer must be stored after the response")
assert.Equal(t, messages.ProtocolV1, item.(*Peer).Version(), "the connection must use the selected v1")
})
}
}
func TestRelay_AuthRejectsInvalidFirstFrame(t *testing.T) {
peerID := messages.HashID("client")
validEnvelope, err := messages.MarshalAuthEnvelope(2, peerID, []byte("token"), []byte("ext"))
require.NoError(t, err)
validV1, err := messages.MarshalAuthMsg(peerID, []byte("token"))
require.NoError(t, err)
transportFirst, err := messages.MarshalTransportMsg(peerID, []byte("payload"))
require.NoError(t, err)
versionZero := append([]byte(nil), validV1...)
versionZero[0] = 0
badMagic := append([]byte(nil), validEnvelope...)
badMagic[2] ^= 0xFF
zeroTokenLen := append([]byte(nil), validEnvelope...)
binary.BigEndian.PutUint16(zeroTokenLen[authTokenLenOffset:], 0)
tokenLenPastEnd := append([]byte(nil), validEnvelope...)
binary.BigEndian.PutUint16(tokenLenPastEnd[authTokenLenOffset:], uint16(len(validEnvelope)))
cases := []struct {
name string
frame []byte
}{
{"version zero", versionZero},
{"reserved hello type", []byte{byte(messages.ProtocolV1), byte(messages.MsgTypeHello)}},
{"unknown type before auth", []byte{byte(messages.ProtocolV1), 200}},
{"transport before auth", transportFirst},
{"bad magic in envelope", badMagic},
{"zero token length", zeroTokenLen},
{"token length past the frame", tokenLenPastEnd},
{"envelope truncated at the length field", validEnvelope[:authTokenLenOffset+1]},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
validator := &recordingValidator{}
relay := newTestRelay(t, validator)
conn := newScriptedConn()
conn.deliver(t, tc.frame)
relay.Accept(conn)
assert.True(t, conn.isClosed(), "the connection must be closed")
assert.Empty(t, conn.frames(), "no response may be written")
assert.Empty(t, validator.calls(), "the validator must not run")
_, ok := relay.store.Peer(peerID)
assert.False(t, ok, "the peer must not be stored")
})
}
}
func TestRelay_AuthRejectsInvalidToken(t *testing.T) {
peerID := messages.HashID("client")
v1Auth, err := messages.MarshalAuthMsg(peerID, []byte("token"))
require.NoError(t, err)
envelopeAuth, err := messages.MarshalAuthEnvelope(2, peerID, []byte("token"), []byte(`{"ok":true}`))
require.NoError(t, err)
cases := []struct {
name string
frame []byte
}{
{"v1 layout", v1Auth},
{"envelope with well-formed extension", envelopeAuth},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
validator := &recordingValidator{err: errors.New("invalid token")}
relay := newTestRelay(t, validator)
conn := newScriptedConn()
conn.deliver(t, tc.frame)
relay.Accept(conn)
assert.True(t, conn.isClosed(), "the connection must be closed")
assert.Empty(t, conn.frames(), "no response may be written")
assert.Len(t, validator.calls(), 1, "the validator must run exactly once")
_, ok := relay.store.Peer(peerID)
assert.False(t, ok, "the peer must not be stored")
})
}
}
func TestRelay_AuthResponsePrecedesPublish(t *testing.T) {
relay := newTestRelay(t, &allow.Auth{})
peerID := messages.HashID("client")
onlineEvents := make(chan []messages.PeerID, 1)
watcher := relay.notifier.NewListener(func(ids []messages.PeerID) { onlineEvents <- ids }, func([]messages.PeerID) {})
t.Cleanup(func() { relay.notifier.RemoveListener(watcher) })
watcher.AddInterestedPeers([]messages.PeerID{peerID})
var storedAtResponse, notifiedAtResponse bool
conn := newScriptedConn()
conn.onWrite = func(frame []byte) {
if frame[1] != byte(messages.MsgTypeAuthResponse) {
return
}
_, storedAtResponse = relay.store.Peer(peerID)
notifiedAtResponse = len(onlineEvents) > 0
}
auth, err := messages.MarshalAuthMsg(peerID, []byte("token"))
require.NoError(t, err)
conn.deliver(t, auth)
relay.Accept(conn)
conn.waitFrames(t, 1)
assert.False(t, storedAtResponse, "the peer must not be in the store when the response is written")
assert.False(t, notifiedAtResponse, "no online notification may precede the response")
select {
case ids := <-onlineEvents:
assert.Equal(t, []messages.PeerID{peerID}, ids, "the peer must be published after the response")
case <-time.After(testWaitTimeout):
t.Fatal("the peer was not published after the response")
}
}
func TestRelay_ReconnectClosesPreviousConnection(t *testing.T) {
relay := newTestRelay(t, &allow.Auth{})
first, peerID := acceptPeer(t, relay, "client")
auth, err := messages.MarshalAuthMsg(peerID, []byte("token"))
require.NoError(t, err)
second := newScriptedConn()
second.deliver(t, auth)
relay.Accept(second)
second.waitFrames(t, 1)
first.waitClosed(t)
assert.False(t, second.isClosed(), "the reconnecting client must keep its new connection")
item, ok := relay.store.Peer(peerID)
require.True(t, ok, "the peer must stay stored after a reconnect")
assert.Same(t, second, item.(*Peer).conn, "the store must hold the new connection")
}
func newTestRelay(t *testing.T, validator Validator) *Relay {
t.Helper()
relay, err := NewRelay(Config{
ExposedAddress: "rel://127.0.0.1:1234",
AuthValidator: validator,
})
require.NoError(t, err)
t.Cleanup(func() { relay.Shutdown(context.Background()) })
return relay
}
func acceptPeer(t *testing.T, relay *Relay, name string) (*scriptedConn, messages.PeerID) {
t.Helper()
peerID := messages.HashID(name)
auth, err := messages.MarshalAuthMsg(peerID, []byte("token"))
require.NoError(t, err)
conn := newScriptedConn()
conn.deliver(t, auth)
relay.Accept(conn)
conn.waitFrames(t, 1)
return conn, peerID
}
+21 -13
View File
@@ -27,6 +27,8 @@ type Peer struct {
metrics *metrics.Metrics metrics *metrics.Metrics
log *log.Entry log *log.Entry
id messages.PeerID id messages.PeerID
version messages.ProtocolVersion
enc messages.Encoder
conn listener.Conn conn listener.Conn
connMu sync.RWMutex connMu sync.RWMutex
store *store.Store store *store.Store
@@ -42,12 +44,14 @@ type Peer struct {
} }
// NewPeer creates a new Peer instance and prepare custom logging // NewPeer creates a new Peer instance and prepare custom logging
func NewPeer(metrics *metrics.Metrics, id messages.PeerID, conn listener.Conn, store *store.Store, notifier *store.PeerNotifier) *Peer { func NewPeer(metrics *metrics.Metrics, id messages.PeerID, version messages.ProtocolVersion, conn listener.Conn, store *store.Store, notifier *store.PeerNotifier) *Peer {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
p := &Peer{ p := &Peer{
metrics: metrics, metrics: metrics,
log: log.WithField("peer_id", id.String()), log: log.WithField("peer_id", id.String()),
id: id, id: id,
version: version,
enc: messages.NewEncoder(version),
conn: conn, conn: conn,
store: store, store: store,
notifier: notifier, notifier: notifier,
@@ -95,16 +99,15 @@ func (p *Peer) Work() {
msg := buf[:n] msg := buf[:n]
_, err = messages.ValidateVersion(msg) if err := messages.ValidateConnectionVersion(msg, p.version); err != nil {
if err != nil { p.log.Tracef("drop message: %s", err)
p.log.Warnf("failed to validate protocol version: %s", err) continue
return
} }
msgType, err := messages.DetermineClientMessageType(msg) msgType, err := messages.DetermineClientMessageType(msg)
if err != nil { if err != nil {
p.log.Errorf("failed to determine message type: %s", err) p.log.Tracef("drop message: %s", err)
return continue
} }
p.handleMsgType(ctx, msgType, hc, n, msg) p.handleMsgType(ctx, msgType, hc, n, msg)
@@ -115,6 +118,11 @@ func (p *Peer) ID() messages.PeerID {
return p.id return p.id
} }
// Version returns the protocol version selected for this connection
func (p *Peer) Version() messages.ProtocolVersion {
return p.version
}
func (p *Peer) handleMsgType(ctx context.Context, msgType messages.MsgType, hc *healthcheck.Sender, n int, msg []byte) { func (p *Peer) handleMsgType(ctx context.Context, msgType messages.MsgType, hc *healthcheck.Sender, n int, msg []byte) {
switch msgType { switch msgType {
case messages.MsgTypeHealthCheck: case messages.MsgTypeHealthCheck:
@@ -133,7 +141,7 @@ func (p *Peer) handleMsgType(ctx context.Context, msgType messages.MsgType, hc *
case messages.MsgTypeUnsubscribePeerState: case messages.MsgTypeUnsubscribePeerState:
p.handleUnsubscribePeerState(msg) p.handleUnsubscribePeerState(msg)
default: default:
p.log.Warnf("received unexpected message type: %s", msgType) p.log.Tracef("drop %s message: not allowed after authentication", msgType)
} }
} }
@@ -149,7 +157,7 @@ func (p *Peer) Write(ctx context.Context, b []byte) (int, error) {
func (p *Peer) CloseGracefully(ctx context.Context) { func (p *Peer) CloseGracefully(ctx context.Context) {
p.connMu.Lock() p.connMu.Lock()
defer p.connMu.Unlock() defer p.connMu.Unlock()
err := p.writeWithTimeout(ctx, messages.MarshalCloseMsg()) err := p.writeWithTimeout(ctx, p.enc.CloseMsg())
if err != nil { if err != nil {
p.log.Errorf("failed to send close message to peer: %s", p.String()) p.log.Errorf("failed to send close message to peer: %s", p.String())
} }
@@ -187,7 +195,7 @@ func (p *Peer) handleHealthcheckEvents(ctx context.Context, hc *healthcheck.Send
for { for {
select { select {
case <-hc.HealthCheck: case <-hc.HealthCheck:
_, err := p.Write(ctx, messages.MarshalHealthcheck()) _, err := p.Write(ctx, p.enc.Healthcheck())
if err != nil { if err != nil {
p.log.Errorf("failed to send healthcheck message: %s", err) p.log.Errorf("failed to send healthcheck message: %s", err)
return return
@@ -220,7 +228,7 @@ func (p *Peer) handleTransportMsg(msg []byte) {
} }
dp := item.(*Peer) dp := item.(*Peer)
err = messages.UpdateTransportMsg(msg, p.id) err = messages.UpdateTransportMsg(msg, dp.version, p.id)
if err != nil { if err != nil {
p.log.Errorf("failed to update transport message: %s", err) p.log.Errorf("failed to update transport message: %s", err)
return return
@@ -267,7 +275,7 @@ func (p *Peer) handleUnsubscribePeerState(msg []byte) {
} }
func (p *Peer) sendPeersOnline(peers []messages.PeerID) { func (p *Peer) sendPeersOnline(peers []messages.PeerID) {
msgs, err := messages.MarshalPeersOnline(peers) msgs, err := p.enc.PeersOnline(peers)
if err != nil { if err != nil {
p.log.Errorf("failed to marshal peer location message: %s", err) p.log.Errorf("failed to marshal peer location message: %s", err)
return return
@@ -284,7 +292,7 @@ func (p *Peer) sendPeersWentOffline(peers []messages.PeerID) {
p.notificationMutex.Lock() p.notificationMutex.Lock()
defer p.notificationMutex.Unlock() defer p.notificationMutex.Unlock()
msgs, err := messages.MarshalPeersWentOffline(peers) msgs, err := p.enc.PeersWentOffline(peers)
if err != nil { if err != nil {
p.log.Errorf("failed to marshal peer location message: %s", err) p.log.Errorf("failed to marshal peer location message: %s", err)
return return
+99
View File
@@ -0,0 +1,99 @@
package server
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/relay/auth/allow"
"github.com/netbirdio/netbird/shared/relay/messages"
)
func TestPeer_DropsFramesItCannotHandle(t *testing.T) {
secondAuth, err := messages.MarshalAuthMsg(messages.HashID("alice"), []byte("token"))
require.NoError(t, err)
cases := []struct {
name string
frame []byte
}{
{"unknown message type", []byte{byte(messages.ProtocolV1), 200, 0xDE, 0xAD}},
{"version above the selected one", []byte{2, byte(messages.MsgTypeHealthCheck)}},
{"version zero", []byte{0, byte(messages.MsgTypeHealthCheck)}},
{"header shorter than two bytes", []byte{byte(messages.ProtocolV1)}},
{"reserved hello type", []byte{byte(messages.ProtocolV1), byte(messages.MsgTypeHello)}},
{"server to client peers online type", []byte{byte(messages.ProtocolV1), byte(messages.MsgTypePeersOnline)}},
{"auth after authentication", secondAuth},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
relay := newTestRelay(t, &allow.Auth{})
alice, _ := acceptPeer(t, relay, "alice")
_, bobID := acceptPeer(t, relay, "bob")
alice.deliver(t, tc.frame)
subscribe, err := messages.MarshalSubPeerStateMsg([]messages.PeerID{bobID})
require.NoError(t, err)
alice.deliver(t, subscribe[0])
// The subscription answer proves the loop survived the dropped frame before it.
frames := alice.waitFrames(t, 2)
assert.False(t, alice.isClosed(), "a dropped frame must not close the connection")
online := frames[1]
assert.Equal(t, byte(messages.ProtocolV1), online[0], "notifications must carry the connection version")
assert.Equal(t, byte(messages.MsgTypePeersOnline), online[1], "the subscription must be answered")
ids, err := messages.UnmarshalPeersOnlineMsg(online)
require.NoError(t, err)
assert.Equal(t, []messages.PeerID{bobID}, ids, "the online peer must be reported")
})
}
}
func TestPeer_ClosesOnClientCloseMessage(t *testing.T) {
relay := newTestRelay(t, &allow.Auth{})
conn, peerID := acceptPeer(t, relay, "alice")
conn.deliver(t, messages.MarshalCloseMsg())
conn.waitClosed(t)
assert.Len(t, conn.frames(), 1, "only the auth response may be written")
assert.Eventually(t, func() bool {
_, ok := relay.store.Peer(peerID)
return !ok
}, testWaitTimeout, 5*time.Millisecond, "the peer must be removed from the store")
}
func TestPeer_ForwardsTransportWithSenderAndDestinationVersion(t *testing.T) {
relay := newTestRelay(t, &allow.Auth{})
alice, aliceID := acceptPeer(t, relay, "alice")
bob, bobID := acceptPeer(t, relay, "bob")
payload := []byte("hello bob")
transport, err := messages.MarshalTransportMsg(bobID, payload)
require.NoError(t, err)
alice.deliver(t, transport)
frames := bob.waitFrames(t, 2)
forwarded := frames[1]
assert.Equal(t, byte(messages.ProtocolV1), forwarded[0], "the forwarded frame must carry the destination's version")
assert.Equal(t, byte(messages.MsgTypeTransport), forwarded[1], "the forwarded frame must stay a transport message")
senderID, gotPayload, err := messages.UnmarshalTransportMsg(forwarded)
require.NoError(t, err)
assert.Equal(t, aliceID, *senderID, "the peer ID must be rewritten to the sender")
assert.Equal(t, payload, gotPayload, "the payload must be preserved")
assert.False(t, alice.isClosed(), "the sender must stay connected")
}
func TestPeer_GracefulCloseCarriesConnectionVersion(t *testing.T) {
relay := newTestRelay(t, &allow.Auth{})
conn, _ := acceptPeer(t, relay, "alice")
relay.Shutdown(context.Background())
frames := conn.waitFrames(t, 2)
assert.Equal(t, messages.NewEncoder(messages.ProtocolV1).CloseMsg(), frames[1], "the close message must carry the connection version")
assert.True(t, conn.isClosed(), "the connection must be closed after the close message")
}
+12 -8
View File
@@ -150,8 +150,17 @@ func (r *Relay) Accept(conn listener.Conn) {
return return
} }
peer := NewPeer(r.metrics, *peerID, conn, r.store, r.notifier) if err := h.handshakeResponse(hsCtx); err != nil {
peer.log.Infof("peer connected from: %s", conn.RemoteAddr()) log.Errorf("failed to send handshake response, close connection: %s", err)
if cErr := conn.Close(); cErr != nil {
log.Errorf("failed to close connection, %s: %s", conn.RemoteAddr(), cErr)
}
return
}
r.metrics.RecordAuthenticationTime(time.Since(acceptTime))
peer := NewPeer(r.metrics, *peerID, h.selectedVersion, conn, r.store, r.notifier)
peer.log.Infof("peer connected from: %s, advertised protocol version: %d, selected: %d", conn.RemoteAddr(), h.advertisedVersion, h.selectedVersion)
storeTime := time.Now() storeTime := time.Now()
if isReconnection := r.store.AddPeer(peer); isReconnection { if isReconnection := r.store.AddPeer(peer); isReconnection {
r.metrics.RecordPeerReconnection() r.metrics.RecordPeerReconnection()
@@ -161,6 +170,7 @@ func (r *Relay) Accept(conn listener.Conn) {
transport := conn.Protocol() transport := conn.Protocol()
r.metrics.RecordPeerStoreTime(time.Since(storeTime)) r.metrics.RecordPeerStoreTime(time.Since(storeTime))
r.metrics.PeerConnected(peer.String(), transport) r.metrics.PeerConnected(peer.String(), transport)
r.metrics.RecordProtocolVersion(h.advertisedVersion, h.selectedVersion)
go func() { go func() {
peer.Work() peer.Work()
if deleted := r.store.DeletePeer(peer); deleted { if deleted := r.store.DeletePeer(peer); deleted {
@@ -169,12 +179,6 @@ func (r *Relay) Accept(conn listener.Conn) {
peer.log.Debugf("relay connection closed") peer.log.Debugf("relay connection closed")
r.metrics.PeerDisconnected(peer.String(), transport) r.metrics.PeerDisconnected(peer.String(), transport)
}() }()
if err := h.handshakeResponse(hsCtx); err != nil {
log.Errorf("failed to send handshake response, close peer: %s", err)
peer.Close()
}
r.metrics.RecordAuthenticationTime(time.Since(acceptTime))
} }
// Shutdown closes the relay server // Shutdown closes the relay server
+97
View File
@@ -0,0 +1,97 @@
package server
import (
"context"
"testing"
"time"
"github.com/coder/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/relay"
"github.com/netbirdio/netbird/shared/relay/messages"
)
func TestServer_FutureClientOverWebSocket(t *testing.T) {
addr := freeAddress(t)
srv := newTestServer(t, addr)
errChan := make(chan error, 1)
go func() {
errChan <- srv.Listen(ListenerConfig{Address: addr})
}()
waitForListeners(t, srv, errChan)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
instanceURL := srv.InstanceURL()
want, err := messages.MarshalAuthResponse(instanceURL.String())
require.NoError(t, err)
// A synthetic client advertising v2 with an extension area that a v1 server does not read.
future := dialRelayWebSocket(t, ctx, addr)
futureID := messages.HashID("future-client")
futureAuth, err := messages.MarshalAuthEnvelope(2, futureID, []byte("future-token"), []byte(`{"cred2":"value"}`))
require.NoError(t, err)
require.NoError(t, future.Write(ctx, websocket.MessageBinary, futureAuth))
assert.Equal(t, want, readFrame(t, ctx, future), "a v2 client must receive the v1 response")
legacy := dialRelayWebSocket(t, ctx, addr)
legacyID := messages.HashID("legacy-client")
legacyAuth, err := messages.MarshalAuthMsg(legacyID, []byte("legacy-token"))
require.NoError(t, err)
require.NoError(t, legacy.Write(ctx, websocket.MessageBinary, legacyAuth))
assert.Equal(t, want, readFrame(t, ctx, legacy), "a v1 client must receive the response it gets today")
toLegacy, err := messages.MarshalTransportMsg(legacyID, []byte("from future"))
require.NoError(t, err)
require.NoError(t, future.Write(ctx, websocket.MessageBinary, toLegacy))
assertTransportFrame(t, readFrame(t, ctx, legacy), futureID, "from future")
toFuture, err := messages.MarshalTransportMsg(futureID, []byte("from legacy"))
require.NoError(t, err)
require.NoError(t, legacy.Write(ctx, websocket.MessageBinary, toFuture))
assertTransportFrame(t, readFrame(t, ctx, future), legacyID, "from legacy")
// After the response the connection is v1. A frame with another version is dropped and the
// connection stays usable, which the transport message right after it proves.
require.NoError(t, future.Write(ctx, websocket.MessageBinary, []byte{2, byte(messages.MsgTypeHealthCheck)}))
toLegacyAgain, err := messages.MarshalTransportMsg(legacyID, []byte("after the dropped frame"))
require.NoError(t, err)
require.NoError(t, future.Write(ctx, websocket.MessageBinary, toLegacyAgain))
assertTransportFrame(t, readFrame(t, ctx, legacy), futureID, "after the dropped frame")
require.NoError(t, future.Write(ctx, websocket.MessageBinary, messages.MarshalCloseMsg()))
require.NoError(t, legacy.Write(ctx, websocket.MessageBinary, messages.MarshalCloseMsg()))
}
func dialRelayWebSocket(t *testing.T, ctx context.Context, addr string) *websocket.Conn {
t.Helper()
conn, resp, err := websocket.Dial(ctx, "ws://"+addr+relay.WebSocketURLPath, nil) //nolint:bodyclose
require.NoError(t, err)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
t.Cleanup(func() { _ = conn.CloseNow() })
return conn
}
func readFrame(t *testing.T, ctx context.Context, conn *websocket.Conn) []byte {
t.Helper()
msgType, frame, err := conn.Read(ctx)
require.NoError(t, err)
require.Equal(t, websocket.MessageBinary, msgType, "relay frames are binary messages")
return frame
}
func assertTransportFrame(t *testing.T, frame []byte, sender messages.PeerID, payload string) {
t.Helper()
require.GreaterOrEqual(t, len(frame), 2, "a frame must carry the protocol header")
assert.Equal(t, byte(messages.ProtocolV1), frame[0], "the forwarded frame must carry the destination's version")
assert.Equal(t, byte(messages.MsgTypeTransport), frame[1], "the forwarded frame must be a transport message")
id, got, err := messages.UnmarshalTransportMsg(frame)
require.NoError(t, err)
assert.Equal(t, sender, *id, "the peer ID must be rewritten to the sender")
assert.Equal(t, payload, string(got), "the payload must be preserved")
}
+190 -30
View File
@@ -2,16 +2,27 @@ package messages
import ( import (
"bytes" "bytes"
"encoding/binary"
"errors" "errors"
"fmt" "fmt"
"math"
) )
const ( const (
MaxHandshakeSize = 212 // TODO: MaxHandshakeSize is the buffer size of the first read on every connection before
// authentication. Weigh it against memory use under many unauthenticated connections; a
// smaller limit may still fit the extension area.
MaxHandshakeSize = 8192
MaxHandshakeRespSize = 8192 MaxHandshakeRespSize = 8192
MaxMessageSize = 8820 MaxMessageSize = 8820
CurrentProtocolVersion = 1 ProtocolV1 ProtocolVersion = 1
// CurrentProtocolVersion is the highest protocol version this build supports.
CurrentProtocolVersion = ProtocolV1
// authEnvelopeMinVersion is the first advertised version whose Auth message carries a
// length-prefixed token followed by an extension area.
authEnvelopeMinVersion ProtocolVersion = 2
MsgTypeUnknown MsgType = 0 MsgTypeUnknown MsgType = 0
// MsgTypeHello and MsgTypeHelloResponse are the removed legacy handshake // MsgTypeHello and MsgTypeHelloResponse are the removed legacy handshake
@@ -38,10 +49,13 @@ const (
// auth message // auth message
sizeOfMagicByte = 4 sizeOfMagicByte = 4
sizeOfTokenLen = 2
headerSizeAuth = sizeOfMagicByte + peerIDSize headerSizeAuth = sizeOfMagicByte + peerIDSize
offsetMagicByte = sizeOfProtoHeader offsetMagicByte = sizeOfProtoHeader
offsetAuthPeerID = sizeOfProtoHeader + sizeOfMagicByte offsetAuthPeerID = sizeOfProtoHeader + sizeOfMagicByte
headerTotalSizeAuth = sizeOfProtoHeader + headerSizeAuth headerTotalSizeAuth = sizeOfProtoHeader + headerSizeAuth
offsetAuthTokenLen = headerTotalSizeAuth
offsetAuthToken = offsetAuthTokenLen + sizeOfTokenLen
// transport // transport
headerSizeTransport = peerIDSize headerSizeTransport = peerIDSize
@@ -52,6 +66,10 @@ const (
var ( var (
ErrInvalidMessageLength = errors.New("invalid message length") ErrInvalidMessageLength = errors.New("invalid message length")
ErrUnsupportedVersion = errors.New("unsupported version") ErrUnsupportedVersion = errors.New("unsupported version")
ErrInvalidTokenLength = errors.New("invalid token length")
ErrReservedMsgType = errors.New("reserved message type")
ErrMsgTypeDirection = errors.New("message type not valid from client")
ErrUnknownMsgType = errors.New("unknown message type")
magicHeader = []byte{0x21, 0x12, 0xA4, 0x42} magicHeader = []byte{0x21, 0x12, 0xA4, 0x42}
@@ -60,6 +78,19 @@ var (
type MsgType byte type MsgType byte
// ProtocolVersion is the relay wire protocol version carried in the first byte of every frame.
type ProtocolVersion uint8
// Encoder marshals server-originated messages with the protocol version selected for one connection.
type Encoder struct {
version ProtocolVersion
}
// NewEncoder returns an Encoder that stamps every message with the given protocol version.
func NewEncoder(version ProtocolVersion) Encoder {
return Encoder{version: version}
}
func (m MsgType) String() string { func (m MsgType) String() string {
switch m { switch m {
case MsgTypeHello: case MsgTypeHello:
@@ -89,18 +120,99 @@ func (m MsgType) String() string {
} }
} }
// ValidateVersion checks if the given version is supported by the protocol // Version returns the protocol version the Encoder stamps on its messages.
func ValidateVersion(msg []byte) (int, error) { func (e Encoder) Version() ProtocolVersion {
return e.version
}
// AuthResponse creates the response to a successful Auth message. The payload is the instance URL
// of the server, which the clients use to choose a common Relay server.
func (e Encoder) AuthResponse(address string) ([]byte, error) {
ab := []byte(address)
msg := make([]byte, sizeOfProtoHeader, sizeOfProtoHeader+len(ab))
msg[0] = byte(e.version)
msg[1] = byte(MsgTypeAuthResponse)
msg = append(msg, ab...)
if len(msg) > MaxHandshakeRespSize {
return nil, fmt.Errorf("invalid message length: %d", len(msg))
}
return msg, nil
}
// CloseMsg creates a close message for a graceful disconnect.
func (e Encoder) CloseMsg() []byte {
return []byte{
byte(e.version),
byte(MsgTypeClose),
}
}
// Healthcheck creates a health check message.
func (e Encoder) Healthcheck() []byte {
return []byte{
byte(e.version),
byte(MsgTypeHealthCheck),
}
}
// PeersOnline creates the peers online notification messages.
func (e Encoder) PeersOnline(ids []PeerID) ([][]byte, error) {
return marshalPeerIDs(e.version, ids, byte(MsgTypePeersOnline))
}
// PeersWentOffline creates the peers went offline notification messages.
func (e Encoder) PeersWentOffline(ids []PeerID) ([][]byte, error) {
return marshalPeerIDs(e.version, ids, byte(MsgTypePeersWentOffline))
}
// ReadVersion returns the protocol version carried in the frame header. Zero is invalid.
func ReadVersion(msg []byte) (ProtocolVersion, error) {
if len(msg) < sizeOfProtoHeader { if len(msg) < sizeOfProtoHeader {
return 0, ErrInvalidMessageLength return 0, ErrInvalidMessageLength
} }
version := int(msg[0]) version := ProtocolVersion(msg[0])
if version != CurrentProtocolVersion { if version == 0 {
return 0, fmt.Errorf("%d: %w", version, ErrUnsupportedVersion) return 0, fmt.Errorf("%d: %w", version, ErrUnsupportedVersion)
} }
return version, nil return version, nil
} }
// SelectVersion returns the highest protocol version both sides support.
func SelectVersion(advertised, supported ProtocolVersion) ProtocolVersion {
if advertised < supported {
return advertised
}
return supported
}
// ValidateVersion checks that the frame carries the protocol version of this build.
func ValidateVersion(msg []byte) (int, error) {
version, err := ReadVersion(msg)
if err != nil {
return 0, err
}
if version != CurrentProtocolVersion {
return 0, fmt.Errorf("%d: %w", version, ErrUnsupportedVersion)
}
return int(version), nil
}
// ValidateConnectionVersion checks that the frame carries the version selected for the connection.
func ValidateConnectionVersion(msg []byte, selected ProtocolVersion) error {
version, err := ReadVersion(msg)
if err != nil {
return err
}
if version != selected {
return fmt.Errorf("%d instead of selected %d: %w", version, selected, ErrUnsupportedVersion)
}
return nil
}
// DetermineClientMessageType determines the message type from the first the message // DetermineClientMessageType determines the message type from the first the message
func DetermineClientMessageType(msg []byte) (MsgType, error) { func DetermineClientMessageType(msg []byte) (MsgType, error) {
if len(msg) < sizeOfProtoHeader { if len(msg) < sizeOfProtoHeader {
@@ -117,8 +229,12 @@ func DetermineClientMessageType(msg []byte) (MsgType, error) {
MsgTypeSubscribePeerState, MsgTypeSubscribePeerState,
MsgTypeUnsubscribePeerState: MsgTypeUnsubscribePeerState:
return msgType, nil return msgType, nil
case MsgTypeHello, MsgTypeHelloResponse:
return MsgTypeUnknown, fmt.Errorf("%d: %w", msgType, ErrReservedMsgType)
case MsgTypeAuthResponse, MsgTypePeersOnline, MsgTypePeersWentOffline:
return MsgTypeUnknown, fmt.Errorf("%d: %w", msgType, ErrMsgTypeDirection)
default: default:
return MsgTypeUnknown, fmt.Errorf("invalid msg type %d", msgType) return MsgTypeUnknown, fmt.Errorf("%d: %w", msgType, ErrUnknownMsgType)
} }
} }
@@ -162,8 +278,41 @@ func MarshalAuthMsg(peerID PeerID, authPayload []byte) ([]byte, error) {
return msg, nil return msg, nil
} }
// UnmarshalAuthMsg extracts peerID and the auth payload from the message // MarshalAuthEnvelope creates the Auth message layout used by clients advertising protocol
// version 2 or higher: the token is length-prefixed and followed by an extension area that
// servers without support for it ignore.
func MarshalAuthEnvelope(version ProtocolVersion, peerID PeerID, token, ext []byte) ([]byte, error) {
if version < authEnvelopeMinVersion {
return nil, fmt.Errorf("protocol version %d does not use the auth envelope", version)
}
if len(token) == 0 || len(token) > math.MaxUint16 {
return nil, fmt.Errorf("%d: %w", len(token), ErrInvalidTokenLength)
}
size := offsetAuthToken + len(token) + len(ext)
if size > MaxHandshakeSize {
return nil, fmt.Errorf("too large auth message: %d", size)
}
msg := make([]byte, size)
msg[0] = byte(version)
msg[1] = byte(MsgTypeAuth)
copy(msg[offsetMagicByte:], magicHeader)
copy(msg[offsetAuthPeerID:], peerID[:])
binary.BigEndian.PutUint16(msg[offsetAuthTokenLen:], uint16(len(token)))
copy(msg[offsetAuthToken:], token)
copy(msg[offsetAuthToken+len(token):], ext)
return msg, nil
}
// UnmarshalAuthMsg extracts the peer ID and the auth token from an Auth message of any
// protocol version. The extension area of the envelope layout is not returned.
func UnmarshalAuthMsg(msg []byte) (*PeerID, []byte, error) { func UnmarshalAuthMsg(msg []byte) (*PeerID, []byte, error) {
version, err := ReadVersion(msg)
if err != nil {
return nil, nil, err
}
if len(msg) < headerTotalSizeAuth { if len(msg) < headerTotalSizeAuth {
return nil, nil, ErrInvalidMessageLength return nil, nil, ErrInvalidMessageLength
} }
@@ -174,7 +323,15 @@ func UnmarshalAuthMsg(msg []byte) (*PeerID, []byte, error) {
} }
peerID := PeerID(msg[offsetAuthPeerID:headerTotalSizeAuth]) peerID := PeerID(msg[offsetAuthPeerID:headerTotalSizeAuth])
return &peerID, msg[headerTotalSizeAuth:], nil if version < authEnvelopeMinVersion {
return &peerID, msg[headerTotalSizeAuth:], nil
}
token, err := unmarshalAuthEnvelopeToken(msg)
if err != nil {
return nil, nil, err
}
return &peerID, token, nil
} }
// MarshalAuthResponse creates a response message to the auth. // MarshalAuthResponse creates a response message to the auth.
@@ -182,19 +339,7 @@ func UnmarshalAuthMsg(msg []byte) (*PeerID, []byte, error) {
// instance URL. This URL will be used by choose the common Relay server in case if the peers are in different Relay // instance URL. This URL will be used by choose the common Relay server in case if the peers are in different Relay
// servers. // servers.
func MarshalAuthResponse(address string) ([]byte, error) { func MarshalAuthResponse(address string) ([]byte, error) {
ab := []byte(address) return NewEncoder(CurrentProtocolVersion).AuthResponse(address)
msg := make([]byte, sizeOfProtoHeader, sizeOfProtoHeader+len(ab))
msg[0] = byte(CurrentProtocolVersion)
msg[1] = byte(MsgTypeAuthResponse)
msg = append(msg, ab...)
if len(msg) > MaxHandshakeRespSize {
return nil, fmt.Errorf("invalid message length: %d", len(msg))
}
return msg, nil
} }
// UnmarshalAuthResponse it is a confirmation message to auth success // UnmarshalAuthResponse it is a confirmation message to auth success
@@ -209,10 +354,7 @@ func UnmarshalAuthResponse(msg []byte) (string, error) {
// The close message is used to close the connection gracefully between the client and the server. The server and the // The close message is used to close the connection gracefully between the client and the server. The server and the
// client can send this message. After receiving this message, the server or client will close the connection. // client can send this message. After receiving this message, the server or client will close the connection.
func MarshalCloseMsg() []byte { func MarshalCloseMsg() []byte {
return []byte{ return NewEncoder(CurrentProtocolVersion).CloseMsg()
byte(CurrentProtocolVersion),
byte(MsgTypeClose),
}
} }
// MarshalTransportMsg creates a transport message. // MarshalTransportMsg creates a transport message.
@@ -252,13 +394,14 @@ func UnmarshalTransportID(buf []byte) (*PeerID, error) {
return &id, nil return &id, nil
} }
// UpdateTransportMsg updates the peerID in the transport message. // UpdateTransportMsg rewrites the transport message in place for forwarding: the header carries
// With this function the server can reuse the given byte slice to update the peerID in the transport message. So do // the protocol version of the destination connection and the peer ID field the sender's ID.
// need to allocate a new byte slice. // The payload bytes are left untouched, so no new byte slice is allocated.
func UpdateTransportMsg(msg []byte, peerID PeerID) error { func UpdateTransportMsg(msg []byte, version ProtocolVersion, peerID PeerID) error {
if len(msg) < offsetTransportID+peerIDSize { if len(msg) < offsetTransportID+peerIDSize {
return ErrInvalidMessageLength return ErrInvalidMessageLength
} }
msg[0] = byte(version)
copy(msg[offsetTransportID:], peerID[:]) copy(msg[offsetTransportID:], peerID[:])
return nil return nil
} }
@@ -269,3 +412,20 @@ func UpdateTransportMsg(msg []byte, peerID PeerID) error {
func MarshalHealthcheck() []byte { func MarshalHealthcheck() []byte {
return healthCheckMsg return healthCheckMsg
} }
func unmarshalAuthEnvelopeToken(msg []byte) ([]byte, error) {
if len(msg) < offsetAuthToken {
return nil, ErrInvalidMessageLength
}
tokenLen := int(binary.BigEndian.Uint16(msg[offsetAuthTokenLen:offsetAuthToken]))
if tokenLen == 0 {
return nil, fmt.Errorf("%d: %w", tokenLen, ErrInvalidTokenLength)
}
end := offsetAuthToken + tokenLen
if end > len(msg) {
return nil, fmt.Errorf("%d exceeds the message: %w", tokenLen, ErrInvalidTokenLength)
}
return msg[offsetAuthToken:end], nil
}
+246
View File
@@ -1,7 +1,11 @@
package messages package messages
import ( import (
"encoding/binary"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestDetermineClientMessageTypeRejectsHello(t *testing.T) { func TestDetermineClientMessageTypeRejectsHello(t *testing.T) {
@@ -119,3 +123,245 @@ func TestMarshalHealthcheck(t *testing.T) {
t.Errorf("expected %d, got %d", MsgTypeHealthCheck, msgType) t.Errorf("expected %d, got %d", MsgTypeHealthCheck, msgType)
} }
} }
func TestReadVersion(t *testing.T) {
_, err := ReadVersion([]byte{byte(ProtocolV1)})
assert.ErrorIs(t, err, ErrInvalidMessageLength, "a header needs two bytes")
_, err = ReadVersion([]byte{0, byte(MsgTypeAuth)})
assert.ErrorIs(t, err, ErrUnsupportedVersion, "version zero is invalid")
for _, version := range []ProtocolVersion{1, 2, 128, 255} {
got, err := ReadVersion([]byte{byte(version), byte(MsgTypeAuth)})
require.NoError(t, err)
assert.Equal(t, version, got, "the advertised version must be returned unchanged")
}
}
func TestSelectVersion(t *testing.T) {
assert.Equal(t, ProtocolV1, SelectVersion(1, 1), "equal versions select themselves")
assert.Equal(t, ProtocolV1, SelectVersion(2, 1), "a higher advertised version selects the server maximum")
assert.Equal(t, ProtocolV1, SelectVersion(255, 1), "the highest advertised version selects the server maximum")
assert.Equal(t, ProtocolVersion(2), SelectVersion(2, 3), "a lower advertised version selects itself")
assert.Equal(t, ProtocolVersion(2), SelectVersion(3, 2), "a higher advertised version selects the server maximum")
}
func TestValidateVersionAcceptsOnlyCurrent(t *testing.T) {
_, err := ValidateVersion([]byte{byte(CurrentProtocolVersion), byte(MsgTypeHealthCheck)})
require.NoError(t, err)
_, err = ValidateVersion([]byte{byte(CurrentProtocolVersion) + 1, byte(MsgTypeHealthCheck)})
assert.ErrorIs(t, err, ErrUnsupportedVersion, "the client build accepts only its own version")
_, err = ValidateVersion([]byte{0, byte(MsgTypeHealthCheck)})
assert.ErrorIs(t, err, ErrUnsupportedVersion, "version zero is invalid")
}
func TestValidateConnectionVersion(t *testing.T) {
require.NoError(t, ValidateConnectionVersion([]byte{1, byte(MsgTypeHealthCheck)}, ProtocolV1))
assert.ErrorIs(t, ValidateConnectionVersion([]byte{2, byte(MsgTypeHealthCheck)}, ProtocolV1), ErrUnsupportedVersion, "a frame must carry the selected version")
assert.ErrorIs(t, ValidateConnectionVersion([]byte{1, byte(MsgTypeHealthCheck)}, 2), ErrUnsupportedVersion, "a frame below the selected version is rejected too")
assert.ErrorIs(t, ValidateConnectionVersion([]byte{0, byte(MsgTypeHealthCheck)}, ProtocolV1), ErrUnsupportedVersion, "version zero is invalid")
assert.ErrorIs(t, ValidateConnectionVersion([]byte{1}, ProtocolV1), ErrInvalidMessageLength, "a header needs two bytes")
}
func TestDetermineClientMessageTypeClassification(t *testing.T) {
rejected := []struct {
name string
msgType MsgType
wantErr error
}{
{"hello is reserved", MsgTypeHello, ErrReservedMsgType},
{"hello response is reserved", MsgTypeHelloResponse, ErrReservedMsgType},
{"auth response is server to client only", MsgTypeAuthResponse, ErrMsgTypeDirection},
{"peers online is server to client only", MsgTypePeersOnline, ErrMsgTypeDirection},
{"peers went offline is server to client only", MsgTypePeersWentOffline, ErrMsgTypeDirection},
{"zero is unknown", MsgTypeUnknown, ErrUnknownMsgType},
{"a future type is unknown", MsgType(200), ErrUnknownMsgType},
}
for _, tc := range rejected {
t.Run(tc.name, func(t *testing.T) {
got, err := DetermineClientMessageType([]byte{byte(ProtocolV1), byte(tc.msgType)})
assert.ErrorIs(t, err, tc.wantErr)
assert.Equal(t, MsgTypeUnknown, got, "a rejected type must not be returned")
})
}
accepted := []MsgType{MsgTypeAuth, MsgTypeTransport, MsgTypeClose, MsgTypeHealthCheck, MsgTypeSubscribePeerState, MsgTypeUnsubscribePeerState}
for _, msgType := range accepted {
got, err := DetermineClientMessageType([]byte{byte(ProtocolV1), byte(msgType)})
require.NoError(t, err)
assert.Equal(t, msgType, got, "a client message type must be returned")
}
}
func TestAuthEnvelopeRoundTrip(t *testing.T) {
peerID := HashID("future-client")
token := []byte("0123456789abcdef")
cases := []struct {
name string
version ProtocolVersion
ext []byte
}{
{"v2 without extension", 2, nil},
{"v2 with json extension", 2, []byte(`{"cred2":"value"}`)},
{"v255 with opaque extension", 255, []byte("opaque-extension-bytes")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
msg, err := MarshalAuthEnvelope(tc.version, peerID, token, tc.ext)
require.NoError(t, err)
version, err := ReadVersion(msg)
require.NoError(t, err)
assert.Equal(t, tc.version, version, "the header must carry the advertised version")
msgType, err := DetermineClientMessageType(msg)
require.NoError(t, err)
assert.Equal(t, MsgType(MsgTypeAuth), msgType, "the envelope is an auth message")
gotID, gotToken, err := UnmarshalAuthMsg(msg)
require.NoError(t, err)
assert.Equal(t, peerID, *gotID, "the peer ID must be preserved")
assert.Equal(t, token, gotToken, "the token must be returned without the extension bytes")
})
}
}
func TestMarshalAuthEnvelopeRejectsInvalidInput(t *testing.T) {
peerID := HashID("future-client")
_, err := MarshalAuthEnvelope(ProtocolV1, peerID, []byte("token"), nil)
assert.Error(t, err, "v1 does not use the envelope layout")
_, err = MarshalAuthEnvelope(2, peerID, nil, nil)
assert.ErrorIs(t, err, ErrInvalidTokenLength, "the token is mandatory")
_, err = MarshalAuthEnvelope(2, peerID, []byte("token"), make([]byte, MaxHandshakeSize))
assert.Error(t, err, "the envelope must fit the handshake size limit")
}
func TestUnmarshalAuthMsgV1LayoutUnchanged(t *testing.T) {
peerID := HashID("legacy-client")
// In the v1 layout the token runs to the end of the frame, so bytes that would be a
// length prefix in the envelope belong to the token here.
payload := []byte{0x00, 0x05, 'a', 'b', 'c', 'd', 'e', 'f', 'g'}
msg, err := MarshalAuthMsg(peerID, payload)
require.NoError(t, err)
gotID, gotToken, err := UnmarshalAuthMsg(msg)
require.NoError(t, err)
assert.Equal(t, peerID, *gotID, "the peer ID must be preserved")
assert.Equal(t, payload, gotToken, "the v1 token must be returned whole")
}
func TestUnmarshalAuthMsgRejectsMalformedFrames(t *testing.T) {
peerID := HashID("future-client")
valid, err := MarshalAuthEnvelope(2, peerID, []byte("token"), []byte("ext"))
require.NoError(t, err)
t.Run("zero token length", func(t *testing.T) {
msg := append([]byte(nil), valid...)
binary.BigEndian.PutUint16(msg[offsetAuthTokenLen:], 0)
_, _, err := UnmarshalAuthMsg(msg)
assert.ErrorIs(t, err, ErrInvalidTokenLength)
})
t.Run("token length past the end", func(t *testing.T) {
msg := append([]byte(nil), valid...)
binary.BigEndian.PutUint16(msg[offsetAuthTokenLen:], uint16(len(msg)))
_, _, err := UnmarshalAuthMsg(msg)
assert.ErrorIs(t, err, ErrInvalidTokenLength)
})
t.Run("truncated before the length field", func(t *testing.T) {
_, _, err := UnmarshalAuthMsg(valid[:headerTotalSizeAuth+1])
assert.ErrorIs(t, err, ErrInvalidMessageLength)
})
t.Run("version zero", func(t *testing.T) {
msg := append([]byte(nil), valid...)
msg[0] = 0
_, _, err := UnmarshalAuthMsg(msg)
assert.ErrorIs(t, err, ErrUnsupportedVersion)
})
t.Run("invalid magic", func(t *testing.T) {
msg := append([]byte(nil), valid...)
msg[offsetMagicByte] ^= 0xFF
_, _, err := UnmarshalAuthMsg(msg)
assert.Error(t, err)
})
t.Run("shorter than the fixed header", func(t *testing.T) {
_, _, err := UnmarshalAuthMsg(valid[:headerTotalSizeAuth-1])
assert.ErrorIs(t, err, ErrInvalidMessageLength)
})
}
func TestEncoderStampsVersion(t *testing.T) {
for _, version := range []ProtocolVersion{1, 2, 7} {
enc := NewEncoder(version)
assert.Equal(t, version, enc.Version())
assert.Equal(t, []byte{byte(version), byte(MsgTypeHealthCheck)}, enc.Healthcheck(), "healthcheck must carry the version")
assert.Equal(t, []byte{byte(version), byte(MsgTypeClose)}, enc.CloseMsg(), "close must carry the version")
resp, err := enc.AuthResponse("rel://relay.example")
require.NoError(t, err)
assert.Equal(t, byte(version), resp[0], "auth response must carry the version")
assert.Equal(t, byte(MsgTypeAuthResponse), resp[1])
addr, err := UnmarshalAuthResponse(resp)
require.NoError(t, err)
assert.Equal(t, "rel://relay.example", addr, "the auth response payload must be the address")
online, err := enc.PeersOnline([]PeerID{HashID("a")})
require.NoError(t, err)
require.Len(t, online, 1)
assert.Equal(t, byte(version), online[0][0], "peers online must carry the version")
assert.Equal(t, byte(MsgTypePeersOnline), online[0][1])
offline, err := enc.PeersWentOffline([]PeerID{HashID("a")})
require.NoError(t, err)
require.Len(t, offline, 1)
assert.Equal(t, byte(version), offline[0][0], "peers went offline must carry the version")
assert.Equal(t, byte(MsgTypePeersWentOffline), offline[0][1])
}
}
func TestPackageEncodersUseCurrentVersion(t *testing.T) {
current := NewEncoder(CurrentProtocolVersion)
assert.Equal(t, current.CloseMsg(), MarshalCloseMsg())
assert.Equal(t, current.Healthcheck(), MarshalHealthcheck())
want, err := current.AuthResponse("addr")
require.NoError(t, err)
got, err := MarshalAuthResponse("addr")
require.NoError(t, err)
assert.Equal(t, want, got)
ids := []PeerID{HashID("a"), HashID("b")}
wantOnline, err := current.PeersOnline(ids)
require.NoError(t, err)
gotOnline, err := MarshalPeersOnline(ids)
require.NoError(t, err)
assert.Equal(t, wantOnline, gotOnline)
}
func TestUpdateTransportMsgRewritesVersionAndSender(t *testing.T) {
dst := HashID("dst")
sender := HashID("sender")
payload := []byte("payload")
msg, err := MarshalTransportMsg(dst, payload)
require.NoError(t, err)
require.NoError(t, UpdateTransportMsg(msg, ProtocolVersion(2), sender))
assert.Equal(t, byte(2), msg[0], "the header must carry the destination version")
assert.Equal(t, byte(MsgTypeTransport), msg[1], "the message type must be preserved")
gotID, gotPayload, err := UnmarshalTransportMsg(msg)
require.NoError(t, err)
assert.Equal(t, sender, *gotID, "the peer ID must be rewritten to the sender")
assert.Equal(t, payload, gotPayload, "the payload must be preserved")
assert.ErrorIs(t, UpdateTransportMsg(msg[:headerTotalSizeTransport-1], ProtocolV1, sender), ErrInvalidMessageLength)
}
+6 -6
View File
@@ -5,7 +5,7 @@ import (
) )
func MarshalSubPeerStateMsg(ids []PeerID) ([][]byte, error) { func MarshalSubPeerStateMsg(ids []PeerID) ([][]byte, error) {
return marshalPeerIDs(ids, byte(MsgTypeSubscribePeerState)) return marshalPeerIDs(CurrentProtocolVersion, ids, byte(MsgTypeSubscribePeerState))
} }
func UnmarshalSubPeerStateMsg(buf []byte) ([]PeerID, error) { func UnmarshalSubPeerStateMsg(buf []byte) ([]PeerID, error) {
@@ -13,7 +13,7 @@ func UnmarshalSubPeerStateMsg(buf []byte) ([]PeerID, error) {
} }
func MarshalUnsubPeerStateMsg(ids []PeerID) ([][]byte, error) { func MarshalUnsubPeerStateMsg(ids []PeerID) ([][]byte, error) {
return marshalPeerIDs(ids, byte(MsgTypeUnsubscribePeerState)) return marshalPeerIDs(CurrentProtocolVersion, ids, byte(MsgTypeUnsubscribePeerState))
} }
func UnmarshalUnsubPeerStateMsg(buf []byte) ([]PeerID, error) { func UnmarshalUnsubPeerStateMsg(buf []byte) ([]PeerID, error) {
@@ -21,7 +21,7 @@ func UnmarshalUnsubPeerStateMsg(buf []byte) ([]PeerID, error) {
} }
func MarshalPeersOnline(ids []PeerID) ([][]byte, error) { func MarshalPeersOnline(ids []PeerID) ([][]byte, error) {
return marshalPeerIDs(ids, byte(MsgTypePeersOnline)) return marshalPeerIDs(CurrentProtocolVersion, ids, byte(MsgTypePeersOnline))
} }
func UnmarshalPeersOnlineMsg(buf []byte) ([]PeerID, error) { func UnmarshalPeersOnlineMsg(buf []byte) ([]PeerID, error) {
@@ -29,7 +29,7 @@ func UnmarshalPeersOnlineMsg(buf []byte) ([]PeerID, error) {
} }
func MarshalPeersWentOffline(ids []PeerID) ([][]byte, error) { func MarshalPeersWentOffline(ids []PeerID) ([][]byte, error) {
return marshalPeerIDs(ids, byte(MsgTypePeersWentOffline)) return marshalPeerIDs(CurrentProtocolVersion, ids, byte(MsgTypePeersWentOffline))
} }
func UnMarshalPeersWentOffline(buf []byte) ([]PeerID, error) { func UnMarshalPeersWentOffline(buf []byte) ([]PeerID, error) {
@@ -37,7 +37,7 @@ func UnMarshalPeersWentOffline(buf []byte) ([]PeerID, error) {
} }
// marshalPeerIDs is a generic function to marshal peer IDs with a specific message type // marshalPeerIDs is a generic function to marshal peer IDs with a specific message type
func marshalPeerIDs(ids []PeerID, msgType byte) ([][]byte, error) { func marshalPeerIDs(version ProtocolVersion, ids []PeerID, msgType byte) ([][]byte, error) {
if len(ids) == 0 { if len(ids) == 0 {
return nil, fmt.Errorf("no list of peer ids provided") return nil, fmt.Errorf("no list of peer ids provided")
} }
@@ -54,7 +54,7 @@ func marshalPeerIDs(ids []PeerID, msgType byte) ([][]byte, error) {
totalSize := sizeOfProtoHeader + len(chunk)*peerIDSize totalSize := sizeOfProtoHeader + len(chunk)*peerIDSize
buf := make([]byte, totalSize) buf := make([]byte, totalSize)
buf[0] = byte(CurrentProtocolVersion) buf[0] = byte(version)
buf[1] = msgType buf[1] = msgType
offset := sizeOfProtoHeader offset := sizeOfProtoHeader