The anchor keys its clients map by peer ID. With the daemon joining one room per trusted peer, both sessions authenticated with the same real Ed25519 ID — the second register() overwrote the first, orphaning the earlier room's WS connection. Fix mirrors waste-go/internal/netmgr: derive a deterministic per-room Ed25519 keypair from HKDF(master_private_key, room_hash). Same inputs always produce the same derived ID; different rooms produce different IDs. No anchor changes required. cli/internal/crypto: add DeriveForNetwork (HKDF-SHA256, same KDF as waste-go) so the daemon can derive stable per-room signaling identities. cli/internal/transport: Join derives a per-room signaling identity before calling dialSignaling. Real identity is still used for hello verification and seal/open crypto inside the DataChannel. pwa/src/transport/flit.ts: split PeerConn.peerId into signalingId (anchor routing) and cryptoId (seal/open, hello). In pair-room mode the daemon's signaling ID differs from its real ID, so connectTo and _onFrom now use trustedPeerId as the cryptoId regardless of what the anchor reports as the sender. offerByOrder comparison uses real IDs (trustedPeerId ?? signalingId) so both sides agree. hello verification now also checks the Ed25519 signature, not just the claimed ID. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
841 lines
21 KiB
Go
841 lines
21 KiB
Go
// Package transport implements flit's yaw/2.1 signaling + WebRTC session for
|
|
// 1:1 ephemeral file transfer, mirroring pwa/src/transport/flit.ts. Trimmed
|
|
// from waste-go: no chat, no multi-peer mesh — exactly one peer per session.
|
|
package transport
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/pion/webrtc/v3"
|
|
"nhooyr.io/websocket"
|
|
"nhooyr.io/websocket/wsjson"
|
|
|
|
"flit/internal/crypto"
|
|
)
|
|
|
|
const (
|
|
bindPrefix = "yaw/2 bind"
|
|
ekeyPrefix = "yaw/2.1 ekey"
|
|
ekeyTimeout = 2 * time.Second
|
|
chunkSize = 64 * 1024
|
|
)
|
|
|
|
type Config struct {
|
|
SignalURL string
|
|
TurnURL string
|
|
// TurnSecret holds the coturn use-auth-secret shared secret. Safe to keep
|
|
// in a native config file (unlike the browser, this never ships to a
|
|
// client that can view-source it).
|
|
TurnSecret string
|
|
}
|
|
|
|
func NetHash(name string) string {
|
|
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// PairRoomName derives a stable room name for two known peers — mirrors
|
|
// pairRoomName() in pwa/src/pairing/ephemeral.ts. Both sides compute it
|
|
// independently from their stored peer IDs, so no QR/invite is needed.
|
|
func PairRoomName(a, b string) string {
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
return "flit-pair:" + a + ":" + b
|
|
}
|
|
|
|
func iceServers(cfg Config) []webrtc.ICEServer {
|
|
servers := []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}
|
|
if cfg.TurnURL != "" && cfg.TurnSecret != "" {
|
|
expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + ":flit"
|
|
mac := hmac.New(sha1.New, []byte(cfg.TurnSecret))
|
|
mac.Write([]byte(expiry))
|
|
cred := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
|
servers = append(servers, webrtc.ICEServer{
|
|
URLs: []string{cfg.TurnURL}, Username: expiry, Credential: cred,
|
|
CredentialType: webrtc.ICECredentialTypePassword,
|
|
})
|
|
}
|
|
return servers
|
|
}
|
|
|
|
// ── Signaling ─────────────────────────────────────────────────────────────────
|
|
|
|
type anchorMsg struct {
|
|
Type string `json:"type"`
|
|
Nonce string `json:"nonce,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Net string `json:"net,omitempty"`
|
|
Sig string `json:"sig,omitempty"`
|
|
Peers []string `json:"peers,omitempty"`
|
|
From string `json:"from,omitempty"`
|
|
To string `json:"to,omitempty"`
|
|
Box string `json:"box,omitempty"`
|
|
}
|
|
|
|
type signaling struct {
|
|
conn *websocket.Conn
|
|
sendCh chan anchorMsg
|
|
}
|
|
|
|
func dialSignaling(ctx context.Context, url string, id *crypto.Identity, netHash string) (*signaling, []string, error) {
|
|
conn, _, err := websocket.Dial(ctx, url, nil)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("dial: %w", err)
|
|
}
|
|
s := &signaling{conn: conn, sendCh: make(chan anchorMsg, 64)}
|
|
go func() {
|
|
for msg := range s.sendCh {
|
|
if err := wsjson.Write(ctx, conn, msg); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
// Keepalive: ping every 20s so proxies/NAT don't silently drop the connection.
|
|
go func() {
|
|
t := time.NewTicker(20 * time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err := conn.Ping(pingCtx)
|
|
cancel()
|
|
if err != nil {
|
|
// Close so the read loop errors and OnDisconnected fires.
|
|
_ = conn.Close(websocket.StatusGoingAway, "ping failed")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
for {
|
|
var msg anchorMsg
|
|
if err := wsjson.Read(ctx, conn, &msg); err != nil {
|
|
return nil, nil, fmt.Errorf("read: %w", err)
|
|
}
|
|
if msg.Type == "challenge" {
|
|
nonceBytes, err := hex.DecodeString(msg.Nonce)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("bad challenge nonce: %w", err)
|
|
}
|
|
sig := id.Sign(append(nonceBytes, []byte(netHash)...))
|
|
s.sendCh <- anchorMsg{Type: "join", ID: id.PeerID(), Net: netHash, Sig: sig}
|
|
} else if msg.Type == "joined" {
|
|
return s, msg.Peers, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *signaling) sendTo(to, box string) {
|
|
select {
|
|
case s.sendCh <- anchorMsg{Type: "to", To: to, Box: box}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// ── Session ───────────────────────────────────────────────────────────────────
|
|
|
|
type FileOffer struct {
|
|
XID string
|
|
Name string
|
|
Size int64
|
|
}
|
|
|
|
// Session is a 1:1 ephemeral pairing: join a room, connect to exactly one
|
|
// peer (optionally pinned to a specific id), exchange files.
|
|
type Session struct {
|
|
identity *crypto.Identity
|
|
cfg Config
|
|
|
|
mu sync.Mutex
|
|
pc *webrtc.PeerConnection
|
|
dc *webrtc.DataChannel
|
|
peerID string
|
|
verified bool
|
|
esk, epk *[32]byte
|
|
peerEPK *[32]byte
|
|
ekeySent bool
|
|
offered bool
|
|
offerByOrder bool // true if this side should create the yaw DC and SDP offer
|
|
|
|
sig *signaling
|
|
pendingRecvs map[string]*recvState
|
|
pendingAccepts map[string]chan int64
|
|
|
|
OnConnected func(verified bool)
|
|
OnDisconnected func()
|
|
OnFileOffer func(FileOffer)
|
|
OnFileProgress func(xid string, received, total int64)
|
|
OnFileDone func(name string, path string)
|
|
}
|
|
|
|
func NewSession(dataDir string, cfg Config) (*Session, error) {
|
|
id, err := crypto.LoadOrCreate(dataDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Session{identity: id, cfg: cfg}, nil
|
|
}
|
|
|
|
func (s *Session) PeerID() string { return s.identity.PeerID() }
|
|
|
|
// Join connects to the signaling server for roomName and waits for exactly
|
|
// one peer (or the pinned trustedPeerID) to complete the handshake.
|
|
func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID string) error {
|
|
hash := NetHash(roomName)
|
|
// Use a per-room derived identity for signaling so that joining multiple
|
|
// rooms simultaneously doesn't collide in the anchor's peer-ID map.
|
|
signingID, err := crypto.DeriveForNetwork(s.identity, hash)
|
|
if err != nil {
|
|
return fmt.Errorf("derive signaling identity: %w", err)
|
|
}
|
|
sig, present, err := dialSignaling(ctx, s.cfg.SignalURL, signingID, hash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.sig = sig
|
|
|
|
for _, pid := range present {
|
|
if trustedPeerID != "" && pid != trustedPeerID {
|
|
continue
|
|
}
|
|
if err := s.connectTo(ctx, pid); err != nil {
|
|
return err
|
|
}
|
|
break
|
|
}
|
|
|
|
go func() {
|
|
for {
|
|
// 3-minute read deadline: if the anchor silently evicts us from
|
|
// the room (idle timeout) without closing the WS, we'll never
|
|
// receive a peer-join. Force a reconnect so we re-enter the room.
|
|
readCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
|
var msg anchorMsg
|
|
err := wsjson.Read(readCtx, sig.conn, &msg)
|
|
cancel()
|
|
if err != nil {
|
|
_ = sig.conn.Close(websocket.StatusGoingAway, "idle timeout")
|
|
if s.OnDisconnected != nil {
|
|
s.OnDisconnected()
|
|
}
|
|
return
|
|
}
|
|
switch msg.Type {
|
|
case "peer-join":
|
|
if trustedPeerID != "" && msg.ID != trustedPeerID {
|
|
continue
|
|
}
|
|
log.Printf("flit: peer-join: %s", msg.ID[:8])
|
|
_ = s.connectTo(ctx, msg.ID)
|
|
case "peer-left":
|
|
if trustedPeerID != "" && msg.ID != trustedPeerID {
|
|
continue
|
|
}
|
|
// Log but don't reset: active file transfers use the WebRTC
|
|
// path which is independent of signaling. The stale-PC case
|
|
// is handled in connectTo when peer-join arrives next.
|
|
log.Printf("flit: peer-left: %s", msg.ID[:8])
|
|
case "from":
|
|
if trustedPeerID != "" && msg.From != trustedPeerID {
|
|
continue
|
|
}
|
|
s.onBox(msg.From, msg.Box)
|
|
}
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (s *Session) connectTo(ctx context.Context, peerID string) error {
|
|
s.mu.Lock()
|
|
var oldPC *webrtc.PeerConnection
|
|
if s.pc != nil {
|
|
state := s.pc.ConnectionState()
|
|
if state == webrtc.PeerConnectionStateNew ||
|
|
state == webrtc.PeerConnectionStateConnected {
|
|
s.mu.Unlock()
|
|
return nil // healthy or brand-new connection already exists
|
|
}
|
|
// Stale PC (peer left while we were connecting, or ICE failed) — replace it.
|
|
log.Printf("flit: connectTo %s: replacing stale PC (state=%s)", peerID[:8], state)
|
|
oldPC = s.pc
|
|
s.pc = nil
|
|
}
|
|
s.peerID = peerID
|
|
var se webrtc.SettingEngine
|
|
se.DetachDataChannels()
|
|
api := webrtc.NewAPI(webrtc.WithSettingEngine(se))
|
|
pc, err := api.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers(s.cfg)})
|
|
if err != nil {
|
|
s.mu.Unlock()
|
|
if oldPC != nil {
|
|
_ = oldPC.Close()
|
|
}
|
|
return err
|
|
}
|
|
s.pc = pc
|
|
kp, err := crypto.GenerateEphemeral()
|
|
if err != nil {
|
|
s.pc = nil
|
|
s.mu.Unlock()
|
|
if oldPC != nil {
|
|
_ = oldPC.Close()
|
|
}
|
|
return err
|
|
}
|
|
s.esk, s.epk = kp.PrivateRaw(), kp.PublicRaw()
|
|
s.ekeySent = false
|
|
s.offered = false
|
|
s.peerEPK = nil
|
|
s.offerByOrder = s.identity.PeerID() < peerID
|
|
s.mu.Unlock()
|
|
|
|
// Close the stale PC after s.pc has been updated. The Closed state callback
|
|
// will see isCurrent=false and skip OnDisconnected, so runPeerLoop keeps running.
|
|
if oldPC != nil {
|
|
_ = oldPC.Close()
|
|
}
|
|
|
|
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
|
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
|
if state == webrtc.PeerConnectionStateFailed ||
|
|
state == webrtc.PeerConnectionStateDisconnected ||
|
|
state == webrtc.PeerConnectionStateClosed {
|
|
s.mu.Lock()
|
|
isCurrent := s.pc == pc
|
|
if isCurrent {
|
|
s.pc = nil
|
|
s.ekeySent = false
|
|
s.offered = false
|
|
s.peerEPK = nil
|
|
}
|
|
s.mu.Unlock()
|
|
log.Printf("flit: PC state=%s isCurrent=%v", state, isCurrent)
|
|
if isCurrent && s.OnDisconnected != nil {
|
|
s.OnDisconnected()
|
|
}
|
|
}
|
|
})
|
|
|
|
s.sendEkey()
|
|
if s.offerByOrder {
|
|
go func() {
|
|
time.Sleep(ekeyTimeout)
|
|
s.maybeOffer()
|
|
}()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resetPeer tears down the current WebRTC connection without triggering
|
|
// OnDisconnected, leaving the signaling connection open so that the next
|
|
// peer-join for this peer can reconnect immediately.
|
|
func (s *Session) resetPeer() {
|
|
s.mu.Lock()
|
|
pc := s.pc
|
|
s.pc = nil
|
|
s.ekeySent = false
|
|
s.offered = false
|
|
s.peerEPK = nil
|
|
s.mu.Unlock()
|
|
if pc != nil {
|
|
log.Printf("flit: resetPeer: closing stale PC (peer left signaling)")
|
|
_ = pc.Close()
|
|
}
|
|
}
|
|
|
|
func (s *Session) sendEkey() {
|
|
s.mu.Lock()
|
|
if s.ekeySent {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.ekeySent = true
|
|
epk := *s.epk
|
|
s.mu.Unlock()
|
|
|
|
signed := append([]byte(ekeyPrefix), mustHex(s.identity.PeerID())...)
|
|
signed = append(signed, mustHex(s.peerID)...)
|
|
signed = append(signed, epk[:]...)
|
|
msg := map[string]string{
|
|
"kind": "ekey", "v": "yaw/2.1",
|
|
"epk": hex.EncodeToString(epk[:]),
|
|
"sig": s.identity.Sign(signed),
|
|
}
|
|
s.sealAndSend(msg, false)
|
|
}
|
|
|
|
func (s *Session) maybeOffer() {
|
|
s.mu.Lock()
|
|
if !s.offerByOrder || s.offered || s.pc == nil {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.offered = true
|
|
pc := s.pc
|
|
s.mu.Unlock()
|
|
|
|
dc, err := pc.CreateDataChannel("yaw", nil)
|
|
if err != nil {
|
|
log.Printf("flit: create datachannel: %v", err)
|
|
return
|
|
}
|
|
s.wireDC(dc)
|
|
|
|
offer, err := pc.CreateOffer(nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := pc.SetLocalDescription(offer); err != nil {
|
|
return
|
|
}
|
|
<-gatherComplete(pc)
|
|
s.mu.Lock()
|
|
hasEPK := s.peerEPK != nil
|
|
s.mu.Unlock()
|
|
s.sealAndSend(map[string]string{"kind": "offer", "sdp": pc.LocalDescription().SDP}, hasEPK)
|
|
}
|
|
|
|
func (s *Session) onBox(from, box string) {
|
|
plain, usedEph := s.openBox(box)
|
|
if plain == nil {
|
|
return
|
|
}
|
|
var obj map[string]any
|
|
if err := json.Unmarshal(plain, &obj); err != nil {
|
|
return
|
|
}
|
|
switch obj["kind"] {
|
|
case "ekey":
|
|
s.onEkey(obj)
|
|
case "offer":
|
|
s.mu.Lock()
|
|
pc := s.pc
|
|
s.mu.Unlock()
|
|
if pc == nil {
|
|
return
|
|
}
|
|
_ = pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: obj["sdp"].(string)})
|
|
answer, err := pc.CreateAnswer(nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := pc.SetLocalDescription(answer); err != nil {
|
|
return
|
|
}
|
|
<-gatherComplete(pc)
|
|
s.sealAndSend(map[string]string{"kind": "answer", "sdp": pc.LocalDescription().SDP}, usedEph)
|
|
case "answer":
|
|
s.mu.Lock()
|
|
pc := s.pc
|
|
s.mu.Unlock()
|
|
if pc == nil {
|
|
return
|
|
}
|
|
_ = pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: obj["sdp"].(string)})
|
|
}
|
|
}
|
|
|
|
func (s *Session) onEkey(obj map[string]any) {
|
|
s.mu.Lock()
|
|
if s.peerEPK != nil {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
epkHex, _ := obj["epk"].(string)
|
|
sigHex, _ := obj["sig"].(string)
|
|
epkRaw, err := hex.DecodeString(epkHex)
|
|
if err != nil || len(epkRaw) != 32 {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
signed := append([]byte(ekeyPrefix), mustHex(s.peerID)...)
|
|
signed = append(signed, mustHex(s.identity.PeerID())...)
|
|
signed = append(signed, epkRaw...)
|
|
if err := crypto.Verify(s.peerID, signed, sigHex); err != nil {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
var epk [32]byte
|
|
copy(epk[:], epkRaw)
|
|
s.peerEPK = &epk
|
|
s.mu.Unlock()
|
|
|
|
s.sendEkey()
|
|
s.maybeOffer()
|
|
}
|
|
|
|
func (s *Session) sealAndSend(obj map[string]string, preferEph bool) {
|
|
data, _ := json.Marshal(obj)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var sealed string
|
|
if preferEph && s.peerEPK != nil {
|
|
sealed = crypto.SignalingBox(data, s.peerEPK, s.esk)
|
|
} else {
|
|
recip, err := crypto.CurveFromPeerID(s.peerID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
sealed = crypto.SignalingBox(data, recip, s.identity.CurvePrivateKey())
|
|
}
|
|
s.sig.sendTo(s.peerID, sealed)
|
|
}
|
|
|
|
func (s *Session) openBox(boxB64 string) ([]byte, bool) {
|
|
s.mu.Lock()
|
|
peerEPK, esk := s.peerEPK, s.esk
|
|
peerID := s.peerID
|
|
s.mu.Unlock()
|
|
|
|
if peerEPK != nil {
|
|
if pt, err := crypto.SignalingOpen(boxB64, peerEPK, esk); err == nil {
|
|
return pt, true
|
|
}
|
|
}
|
|
senderCurve, err := crypto.CurveFromPeerID(peerID)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
pt, err := crypto.SignalingOpen(boxB64, senderCurve, s.identity.CurvePrivateKey())
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return pt, false
|
|
}
|
|
|
|
// ── control channel + file transfer ───────────────────────────────────────────
|
|
|
|
func (s *Session) wireDC(dc *webrtc.DataChannel) {
|
|
if dc.Label() == "yaw" {
|
|
s.mu.Lock()
|
|
s.dc = dc
|
|
s.mu.Unlock()
|
|
|
|
startYaw := func() {
|
|
raw, err := dc.Detach()
|
|
if err != nil {
|
|
log.Printf("flit: yaw detach: %v", err)
|
|
return
|
|
}
|
|
s.sendHello()
|
|
go func() {
|
|
buf := make([]byte, 32768)
|
|
for {
|
|
n, err := raw.Read(buf)
|
|
if n > 0 {
|
|
s.onControl(append([]byte(nil), buf[:n]...))
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
if dc.ReadyState() == webrtc.DataChannelStateOpen {
|
|
startYaw()
|
|
} else {
|
|
dc.OnOpen(func() { startYaw() })
|
|
}
|
|
return
|
|
}
|
|
if strings.HasPrefix(dc.Label(), "f:") {
|
|
s.wireFileRecv(dc)
|
|
}
|
|
}
|
|
|
|
func (s *Session) sendHello() {
|
|
s.controlSend(map[string]string{
|
|
"type": "hello", "id": s.identity.PeerID(),
|
|
"sig": s.identity.Sign([]byte(bindPrefix)),
|
|
})
|
|
}
|
|
|
|
func (s *Session) onControl(data []byte) {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return
|
|
}
|
|
switch m["type"] {
|
|
case "hello":
|
|
s.mu.Lock()
|
|
s.verified = m["id"] == s.peerID
|
|
s.mu.Unlock()
|
|
if s.OnConnected != nil {
|
|
s.OnConnected(s.verified)
|
|
}
|
|
case "file-offer":
|
|
if s.OnFileOffer != nil {
|
|
size, _ := m["size"].(float64)
|
|
s.OnFileOffer(FileOffer{XID: m["xid"].(string), Name: m["name"].(string), Size: int64(size)})
|
|
}
|
|
case "file-accept":
|
|
xid, _ := m["xid"].(string)
|
|
var offset int64
|
|
if v, ok := m["offset"].(string); ok {
|
|
offset, _ = strconv.ParseInt(v, 10, 64)
|
|
}
|
|
s.mu.Lock()
|
|
ch := s.pendingAccepts[xid]
|
|
delete(s.pendingAccepts, xid)
|
|
s.mu.Unlock()
|
|
if ch != nil {
|
|
ch <- offset
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Session) controlSend(obj map[string]string) {
|
|
s.mu.Lock()
|
|
dc := s.dc
|
|
s.mu.Unlock()
|
|
if dc == nil || dc.ReadyState() != webrtc.DataChannelStateOpen {
|
|
return
|
|
}
|
|
data, _ := json.Marshal(obj)
|
|
_ = dc.SendText(string(data))
|
|
}
|
|
|
|
// SendFile offers and streams a file to the connected peer. If the receiver
|
|
// has a partial .part file it will reply with a non-zero offset and the send
|
|
// resumes from that byte position.
|
|
func (s *Session) SendFile(path string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
xid := fmt.Sprintf("push-%d", time.Now().UnixNano())
|
|
name := st.Name()
|
|
|
|
acceptCh := make(chan int64, 1)
|
|
s.mu.Lock()
|
|
if s.pendingAccepts == nil {
|
|
s.pendingAccepts = make(map[string]chan int64)
|
|
}
|
|
s.pendingAccepts[xid] = acceptCh
|
|
s.mu.Unlock()
|
|
|
|
s.controlSend(map[string]string{"type": "file-offer", "name": name, "size": fmt.Sprint(st.Size()), "xid": xid})
|
|
|
|
s.mu.Lock()
|
|
pc := s.pc
|
|
s.mu.Unlock()
|
|
dc, err := pc.CreateDataChannel("f:"+xid, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opened := make(chan struct{})
|
|
dc.OnOpen(func() { close(opened) })
|
|
|
|
var offset int64
|
|
select {
|
|
case offset = <-acceptCh:
|
|
case <-time.After(30 * time.Second):
|
|
return fmt.Errorf("timed out waiting for file-accept")
|
|
}
|
|
select {
|
|
case <-opened:
|
|
case <-time.After(15 * time.Second):
|
|
return fmt.Errorf("timed out waiting for data channel open")
|
|
}
|
|
|
|
if offset > 0 {
|
|
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
|
return fmt.Errorf("seek to resume offset: %w", err)
|
|
}
|
|
log.Printf("flit: resuming %s from byte %d", name, offset)
|
|
}
|
|
|
|
buf := make([]byte, chunkSize)
|
|
for {
|
|
n, err := f.Read(buf)
|
|
if n > 0 {
|
|
for dc.BufferedAmount() > 1<<20 {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if err := dc.Send(buf[:n]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return dc.Close()
|
|
}
|
|
|
|
// AcceptOffer accepts an incoming file-offer and writes the transfer to
|
|
// downloadDir. If a .part file from a previous partial transfer exists for this
|
|
// filename and is smaller than the declared size, its byte count is sent back
|
|
// as the resume offset so the sender can skip ahead.
|
|
func (s *Session) AcceptOffer(offer FileOffer, downloadDir string) {
|
|
partPath := filepath.Join(downloadDir, offer.Name+".part")
|
|
var offset int64
|
|
if info, err := os.Stat(partPath); err == nil && info.Size() < offer.Size {
|
|
offset = info.Size()
|
|
}
|
|
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID, "offset": fmt.Sprint(offset)})
|
|
s.mu.Lock()
|
|
if s.pendingRecvs == nil {
|
|
s.pendingRecvs = make(map[string]*recvState)
|
|
}
|
|
s.pendingRecvs[offer.XID] = &recvState{offer: offer, dir: downloadDir, have: offset}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *Session) RejectOffer(xid string) {
|
|
s.controlSend(map[string]string{"type": "file-cancel", "xid": xid})
|
|
}
|
|
|
|
type recvState struct {
|
|
offer FileOffer
|
|
dir string
|
|
have int64
|
|
f *os.File
|
|
}
|
|
|
|
func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
|
xid := strings.TrimPrefix(dc.Label(), "f:")
|
|
s.mu.Lock()
|
|
rs := s.pendingRecvs[xid]
|
|
delete(s.pendingRecvs, xid)
|
|
s.mu.Unlock()
|
|
if rs == nil {
|
|
return
|
|
}
|
|
|
|
log.Printf("flit: wireFileRecv: %s size=%d state=%s", rs.offer.Name, rs.offer.Size, dc.ReadyState())
|
|
|
|
// Use pion's Detach API so data is buffered in pion's SCTP stream and
|
|
// read by our goroutine — no OnMessage/OnClose callback race.
|
|
startRecv := func() {
|
|
raw, err := dc.Detach()
|
|
if err != nil {
|
|
log.Printf("flit: file DC detach %s: %v", rs.offer.Name, err)
|
|
return
|
|
}
|
|
log.Printf("flit: file DC open: %s", rs.offer.Name)
|
|
|
|
go func() {
|
|
finalPath := filepath.Join(rs.dir, rs.offer.Name)
|
|
resuming := rs.have > 0
|
|
|
|
var f *os.File
|
|
if resuming {
|
|
partPath := finalPath + ".part"
|
|
f, err = os.OpenFile(partPath, os.O_WRONLY|os.O_APPEND, 0644)
|
|
if err != nil {
|
|
log.Printf("flit: reopen %s: %v — restarting", partPath, err)
|
|
resuming = false
|
|
rs.have = 0
|
|
f, err = os.Create(finalPath)
|
|
} else {
|
|
log.Printf("flit: resuming %s from byte %d", rs.offer.Name, rs.have)
|
|
}
|
|
} else {
|
|
f, err = os.Create(finalPath)
|
|
}
|
|
if err != nil {
|
|
log.Printf("flit: open output: %v", err)
|
|
return
|
|
}
|
|
|
|
buf := make([]byte, chunkSize)
|
|
for {
|
|
n, readErr := raw.Read(buf)
|
|
if n > 0 {
|
|
if _, werr := f.Write(buf[:n]); werr != nil {
|
|
log.Printf("flit: write %s: %v", rs.offer.Name, werr)
|
|
break
|
|
}
|
|
rs.have += int64(n)
|
|
if s.OnFileProgress != nil {
|
|
s.OnFileProgress(xid, rs.have, rs.offer.Size)
|
|
}
|
|
}
|
|
if readErr != nil {
|
|
break
|
|
}
|
|
}
|
|
f.Close()
|
|
log.Printf("flit: recv done: %s wrote=%d of %d", rs.offer.Name, rs.have, rs.offer.Size)
|
|
|
|
if resuming {
|
|
partPath := finalPath + ".part"
|
|
if rerr := os.Rename(partPath, finalPath); rerr != nil {
|
|
log.Printf("flit: rename %s: %v", partPath, rerr)
|
|
return
|
|
}
|
|
}
|
|
if s.OnFileDone != nil {
|
|
s.OnFileDone(rs.offer.Name, finalPath)
|
|
}
|
|
}()
|
|
}
|
|
|
|
if dc.ReadyState() == webrtc.DataChannelStateOpen {
|
|
startRecv()
|
|
} else {
|
|
dc.OnOpen(func() { startRecv() })
|
|
}
|
|
}
|
|
|
|
func mustHex(s string) []byte {
|
|
b, _ := hex.DecodeString(s)
|
|
return b
|
|
}
|
|
|
|
func gatherComplete(pc *webrtc.PeerConnection) <-chan struct{} {
|
|
ch := make(chan struct{})
|
|
if pc.ICEGatheringState() == webrtc.ICEGatheringStateComplete {
|
|
close(ch)
|
|
return ch
|
|
}
|
|
pc.OnICEGatheringStateChange(func(s webrtc.ICEGathererState) {
|
|
if s == webrtc.ICEGathererStateComplete {
|
|
select {
|
|
case <-ch:
|
|
default:
|
|
close(ch)
|
|
}
|
|
}
|
|
})
|
|
go func() {
|
|
time.Sleep(6 * time.Second)
|
|
select {
|
|
case <-ch:
|
|
default:
|
|
close(ch)
|
|
}
|
|
}()
|
|
return ch
|
|
}
|