Files
flit/cli/internal/transport/transport.go
Fredrik Johansson a4bee36f45 Fix multi-room anchor collision via per-room derived identities
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>
2026-07-02 18:26:19 +02:00

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
}