mirror of
https://github.com/Quad4-Software/Reticulum-Go
synced 2026-08-29 23:48:44 -04:00
488 lines
12 KiB
Go
488 lines
12 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright (c) 2024-2026 Quad4.io
|
|
|
|
package link
|
|
|
|
import (
|
|
"bytes"
|
|
"math/rand/v2"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"quad4/reticulum-go/pkg/common"
|
|
"quad4/reticulum-go/pkg/destination"
|
|
"quad4/reticulum-go/pkg/identity"
|
|
"quad4/reticulum-go/pkg/packet"
|
|
"quad4/reticulum-go/pkg/transport"
|
|
)
|
|
|
|
// PipeInterface simulates a direct connection between two nodes
|
|
type PipeInterface struct {
|
|
common.BaseInterface
|
|
peer *PipeInterface
|
|
tr *transport.Transport
|
|
online atomic.Bool
|
|
|
|
// dropOnce, if set, is consulted for every outbound packet. Returning
|
|
|
|
// true drops that packet instead of delivering it. Used by tests that
|
|
// simulate packet loss on a lossy mesh path.
|
|
dropOnce atomic.Pointer[dropFn]
|
|
|
|
chaosMu sync.Mutex
|
|
reorderQ [][]byte
|
|
reorderCap int
|
|
reorderRNG *rand.Rand
|
|
reorderArmed bool
|
|
}
|
|
|
|
type dropFn func(data []byte) bool
|
|
|
|
func NewPipeInterface(name string) *PipeInterface {
|
|
p := &PipeInterface{
|
|
BaseInterface: common.BaseInterface{
|
|
Name: name,
|
|
Type: common.IFTypeUDP,
|
|
Enabled: true,
|
|
Online: true,
|
|
},
|
|
}
|
|
p.online.Store(true)
|
|
return p
|
|
}
|
|
|
|
// setDropOnce installs an arbitrary outbound drop predicate (or clears it
|
|
// when fn is nil). Safe to call while the pipe is in use.
|
|
func (p *PipeInterface) setDropOnce(fn dropFn) {
|
|
if fn == nil {
|
|
p.dropOnce.Store(nil)
|
|
return
|
|
}
|
|
f := fn
|
|
p.dropOnce.Store(&f)
|
|
}
|
|
|
|
// setLossyDrop installs a seeded probabilistic drop filter on outbound Send.
|
|
func (p *PipeInterface) setLossyDrop(prob float64, seed uint64) {
|
|
rng := rand.New(rand.NewPCG(seed, seed^0xdeadbeef))
|
|
var mu sync.Mutex
|
|
p.setDropOnce(func(_ []byte) bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return rng.Float64() < prob
|
|
})
|
|
}
|
|
|
|
// setReorderDrop buffers outbound frames and flushes them in shuffled order.
|
|
func (p *PipeInterface) setReorderDrop(queueCap int, seed uint64) {
|
|
if queueCap < 2 {
|
|
queueCap = 2
|
|
}
|
|
p.chaosMu.Lock()
|
|
defer p.chaosMu.Unlock()
|
|
p.reorderCap = queueCap
|
|
p.reorderRNG = rand.New(rand.NewPCG(seed, seed^0xc0ffee))
|
|
p.reorderQ = nil
|
|
p.reorderArmed = true
|
|
}
|
|
|
|
func (p *PipeInterface) setOnline(online bool) {
|
|
p.online.Store(online)
|
|
p.Online = online
|
|
}
|
|
|
|
func (p *PipeInterface) clearChaos() {
|
|
p.dropOnce.Store(nil)
|
|
batch := p.takeReorderBatch(true)
|
|
for _, frame := range batch {
|
|
p.deliver(frame)
|
|
}
|
|
}
|
|
|
|
// takeReorderBatch copies and clears the reorder queue. When disarm is true
|
|
// reorder mode is turned off. Delivery must happen without holding chaosMu so
|
|
// a synchronous HandlePacket that re-enters Send cannot deadlock.
|
|
func (p *PipeInterface) takeReorderBatch(disarm bool) [][]byte {
|
|
p.chaosMu.Lock()
|
|
defer p.chaosMu.Unlock()
|
|
if len(p.reorderQ) == 0 {
|
|
if disarm {
|
|
p.reorderArmed = false
|
|
p.reorderRNG = nil
|
|
}
|
|
return nil
|
|
}
|
|
batch := p.reorderQ
|
|
p.reorderQ = nil
|
|
if p.reorderRNG != nil {
|
|
p.reorderRNG.Shuffle(len(batch), func(i, j int) { batch[i], batch[j] = batch[j], batch[i] })
|
|
}
|
|
if disarm {
|
|
p.reorderArmed = false
|
|
p.reorderRNG = nil
|
|
}
|
|
return batch
|
|
}
|
|
|
|
func (p *PipeInterface) deliver(data []byte) {
|
|
if !p.online.Load() || p.peer == nil || !p.peer.online.Load() || p.peer.tr == nil {
|
|
return
|
|
}
|
|
dataCopy := make([]byte, len(data))
|
|
copy(dataCopy, data)
|
|
p.peer.tr.HandlePacket(dataCopy, p.peer)
|
|
}
|
|
|
|
func (p *PipeInterface) Send(data []byte, address string) error {
|
|
if !p.online.Load() || p.peer == nil || !p.peer.online.Load() {
|
|
return nil
|
|
}
|
|
if fn := p.dropOnce.Load(); fn != nil && (*fn)(data) {
|
|
return nil
|
|
}
|
|
p.chaosMu.Lock()
|
|
if p.reorderArmed {
|
|
cp := append([]byte(nil), data...)
|
|
p.reorderQ = append(p.reorderQ, cp)
|
|
flush := len(p.reorderQ) >= p.reorderCap
|
|
if !flush && p.reorderRNG != nil {
|
|
flush = p.reorderRNG.Float64() < 0.35
|
|
}
|
|
var batch [][]byte
|
|
if flush {
|
|
batch = p.reorderQ
|
|
p.reorderQ = nil
|
|
if p.reorderRNG != nil {
|
|
p.reorderRNG.Shuffle(len(batch), func(i, j int) { batch[i], batch[j] = batch[j], batch[i] })
|
|
}
|
|
}
|
|
p.chaosMu.Unlock()
|
|
for _, frame := range batch {
|
|
p.deliver(frame)
|
|
}
|
|
return nil
|
|
}
|
|
p.chaosMu.Unlock()
|
|
p.deliver(data)
|
|
return nil
|
|
}
|
|
|
|
func (p *PipeInterface) IsEnabled() bool { return p.Enabled }
|
|
func (p *PipeInterface) IsOnline() bool { return p.online.Load() }
|
|
func (p *PipeInterface) GetName() string { return p.Name }
|
|
func (p *PipeInterface) Start() error { return nil }
|
|
func (p *PipeInterface) Stop() error { return nil }
|
|
func (p *PipeInterface) Detach() {}
|
|
|
|
func TestNodeInterop(t *testing.T) {
|
|
skipHeavyLinkTestsIfShort(t)
|
|
// Create Node A
|
|
cfgA := &common.ReticulumConfig{}
|
|
trA := transport.NewTransport(cfgA)
|
|
defer trA.Close()
|
|
idA, _ := identity.New()
|
|
|
|
// Create Node B
|
|
cfgB := &common.ReticulumConfig{}
|
|
trB := transport.NewTransport(cfgB)
|
|
defer trB.Close()
|
|
idB, _ := identity.New()
|
|
_ = idB // Use idB to avoid unused error
|
|
|
|
// Connect them via PipeInterface
|
|
pipeA := NewPipeInterface("pipeA")
|
|
pipeB := NewPipeInterface("pipeB")
|
|
pipeA.peer = pipeB
|
|
pipeB.peer = pipeA
|
|
pipeA.tr = trA
|
|
pipeB.tr = trB
|
|
|
|
_ = trA.RegisterInterface("pipeA", pipeA)
|
|
_ = trB.RegisterInterface("pipeB", pipeB)
|
|
|
|
// Initialize path request handler on Node A so it can answer path requests
|
|
_ = trA.InitializePathRequestHandler()
|
|
|
|
// Create a destination on Node A
|
|
destA, _ := destination.New(idA, destination.In, destination.Single, "testapp", trA, "service")
|
|
destA.AcceptsLinks(true)
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
|
|
var establishedLink *Link
|
|
destA.SetLinkEstablishedCallback(func(l any) {
|
|
link, ok := l.(*Link)
|
|
if ok {
|
|
establishedLink = link
|
|
wg.Done()
|
|
}
|
|
})
|
|
|
|
// Node A announces itself
|
|
t.Log("Node A announcing...")
|
|
if err := destA.Announce(false, nil, nil); err != nil {
|
|
t.Fatalf("Announce failed: %v", err)
|
|
}
|
|
|
|
// Give time for announce to propagate
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Check if Node B saw the announce and has a path
|
|
if !trB.HasPath(destA.GetHash()) {
|
|
t.Error("Node B should have a path to Node A after announce")
|
|
} else {
|
|
t.Logf("Node B has path to Node A: %d hops", trB.HopsTo(destA.GetHash()))
|
|
}
|
|
|
|
// Node B establishes a link to Node A
|
|
t.Log("Node B establishing link to Node A...")
|
|
var wgB sync.WaitGroup
|
|
wgB.Add(1)
|
|
linkB := NewLink(destA, trB, pipeB, func(l *Link) {
|
|
wgB.Done()
|
|
}, nil)
|
|
|
|
if err := linkB.Establish(); err != nil {
|
|
t.Fatalf("Link establishment failed: %v", err)
|
|
}
|
|
|
|
// Wait for link establishment confirmation on Node B
|
|
doneB := make(chan struct{})
|
|
go func() {
|
|
wgB.Wait()
|
|
close(doneB)
|
|
}()
|
|
|
|
select {
|
|
case <-doneB:
|
|
t.Log("Link established successfully on Node B")
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Timed out waiting for link establishment on Node B")
|
|
}
|
|
|
|
// Wait for link establishment confirmation on Node A
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
t.Log("Link established successfully on Node A")
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Timed out waiting for link establishment")
|
|
}
|
|
|
|
if establishedLink == nil {
|
|
t.Fatal("Link established but establishedLink is nil")
|
|
}
|
|
|
|
// Verify link IDs match
|
|
if !bytes.Equal(linkB.linkID, establishedLink.linkID) {
|
|
t.Errorf("Link ID mismatch: %x != %x", linkB.linkID, establishedLink.linkID)
|
|
}
|
|
|
|
// Test bidirectional communication over link
|
|
t.Log("Testing communication over link...")
|
|
msg := []byte("hello from node B")
|
|
var receivedMsg []byte
|
|
var msgWg sync.WaitGroup
|
|
msgWg.Add(1)
|
|
|
|
establishedLink.SetPacketCallback(func(data []byte, p *packet.Packet) {
|
|
receivedMsg = data
|
|
msgWg.Done()
|
|
})
|
|
|
|
if err := linkB.SendPacket(msg); err != nil {
|
|
t.Fatalf("Failed to send packet over link: %v", err)
|
|
}
|
|
|
|
msgDone := make(chan struct{})
|
|
go func() {
|
|
msgWg.Wait()
|
|
close(msgDone)
|
|
}()
|
|
|
|
select {
|
|
case <-msgDone:
|
|
if !bytes.Equal(receivedMsg, msg) {
|
|
t.Errorf("Received message mismatch: %q != %q", receivedMsg, msg)
|
|
} else {
|
|
t.Log("Message received successfully over link")
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Error("Timed out waiting for message over link")
|
|
}
|
|
|
|
// Test identification
|
|
t.Log("Node B identifying to Node A...")
|
|
var identWg sync.WaitGroup
|
|
identWg.Add(1)
|
|
var identifiedRemote *identity.Identity
|
|
|
|
establishedLink.SetRemoteIdentifiedCallback(func(l *Link, id *identity.Identity) {
|
|
identifiedRemote = id
|
|
identWg.Done()
|
|
})
|
|
|
|
if err := linkB.Identify(idB); err != nil {
|
|
t.Fatalf("Identify failed: %v", err)
|
|
}
|
|
|
|
identDone := make(chan struct{})
|
|
go func() {
|
|
identWg.Wait()
|
|
close(identDone)
|
|
}()
|
|
|
|
select {
|
|
case <-identDone:
|
|
if !bytes.Equal(identifiedRemote.GetPublicKey(), idB.GetPublicKey()) {
|
|
t.Error("Identified public key mismatch")
|
|
} else {
|
|
t.Log("Node B identified successfully to Node A")
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Error("Timed out waiting for identification")
|
|
}
|
|
|
|
// Test path discovery for unknown destination
|
|
t.Log("Testing path discovery for unknown destination...")
|
|
// Create another destination on Node A that B doesn't know about yet
|
|
destA2, _ := destination.New(idA, destination.In, destination.Single, "anotherapp", trA)
|
|
destA2.AcceptsLinks(true)
|
|
|
|
// Node B shouldn't have a path yet
|
|
if trB.HasPath(destA2.GetHash()) {
|
|
t.Error("Node B should NOT have a path to destA2 yet")
|
|
}
|
|
|
|
if err := trB.RequestPath(destA2.GetHash(), "pipeB", nil, false); err != nil {
|
|
t.Errorf("Path request failed: %v", err)
|
|
}
|
|
|
|
// Wait for discovery
|
|
discovered := false
|
|
for range 10 {
|
|
if trB.HasPath(destA2.GetHash()) {
|
|
discovered = true
|
|
break
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
|
|
if !discovered {
|
|
t.Error("Node B failed to discover path to destA2 after path request")
|
|
} else {
|
|
t.Log("Node B discovered path to destA2 successfully")
|
|
}
|
|
}
|
|
|
|
func TestLinkRequestResponseInterop(t *testing.T) {
|
|
skipHeavyLinkTestsIfShort(t)
|
|
// Create Nodes
|
|
cfgA := &common.ReticulumConfig{}
|
|
trA := transport.NewTransport(cfgA)
|
|
defer trA.Close()
|
|
idA, _ := identity.New()
|
|
|
|
cfgB := &common.ReticulumConfig{}
|
|
trB := transport.NewTransport(cfgB)
|
|
defer trB.Close()
|
|
idB, _ := identity.New()
|
|
_ = idB // Use idB to avoid unused error
|
|
|
|
// Connect
|
|
pipeA := NewPipeInterface("pipeA")
|
|
pipeB := NewPipeInterface("pipeB")
|
|
pipeA.peer = pipeB
|
|
pipeB.peer = pipeA
|
|
pipeA.tr = trA
|
|
pipeB.tr = trB
|
|
_ = trA.RegisterInterface("pipeA", pipeA)
|
|
_ = trB.RegisterInterface("pipeB", pipeB)
|
|
|
|
// Setup Destination on A
|
|
destA, _ := destination.New(idA, destination.In, destination.Single, "reqapp", trA)
|
|
destA.AcceptsLinks(true)
|
|
|
|
// Register multiple handlers to test routing
|
|
destA.RegisterRequestHandler("test/1", func(path string, data []byte, requestID []byte, linkID []byte, remoteIdentity *identity.Identity, requestedAt int64) []byte {
|
|
return []byte("resp 1")
|
|
}, destination.AllowAll, nil)
|
|
|
|
destA.RegisterRequestHandler("test/2", func(path string, data []byte, requestID []byte, linkID []byte, remoteIdentity *identity.Identity, requestedAt int64) []byte {
|
|
return append([]byte("resp 2: "), data...)
|
|
}, destination.AllowAll, nil)
|
|
|
|
// Node A announces
|
|
_ = destA.Announce(false, nil, nil)
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Node B establishes link
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
linkB := NewLink(destA, trB, pipeB, func(l *Link) {
|
|
wg.Done()
|
|
}, nil)
|
|
_ = linkB.Establish()
|
|
|
|
select {
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Link timeout")
|
|
case <-func() chan struct{} {
|
|
ch := make(chan struct{})
|
|
go func() { wg.Wait(); close(ch) }()
|
|
return ch
|
|
}():
|
|
}
|
|
|
|
// Test Request 1
|
|
receipt1, err := linkB.Request("test/1", nil, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("Request 1 failed: %v", err)
|
|
}
|
|
|
|
done1 := make(chan struct{})
|
|
receipt1.SetResponseCallback(func(r *RequestReceipt) {
|
|
if !bytes.Equal(r.GetResponse(), []byte("resp 1")) {
|
|
t.Errorf("Response 1 mismatch: %q", r.GetResponse())
|
|
}
|
|
close(done1)
|
|
})
|
|
|
|
// Test Request 2
|
|
payload2 := []byte("hello")
|
|
receipt2, err := linkB.Request("test/2", payload2, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("Request 2 failed: %v", err)
|
|
}
|
|
|
|
done2 := make(chan struct{})
|
|
receipt2.SetResponseCallback(func(r *RequestReceipt) {
|
|
if !bytes.Equal(r.GetResponse(), []byte("resp 2: hello")) {
|
|
t.Errorf("Response 2 mismatch: %q", r.GetResponse())
|
|
}
|
|
close(done2)
|
|
})
|
|
|
|
// Wait for both
|
|
select {
|
|
case <-done1:
|
|
t.Log("Request 1 success")
|
|
case <-time.After(2 * time.Second):
|
|
t.Error("Request 1 timeout")
|
|
}
|
|
|
|
select {
|
|
case <-done2:
|
|
t.Log("Request 2 success")
|
|
case <-time.After(2 * time.Second):
|
|
t.Error("Request 2 timeout")
|
|
}
|
|
}
|