Bring wire protocol into full YAW/2 spec compliance

Five interop issues fixed against PROTOCOL.md:

- Join signature now covers nonce_raw || net_ascii (64-char UTF-8 hex
  string) as specified in §5.1, not net_raw_bytes. Both anchor server
  and client updated to match.

- Chat wire fields renamed to spec names: text/ts (Unix ms int64)
  replacing body/sent_at (ISO timestamp). Flat layout on PeerMessage
  matches §8 exactly; store and TUI updated accordingly.

- Direct messages now use the spec "pm" type (flat {type,mid,text,ts})
  instead of chat+to. Receiver reconstructs a ChatMessage with
  dm:<short-id> room for IPC/storage. §8 compliant.

- File transfer message types changed to spec hyphenated names:
  file-offer, file-accept, file-cancel, file-done with spec field
  names (name/size not filename/size_bytes). §9 compliant.

- DataChannel open-race (§14 gotcha #3) fixed with sync.Once: doOpen
  fires on OnOpen callback or immediately if the channel is already
  open when WireDataChannel is called (answerer race).

Also fixes two bugs found during testing:

- mid was missing from outgoing wire messages, causing all received
  messages to arrive with mid="" and collide on the UNIQUE DB
  constraint. mid is now included on all sent chat/pm messages; a
  random mid is generated for any received message that omits it.

- Test scripts hardened: kill -9 + active lsof polling replaces blind
  sleep for port cleanup; join_network sent before peer_field queries
  (local_peer is now network-scoped and nil until joined).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-22 11:38:01 +02:00
parent 13fb7ba1fe
commit b87f14a361
11 changed files with 623 additions and 126 deletions

View File

@@ -2,10 +2,13 @@
package mesh
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"strings"
"sync"
"time"
"github.com/pion/webrtc/v3"
@@ -21,7 +24,7 @@ type Anchor interface {
}
// WireDataChannel sets up open/message/close handlers on a "yaw" DataChannel.
// Must be called before the DataChannel opens.
// Safe to call whether the channel is already open or not (§6 open-race).
func WireDataChannel(
dc *webrtc.DataChannel,
pc *webrtc.PeerConnection,
@@ -31,7 +34,8 @@ func WireDataChannel(
) {
sendCh := make(chan []byte, 64)
dc.OnOpen(func() {
var once sync.Once
doOpen := func() {
log.Printf("peer: DataChannel open with %s", peerID.Short())
// Send hello — bind our identity to this DTLS session.
@@ -69,7 +73,13 @@ func WireDataChannel(
}
}
}()
})
}
dc.OnOpen(func() { once.Do(doOpen) })
// §6 gotcha: answerer's DC may already be open when OnDataChannel fires.
if dc.ReadyState() == webrtc.DataChannelStateOpen {
once.Do(doOpen)
}
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
if msg.IsString {
@@ -152,8 +162,30 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
switch msg.Type {
case proto.MsgChat:
chat := &proto.ChatMessage{
Mid: midOrRandom(msg.Mid),
From: from,
Room: msg.Room,
Text: msg.Text,
Ts: msg.Ts,
}
m.SaveMessage(chat)
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: chat})
case proto.MsgPm:
// Private message — reconstruct as ChatMessage for IPC/storage using dm:<short-id> room.
chat := &proto.ChatMessage{
Mid: midOrRandom(msg.Mid),
From: from,
Room: "dm:" + from.Short(),
Text: msg.Text,
Ts: msg.Ts,
}
m.SaveMessage(chat)
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: chat})
case proto.MsgFileListReq:
// Peer wants our file list — reply directly on their send channel.
files := m.ScanShareDir()
resp, err := json.Marshal(proto.PeerMessage{
Type: proto.MsgFileListResp,
@@ -165,7 +197,6 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
m.SendTo(from, resp)
case proto.MsgFileListResp:
// Received a remote peer's file list — forward to IPC subscribers.
if msg.FileListResp != nil {
m.Emit(proto.IpcMessage{
Type: proto.EvtFileList,
@@ -174,11 +205,13 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
})
}
case proto.MsgChat:
if msg.Chat != nil {
m.SaveMessage(msg.Chat)
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg.Chat})
}
case proto.MsgFileOffer:
m.Emit(proto.IpcMessage{
Type: proto.EvtIncomingFile,
PeerID: peerIDPtr(from),
Offer: &proto.FileOffer{Xid: msg.Xid, Name: msg.Name, Size: msg.Size, SHA256: msg.SHA256},
})
case proto.MsgPeerGossip:
if msg.Gossip != nil {
log.Printf("mesh: gossip from %s: %d peer hints", from.Short(), len(msg.Gossip.Peers))
@@ -187,19 +220,24 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
log.Printf("mesh: ping from %s", from.Short())
case proto.MsgPong:
log.Printf("mesh: pong from %s", from.Short())
case proto.MsgFileOffer:
if msg.FileOffer != nil {
m.Emit(proto.IpcMessage{
Type: proto.EvtIncomingFile,
PeerID: peerIDPtr(from),
Offer: msg.FileOffer,
})
}
default:
log.Printf("mesh: unknown message type %q from %s", msg.Type, from.Short())
}
}
// midOrRandom returns mid if non-empty, otherwise generates a random 16-byte hex string.
// Ensures every stored message has a unique mid even from peers that don't send one.
func midOrRandom(mid string) string {
if mid != "" {
return mid
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return hex.EncodeToString([]byte(time.Now().String()))
}
return hex.EncodeToString(b)
}
func dtlsFingerprints(pc *webrtc.PeerConnection) (local, remote []byte) {
if ld := pc.LocalDescription(); ld != nil {
local = fingerprintFromSDP(ld.SDP)