v0.3: WebRTC P2P via Pion (Go) + RTCPeerConnection (browser)

Server: unchanged shape, just added a "signal" message type to the
existing /api/send + /api/stream bus. Now carries both "clipboard"
(payload) and "signal" (offer/answer/ICE) over the same envelope.

Client: -rtc flag turns the Go listener into a Pion peer. Posts an SDP
offer at startup, accepts the browser's answer through the signaling
bus, exchanges ICE, then receives clipboard text over a DataChannel
named "tether". On message: writes to OS clipboard same as SSE path.

Web UI: acts as the answerer. Listens for "signal" SSE events, replies
to offers, exchanges ICE. When DataChannel opens, the send button uses
RTCDataChannel.send() instead of POST /api/send — data no longer
traverses the server after pairing. Pill in the header flips
sse → negotiating → rtc to make this visible.

Toolchain: bumped go.mod to go 1.26, switched to pion/webrtc v4 and
prometheus/client_golang v1.23.x.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 4.7
2026-05-21 00:37:31 -05:00
parent 80539ae60c
commit 98dc2ca2a6
5 changed files with 421 additions and 301 deletions

View File

@@ -1,10 +1,17 @@
// tether-client: connects to a tether-server, writes incoming messages
// to the OS clipboard and prints them to stdout.
// tether-client v0.3: SSE listener with optional WebRTC peer.
//
// Default flow (SSE only): subscribe to /api/stream, write incoming
// clipboard messages to the OS clipboard. Works on Win/Linux/macOS.
//
// With -rtc: also act as a WebRTC peer (Pion). Sends an SDP offer via
// the signaling bus, accepts the browser's answer, then receives
// clipboard payloads over a DataChannel — true P2P after ICE.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
@@ -17,36 +24,51 @@ import (
"time"
"github.com/atotto/clipboard"
"github.com/pion/webrtc/v4"
)
// defaultLabel returns "{platform}-{transport}-{role}" so the server can
// tell at a glance who's connecting. Transport is "sse" today; "rtc" once
// WebRTC lands (v0.3 roadmap).
const peerID = "tether-client"
func defaultLabel(role string) string {
return fmt.Sprintf("%s-sse-%s", runtime.GOOS, role)
return fmt.Sprintf("%s-%s-%s", runtime.GOOS, "sse", role)
}
// Message envelope (must match server).
type Message struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
Signal json.RawMessage `json:"signal,omitempty"`
From string `json:"from,omitempty"`
Source string `json:"source,omitempty"`
TS int64 `json:"ts"`
}
// Signal payload carried in Message.Signal.
type SignalPayload struct {
Kind string `json:"kind"` // "offer" | "answer" | "ice"
SDP *webrtc.SessionDescription `json:"sdp,omitempty"`
Candidate *webrtc.ICECandidateInit `json:"candidate,omitempty"`
}
var (
noClipboard bool
myLabel string
useRTC bool
)
type Message struct {
Text string `json:"text"`
Source string `json:"source,omitempty"`
TS int64 `json:"ts"`
}
func main() {
server := flag.String("server", "https://tether.pecord.io", "tether-server base URL")
label := flag.String("label", "", "X-Tether-Client label (default: <os>-sse-<role>)")
sendText := flag.String("send", "", "send this text and exit (otherwise listen)")
flag.BoolVar(&noClipboard, "no-clipboard", false, "don't write incoming messages to the OS clipboard")
flag.BoolVar(&useRTC, "rtc", false, "enable WebRTC peer (uses signaling bus to negotiate)")
flag.Parse()
if *label == "" {
if *sendText != "" {
*label = defaultLabel("sender")
} else if useRTC {
*label = fmt.Sprintf("%s-rtc-listener", runtime.GOOS)
} else {
*label = defaultLabel("listener")
}
@@ -54,7 +76,7 @@ func main() {
myLabel = *label
if *sendText != "" {
send(*server, *label, *sendText)
send(*server, *label, *sendText, "clipboard", nil)
return
}
@@ -63,6 +85,10 @@ func main() {
noClipboard = true
}
if useRTC {
go runRTCPeer(*server)
}
for {
if err := listen(*server, *label); err != nil {
log.Printf("stream error: %v — reconnecting in 3s", err)
@@ -71,22 +97,29 @@ func main() {
}
}
func send(server, label, text string) {
body, _ := json.Marshal(Message{Text: text, Source: label})
func send(server, label, text, msgType string, signal json.RawMessage) {
m := Message{Type: msgType, Text: text, Source: label, From: peerID, Signal: signal}
body, _ := json.Marshal(m)
req, _ := http.NewRequest("POST", server+"/api/send", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Tether-Source", label)
r, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("send: %v", err)
log.Printf("send: %v", err)
return
}
defer r.Body.Close()
if r.StatusCode >= 300 {
log.Fatalf("send: HTTP %d", r.StatusCode)
log.Printf("send: HTTP %d", r.StatusCode)
return
}
if msgType != "signal" {
fmt.Println("sent.")
}
}
// listen subscribes to the SSE stream. Clipboard messages → OS clipboard.
// Signal messages → forwarded to the WebRTC peer (if enabled).
func listen(server, label string) error {
req, _ := http.NewRequest("GET", server+"/api/stream", nil)
req.Header.Set("X-Tether-Client", label)
@@ -98,9 +131,9 @@ func listen(server, label string) error {
if r.StatusCode != 200 {
return fmt.Errorf("HTTP %d", r.StatusCode)
}
fmt.Fprintf(os.Stderr, "tether-client: connected to %s as %q\n", server, label)
fmt.Fprintf(os.Stderr, "tether-client: connected to %s as %q (rtc=%v)\n", server, label, useRTC)
sc := bufio.NewScanner(r.Body)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var ev, data string
for sc.Scan() {
line := sc.Text()
@@ -110,24 +143,13 @@ func listen(server, label string) error {
case strings.HasPrefix(line, "data: "):
data = strings.TrimPrefix(line, "data: ")
case line == "":
if ev == "clipboard" && data != "" {
if data != "" {
var m Message
if err := json.Unmarshal([]byte(data), &m); err == nil {
ts := time.UnixMilli(m.TS).Format("15:04:05")
fmt.Printf("\n────── %s from %s ──────\n%s\n", ts, m.Source, m.Text)
// Don't echo our own message back into our own clipboard
if !noClipboard && m.Source != myLabel {
if err := clipboard.WriteAll(m.Text); err != nil {
fmt.Fprintf(os.Stderr, " ! clipboard write error: %v\n", err)
} else {
fmt.Fprintln(os.Stderr, " → clipboard updated")
}
}
handleMessage(ev, m)
}
}
ev, data = "", ""
case strings.HasPrefix(line, ": "):
// keepalive — ignore
}
}
if err := sc.Err(); err != nil && err != io.EOF {
@@ -135,3 +157,114 @@ func listen(server, label string) error {
}
return nil
}
func handleMessage(ev string, m Message) {
switch m.Type {
case "", "clipboard":
ts := time.UnixMilli(m.TS).Format("15:04:05")
fmt.Printf("\n────── %s from %s ──────\n%s\n", ts, m.Source, m.Text)
if !noClipboard && m.Source != myLabel {
if err := clipboard.WriteAll(m.Text); err != nil {
fmt.Fprintf(os.Stderr, " ! clipboard write error: %v\n", err)
} else {
fmt.Fprintln(os.Stderr, " → clipboard updated")
}
}
case "signal":
if useRTC && m.From != peerID {
incomingSignal <- m
}
}
}
// ── WebRTC ────────────────────────────────────────────────────────────────
var incomingSignal = make(chan Message, 16)
func runRTCPeer(server string) {
api := webrtc.NewAPI()
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
},
}
pc, err := api.NewPeerConnection(config)
if err != nil {
log.Printf("rtc: new peer connection: %v", err)
return
}
dc, err := pc.CreateDataChannel("tether", nil)
if err != nil {
log.Printf("rtc: create datachannel: %v", err)
return
}
dc.OnOpen(func() {
fmt.Fprintln(os.Stderr, "rtc: DataChannel OPEN — P2P live")
})
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
text := string(msg.Data)
fmt.Printf("\n[rtc] %s\n", text)
if !noClipboard {
if err := clipboard.WriteAll(text); err == nil {
fmt.Fprintln(os.Stderr, " → clipboard updated (via rtc)")
}
}
})
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if c == nil {
return
}
init := c.ToJSON()
payload, _ := json.Marshal(SignalPayload{Kind: "ice", Candidate: &init})
send(server, myLabel, "", "signal", payload)
})
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
fmt.Fprintf(os.Stderr, "rtc: state=%s\n", s)
})
// Create offer + post via signaling bus
offer, err := pc.CreateOffer(nil)
if err != nil {
log.Printf("rtc: create offer: %v", err)
return
}
if err := pc.SetLocalDescription(offer); err != nil {
log.Printf("rtc: set local desc: %v", err)
return
}
payload, _ := json.Marshal(SignalPayload{Kind: "offer", SDP: &offer})
send(server, myLabel, "", "signal", payload)
fmt.Fprintln(os.Stderr, "rtc: offer posted, waiting for answer...")
// Process incoming signals
for {
select {
case msg := <-incomingSignal:
var sp SignalPayload
if err := json.Unmarshal(msg.Signal, &sp); err != nil {
continue
}
switch sp.Kind {
case "answer":
if sp.SDP != nil {
if err := pc.SetRemoteDescription(*sp.SDP); err != nil {
log.Printf("rtc: set remote desc: %v", err)
} else {
fmt.Fprintln(os.Stderr, "rtc: answer applied")
}
}
case "ice":
if sp.Candidate != nil {
if err := pc.AddICECandidate(*sp.Candidate); err != nil {
log.Printf("rtc: add ice: %v", err)
}
}
}
case <-context.Background().Done():
return
}
}
}

59
go.mod
View File

@@ -1,44 +1,41 @@
module github.com/pecord/tether
go 1.22
go 1.26
require (
github.com/atotto/clipboard v0.1.4
github.com/prometheus/client_golang v1.20.5
github.com/pion/webrtc/v4 v4.2.12
github.com/prometheus/client_golang v1.23.2
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pion/datachannel v1.5.8 // indirect
github.com/pion/dtls/v2 v2.2.12 // indirect
github.com/pion/ice/v2 v2.3.38 // indirect
github.com/pion/interceptor v0.1.29 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/mdns v0.0.12 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/ice/v4 v4.2.5 // indirect
github.com/pion/interceptor v0.1.44 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.14 // indirect
github.com/pion/rtp v1.8.7 // indirect
github.com/pion/sctp v1.8.19 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v2 v2.0.20 // indirect
github.com/pion/stun v0.6.1 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pion/turn/v2 v2.1.6 // indirect
github.com/pion/webrtc/v3 v3.3.6 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
golang.org/x/crypto v0.24.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sys v0.22.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.1 // indirect
github.com/pion/sctp v1.9.5 // indirect
github.com/pion/sdp/v3 v3.0.18 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.2 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v5 v5.0.3 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)

201
go.sum
View File

@@ -4,143 +4,90 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
github.com/pion/ice/v2 v2.3.38 h1:DEpt13igPfvkE2+1Q+6e8mP30dtWnQD3CtMIKoRDRmA=
github.com/pion/ice/v2 v2.3.38/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M=
github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/ice/v4 v4.2.5 h1:5umUQy4hX6HwMsCnJ0SX337YYCeTWDgC9JWyvUqHIHs=
github.com/pion/ice/v4 v4.2.5/go.mod h1:aaABRaykEYnNjccjbiimuYxViaASeuv5mk9BpplUxK0=
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM=
github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8=
github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag=
github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY=
github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/turn/v5 v5.0.3 h1:I+Nw0fQgdPWF1SXDj0egWDhCkcff7gWiigdQpOK52Ak=
github.com/pion/turn/v5 v5.0.3/go.mod h1:fs4SogUh/aRGQzonc4Lx3Jp4EU3j3t0PfNDEd9KcD/w=
github.com/pion/webrtc/v4 v4.2.12 h1:ux8i+aJxu0OdhcAcVO39JEeodWugD0wdVJoRDtXk1CY=
github.com/pion/webrtc/v4 v4.2.12/go.mod h1:M/DeGZkhdWZVmVgGr34HOD9yUDekVJtz9c9PGO18urQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,7 +1,10 @@
// tether-server: HTTP+SSE relay for phone↔client clipboard sync.
// tether-server v0.3: HTTP+SSE relay with extensible message envelope.
//
// v0.1: SSE-only relay with broadcast bus.
// v0.2 (this): /metrics endpoint + signaling stubs (mailbox for WebRTC SDP/ICE).
// The same /api/send + /api/stream pipeline carries TWO message kinds:
// - "clipboard" — the user-facing payload (text)
// - "signal" — WebRTC SDP/ICE for peer negotiation
//
// Peers filter by .Type on the client side. Server is neutral relay.
package main
import (
@@ -23,9 +26,13 @@ import (
//go:embed web/index.html
var webFS embed.FS
// Message envelope. Type defaults to "clipboard" for backward compat.
type Message struct {
Text string `json:"text"`
Source string `json:"source,omitempty"`
Type string `json:"type,omitempty"` // "clipboard" | "signal"
Text string `json:"text,omitempty"` // clipboard text
Signal json.RawMessage `json:"signal,omitempty"` // {kind:offer|answer|ice, ...}
From string `json:"from,omitempty"` // sender peer id (for signal filtering)
Source string `json:"source,omitempty"` // human-readable label
TS int64 `json:"ts"`
}
@@ -38,15 +45,18 @@ type bus struct {
func newBus() *bus { return &bus{clients: map[chan Message]string{}} }
func (b *bus) subscribe(label string) chan Message {
ch := make(chan Message, 16)
ch := make(chan Message, 32)
b.mu.Lock()
b.clients[ch] = label
// only replay clipboard messages — signals are time-sensitive
for _, m := range b.history {
if m.Type == "" || m.Type == "clipboard" {
select {
case ch <- m:
default:
}
}
}
b.mu.Unlock()
subscribers.Inc()
return ch
@@ -63,10 +73,12 @@ func (b *bus) unsubscribe(ch chan Message) {
func (b *bus) publish(m Message) {
b.mu.Lock()
defer b.mu.Unlock()
if m.Type == "" || m.Type == "clipboard" {
b.history = append(b.history, m)
if len(b.history) > 10 {
b.history = b.history[len(b.history)-10:]
}
}
for ch := range b.clients {
select {
case ch <- m:
@@ -75,16 +87,15 @@ func (b *bus) publish(m Message) {
}
}
// Prometheus metrics --------------------------------------------------------
// Prometheus metrics
var (
messages = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "tether_messages_total",
Help: "Total messages published to the broadcast bus, by source label.",
}, []string{"source"})
Help: "Total messages published to the broadcast bus, by source and type.",
}, []string{"source", "type"})
bytesIn = promauto.NewCounter(prometheus.CounterOpts{
Name: "tether_message_bytes_total",
Help: "Total bytes of message text published.",
Help: "Total bytes of clipboard text published.",
})
subscribers = promauto.NewGauge(prometheus.GaugeOpts{
Name: "tether_active_subscribers",
@@ -93,46 +104,15 @@ var (
publishLatency = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "tether_publish_duration_seconds",
Help: "Latency of the publish() fan-out, including channel sends.",
Buckets: prometheus.ExponentialBuckets(0.0001, 4, 8), // 0.1ms..1.6s
Buckets: prometheus.ExponentialBuckets(0.0001, 4, 8),
})
)
// Signaling mailbox (v0.3 WebRTC scaffolding) --------------------------------
// Peers POST offers/answers/ICE candidates into a per-room mailbox, peers GET
// to drain. Pure relay — no SDP parsing, no peer state on server.
type signalBox struct {
mu sync.Mutex
rooms map[string][]json.RawMessage
}
func newSignalBox() *signalBox { return &signalBox{rooms: map[string][]json.RawMessage{}} }
func (s *signalBox) post(room string, msg json.RawMessage) {
s.mu.Lock()
defer s.mu.Unlock()
s.rooms[room] = append(s.rooms[room], msg)
if len(s.rooms[room]) > 64 {
s.rooms[room] = s.rooms[room][len(s.rooms[room])-64:]
}
}
func (s *signalBox) drain(room string) []json.RawMessage {
s.mu.Lock()
defer s.mu.Unlock()
out := s.rooms[room]
delete(s.rooms, room)
return out
}
// ---------------------------------------------------------------------------
func main() {
addr := flag.String("addr", ":8765", "listen address")
flag.Parse()
b := newBus()
sig := newSignalBox()
sub, _ := fs.Sub(webFS, "web")
mux := http.NewServeMux()
@@ -149,6 +129,9 @@ func main() {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if m.Type == "" {
m.Type = "clipboard"
}
if m.Source == "" {
m.Source = r.Header.Get("X-Tether-Source")
if m.Source == "" {
@@ -160,9 +143,13 @@ func main() {
t0 := time.Now()
b.publish(m)
publishLatency.Observe(time.Since(t0).Seconds())
messages.WithLabelValues(m.Source).Inc()
messages.WithLabelValues(m.Source, m.Type).Inc()
if m.Type == "clipboard" {
bytesIn.Add(float64(len(m.Text)))
log.Printf("publish: %s len=%d", m.Source, len(m.Text))
log.Printf("publish clipboard: %s len=%d", m.Source, len(m.Text))
} else {
log.Printf("publish %s: from=%s", m.Type, m.From)
}
w.WriteHeader(http.StatusNoContent)
})
@@ -195,7 +182,11 @@ func main() {
return
case m := <-ch:
bs, _ := json.Marshal(m)
fmt.Fprintf(w, "event: clipboard\ndata: %s\n\n", bs)
eventName := m.Type
if eventName == "" {
eventName = "clipboard"
}
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventName, bs)
fl.Flush()
case <-ka.C:
fmt.Fprintf(w, ": keepalive\n\n")
@@ -204,35 +195,6 @@ func main() {
}
})
// WebRTC signaling: POST to add a message, GET to drain.
// /api/signal/<room> is a dumb relay — peers exchange SDP + ICE via this.
mux.HandleFunc("/api/signal/", func(w http.ResponseWriter, r *http.Request) {
room := r.URL.Path[len("/api/signal/"):]
if room == "" {
http.Error(w, "missing room", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodPost:
body, _ := func() (json.RawMessage, error) {
var raw json.RawMessage
err := json.NewDecoder(r.Body).Decode(&raw)
return raw, err
}()
sig.post(room, body)
w.WriteHeader(http.StatusNoContent)
case http.MethodGet:
out := sig.drain(room)
if out == nil {
out = []json.RawMessage{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
default:
http.Error(w, "POST or GET", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})

View File

@@ -19,6 +19,12 @@
header { display: flex; align-items: baseline; gap: 8px; }
h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.5px; }
.tag { font-size: 11px; color: #888; letter-spacing: 0.5px; text-transform: uppercase; }
.pill {
font-size: 10px; padding: 2px 8px; border-radius: 999px;
background: #1f1f1f; color: #888; letter-spacing: 0.4px;
}
.pill.live { background: #052e16; color: #4ade80; }
.pill.connecting { background: #1f2937; color: #fbbf24; }
.row { display: flex; flex-direction: column; gap: 6px; }
label { font-size: 11px; color: #888; letter-spacing: 0.4px; text-transform: uppercase; }
textarea {
@@ -56,6 +62,7 @@
max-height: 200px; overflow: auto;
}
.meta { font-size: 11px; color: #666; margin-top: 4px; }
.meta .rtc-badge { color: #4ade80; }
footer {
margin-top: auto; padding-top: 8px;
font-size: 11px; color: #555; text-align: center;
@@ -67,6 +74,7 @@
<header>
<h1>tether</h1>
<span class="tag">phone ↔ laptop</span>
<span class="pill" id="rtcPill">sse</span>
</header>
<div class="row">
@@ -86,21 +94,41 @@
</div>
<footer>
<a href="https://gitea.pecord.io/pecord/tether">tether on gitea</a> · v0.1
<a href="https://gitea.pecord.io/pecord/tether">tether on gitea</a> · v0.3
</footer>
<script>
const $ = (id) => document.getElementById(id);
const peerID = "tether-browser-" + Math.random().toString(36).slice(2, 8);
let rtcChannel = null; // open RTCDataChannel, when we have one
const status = (msg, cls) => {
const s = $("status");
s.textContent = msg;
s.className = "status" + (cls ? " " + cls : "");
};
const setPill = (mode) => {
const p = $("rtcPill");
p.textContent = mode;
p.className = "pill " + (mode === "rtc" ? "live" : mode === "negotiating" ? "connecting" : "");
};
function addIncoming(text, source, ts, viaRTC) {
const el = document.createElement("div");
el.className = "msg";
el.textContent = text;
const meta = document.createElement("div");
meta.className = "meta";
const badge = viaRTC ? '<span class="rtc-badge">via rtc</span> · ' : '';
meta.innerHTML = badge + (source || "client") + " @ " + new Date(ts || Date.now()).toLocaleTimeString();
el.appendChild(meta);
const feed = $("incoming");
feed.insertBefore(el, feed.firstChild);
}
$("pasteBtn").addEventListener("click", async () => {
try {
const text = await navigator.clipboard.readText();
$("out").value = text;
$("out").value = await navigator.clipboard.readText();
status("pasted from clipboard", "ok");
} catch (e) {
status("clipboard read denied — paste manually", "err");
@@ -110,38 +138,91 @@
$("sendBtn").addEventListener("click", async () => {
const text = $("out").value;
if (!text) { status("empty", "err"); return; }
status("sending…");
if (rtcChannel && rtcChannel.readyState === "open") {
rtcChannel.send(text);
status("sent via rtc ✓", "ok");
} else {
status("sending via http…");
try {
const r = await fetch("/api/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
body: JSON.stringify({ type: "clipboard", text, source: "phone" }),
});
if (r.ok) {
status("delivered ✓", "ok");
} else {
status("server returned " + r.status, "err");
}
} catch (e) {
status("network error", "err");
status(r.ok ? "delivered ✓" : "server returned " + r.status, r.ok ? "ok" : "err");
} catch (e) { status("network error", "err"); }
}
});
// SSE: receive incoming messages from clients
// ── WebRTC peer ────────────────────────────────────────────────────────
// Browser acts as ANSWERER. Listens for offers via SSE, replies via /api/send.
async function postSignal(payload) {
await fetch("/api/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "signal",
from: peerID,
source: "phone",
signal: JSON.stringify(payload),
}),
});
}
let pc = null;
async function handleOffer(sdp) {
if (pc) try { pc.close(); } catch (_) {}
pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
setPill("negotiating");
pc.onicecandidate = (ev) => {
if (ev.candidate) postSignal({ kind: "ice", candidate: ev.candidate.toJSON() });
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === "connected") setPill("rtc");
else if (pc.connectionState === "failed" || pc.connectionState === "disconnected") setPill("sse");
};
pc.ondatachannel = (ev) => {
const ch = ev.channel;
ch.onopen = () => { rtcChannel = ch; setPill("rtc"); };
ch.onclose = () => { rtcChannel = null; setPill("sse"); };
ch.onmessage = (m) => addIncoming(m.data, "laptop", Date.now(), true);
};
await pc.setRemoteDescription({ type: "offer", sdp });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await postSignal({ kind: "answer", sdp: answer });
}
async function handleIceCandidate(candidate) {
if (pc) {
try { await pc.addIceCandidate(candidate); } catch (e) {}
}
}
// ── SSE main feed ──────────────────────────────────────────────────────
function connectFeed() {
const es = new EventSource("/api/stream");
es.addEventListener("clipboard", (ev) => {
try {
const m = JSON.parse(ev.data);
const el = document.createElement("div");
el.className = "msg";
el.textContent = m.text;
const meta = document.createElement("div");
meta.className = "meta";
meta.textContent = (m.source || "client") + " @ " + new Date(m.ts).toLocaleTimeString();
el.appendChild(meta);
const feed = $("incoming");
feed.insertBefore(el, feed.firstChild);
if (m.source !== "phone") {
addIncoming(m.text, m.source, m.ts, false);
}
} catch (e) {}
});
es.addEventListener("signal", (ev) => {
try {
const m = JSON.parse(ev.data);
if (m.from === peerID) return; // ignore our own
let payload = m.signal;
if (typeof payload === "string") payload = JSON.parse(payload);
if (payload.kind === "offer" && payload.sdp) {
handleOffer(payload.sdp.sdp);
} else if (payload.kind === "ice" && payload.candidate) {
handleIceCandidate(payload.candidate);
}
} catch (e) {}
});
es.onerror = () => setTimeout(connectFeed, 2000);