guestlink v1: self-hosted guest line (Cleanfeed replacement)
Go + Pion + libopus. One guest, live audio both ways, anonymous chat. README for this version was not preserved; see the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b0e3b805b1
13 files changed
+1839
No files matched your search
@@ -0,0 +1,547 @@
|
||||
// guestlink - self-hosted replacement for Cleanfeed on the MMO rig.
|
||||
//
|
||||
// One remote guest joins from a browser at https://guest.mmo.show/j/<token>.
|
||||
//
|
||||
// guest's voice -> pacat -> PipeWire sink "motu_guest" (From Computer 1-2, the guest strip)
|
||||
// mix-minus <- parec <- "motu_mixminus.monitor" (To Computer 3-4, show minus guest)
|
||||
//
|
||||
// Plus an anonymous text chat between the guest and the host console.
|
||||
//
|
||||
// Two HTTP listeners:
|
||||
//
|
||||
// -public (8790): guest page + guest websocket. This is what NPM on .158 proxies.
|
||||
// -host (8791): host console. LAN only, never proxied.
|
||||
//
|
||||
// Media: one fixed UDP port (-udp 50090) shared by all ICE traffic.
|
||||
//
|
||||
// Audio safety: the parec/pacat streams are ordinary PipeWire client streams (exactly what
|
||||
// Chromium did for Cleanfeed). They never reopen the MOTU. node.dont-fallback/dont-reconnect stop PipeWire from
|
||||
// falling back to the default sink if motu_guest disappears.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
//go:embed web
|
||||
var webFS embed.FS
|
||||
|
||||
var (
|
||||
publicAddr = flag.String("public", "0.0.0.0:8790", "guest-facing listener (proxied by NPM)")
|
||||
hostAddr = flag.String("host", "0.0.0.0:8791", "host console listener (LAN only)")
|
||||
udpPort = flag.Int("udp", 50090, "single UDP port for all WebRTC media")
|
||||
publicHost = flag.String("public-host", "guest.mmo.show", "hostname; its A record is advertised as our public IP")
|
||||
publicIPFlag = flag.String("public-ip", "", "override the public IP instead of resolving -public-host")
|
||||
lanCIDR = flag.String("lan", "192.168.50.0/24", "LAN whose interface address is offered as a host candidate")
|
||||
sinkName = flag.String("sink", "motu_guest", "PipeWire sink the guest's voice is played into")
|
||||
sourceName = flag.String("source", "motu_mixminus.monitor", "PipeWire source sent to the guest")
|
||||
hostLabel = flag.String("host-label", "Host", "name the guest sees on host chat messages")
|
||||
testPlayoutFile = flag.String("test-playout-file", "", "TEST ONLY: write guest audio to this raw file instead of the sink")
|
||||
stateDir = flag.String("state", filepath.Join(os.Getenv("HOME"), ".local/state/guestlink"), "state directory (invite token)")
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- hub
|
||||
|
||||
type chatMsg struct {
|
||||
From string `json:"from"`
|
||||
Who string `json:"who"` // "host" | "guest" | "system"
|
||||
Text string `json:"text"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
type wsConn struct {
|
||||
c *websocket.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (w *wsConn) send(v any) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.c.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
if err := w.c.WriteJSON(v); err != nil {
|
||||
w.c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
guest *wsConn
|
||||
guestName string
|
||||
call *Call
|
||||
hosts map[*wsConn]bool
|
||||
chat []chatMsg
|
||||
udp *net.UDPConn
|
||||
mux ice.UDPMux
|
||||
}
|
||||
|
||||
var hub = &Hub{hosts: map[*wsConn]bool{}}
|
||||
|
||||
func randToken() string {
|
||||
b := make([]byte, 15)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func tokenPath() string { return filepath.Join(*stateDir, "token") }
|
||||
|
||||
func (h *Hub) loadToken() {
|
||||
b, err := os.ReadFile(tokenPath())
|
||||
if err == nil {
|
||||
h.token = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) saveToken() {
|
||||
os.MkdirAll(*stateDir, 0o700)
|
||||
os.WriteFile(tokenPath(), []byte(h.token), 0o600)
|
||||
}
|
||||
|
||||
func (h *Hub) link() string {
|
||||
if h.token == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://" + *publicHost + "/j/" + h.token
|
||||
}
|
||||
|
||||
// stateLocked builds the host-console status message. Caller holds h.mu.
|
||||
func (h *Hub) stateLocked() map[string]any {
|
||||
st := map[string]any{
|
||||
"t": "state",
|
||||
"link": h.link(),
|
||||
"guestOnline": h.guest != nil,
|
||||
"guestName": h.guestName,
|
||||
"call": nil,
|
||||
}
|
||||
if h.call != nil {
|
||||
st["call"] = h.call.status()
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (h *Hub) broadcastHosts(v any) {
|
||||
h.mu.Lock()
|
||||
hs := make([]*wsConn, 0, len(h.hosts))
|
||||
for c := range h.hosts {
|
||||
hs = append(hs, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for _, c := range hs {
|
||||
c.send(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) pushState() {
|
||||
h.mu.Lock()
|
||||
st := h.stateLocked()
|
||||
h.mu.Unlock()
|
||||
h.broadcastHosts(st)
|
||||
}
|
||||
|
||||
func (h *Hub) addChat(m chatMsg) {
|
||||
h.mu.Lock()
|
||||
h.chat = append(h.chat, m)
|
||||
if len(h.chat) > 300 {
|
||||
h.chat = h.chat[len(h.chat)-300:]
|
||||
}
|
||||
g := h.guest
|
||||
h.mu.Unlock()
|
||||
out := map[string]any{"t": "chat", "msg": m}
|
||||
h.broadcastHosts(out)
|
||||
if g != nil {
|
||||
g.send(out)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) system(text string) {
|
||||
h.addChat(chatMsg{From: "", Who: "system", Text: text, TS: time.Now().UnixMilli()})
|
||||
}
|
||||
|
||||
// endCall tears down the current call, if any.
|
||||
func (h *Hub) endCall() {
|
||||
h.mu.Lock()
|
||||
c := h.call
|
||||
h.call = nil
|
||||
h.mu.Unlock()
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
h.pushState()
|
||||
}
|
||||
|
||||
// kickGuest drops the guest's websocket and call.
|
||||
func (h *Hub) kickGuest(reason string) {
|
||||
h.mu.Lock()
|
||||
g := h.guest
|
||||
h.guest = nil
|
||||
h.mu.Unlock()
|
||||
if g != nil {
|
||||
g.send(map[string]any{"t": "ended", "reason": reason})
|
||||
g.c.Close()
|
||||
}
|
||||
h.endCall()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
func cleanText(s string, max int) string {
|
||||
s = strings.TrimSpace(strings.ToValidUTF8(s, ""))
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\t' {
|
||||
return r
|
||||
}
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
if utf8.RuneCountInString(s) > max {
|
||||
s = string([]rune(s)[:max])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func isPrivate(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
_, cg, _ := net.ParseCIDR("100.64.0.0/10") // tailscale / CGNAT VPN
|
||||
return ip.IsLoopback() || ip.IsPrivate() || cg.Contains(ip)
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hd := w.Header()
|
||||
hd.Set("Content-Security-Policy", "default-src 'self'; connect-src 'self' wss: ws:; img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||||
hd.Set("Permissions-Policy", "microphone=(self), camera=(), geolocation=()")
|
||||
hd.Set("Referrer-Policy", "no-referrer")
|
||||
hd.Set("X-Content-Type-Options", "nosniff")
|
||||
hd.Set("Cache-Control", "no-store")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func serveFile(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := webFS.ReadFile("web/" + name)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(b)
|
||||
}
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{ReadBufferSize: 8192, WriteBufferSize: 8192}
|
||||
|
||||
// ---------------------------------------------------------------- guest side
|
||||
|
||||
type rateLimiter struct {
|
||||
times []time.Time
|
||||
n int
|
||||
per time.Duration
|
||||
}
|
||||
|
||||
func (r *rateLimiter) allow() bool {
|
||||
now := time.Now()
|
||||
keep := r.times[:0]
|
||||
for _, t := range r.times {
|
||||
if now.Sub(t) < r.per {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
r.times = keep
|
||||
if len(r.times) >= r.n {
|
||||
return false
|
||||
}
|
||||
r.times = append(r.times, now)
|
||||
return true
|
||||
}
|
||||
|
||||
func guestWS(w http.ResponseWriter, r *http.Request) {
|
||||
tok := r.URL.Query().Get("t")
|
||||
hub.mu.Lock()
|
||||
valid := hub.token != "" && tok == hub.token
|
||||
hub.mu.Unlock()
|
||||
if !valid {
|
||||
http.Error(w, "invite not active", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
c, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.SetReadLimit(64 << 10)
|
||||
me := &wsConn{c: c}
|
||||
|
||||
hub.mu.Lock()
|
||||
old := hub.guest
|
||||
hub.guest = me
|
||||
hist := append([]chatMsg(nil), hub.chat...)
|
||||
hub.mu.Unlock()
|
||||
if old != nil {
|
||||
old.send(map[string]any{"t": "ended", "reason": "This link was opened in another window."})
|
||||
old.c.Close()
|
||||
}
|
||||
log.Printf("guest connected from %s", r.Header.Get("X-Real-IP"))
|
||||
me.send(map[string]any{"t": "welcome", "hostLabel": *hostLabel, "history": hist})
|
||||
hub.pushState()
|
||||
|
||||
chatRL := &rateLimiter{n: 8, per: 10 * time.Second}
|
||||
sigRL := &rateLimiter{n: 200, per: 10 * time.Second}
|
||||
|
||||
defer func() {
|
||||
hub.mu.Lock()
|
||||
still := hub.guest == me
|
||||
if still {
|
||||
hub.guest = nil
|
||||
}
|
||||
hub.mu.Unlock()
|
||||
c.Close()
|
||||
if still {
|
||||
log.Printf("guest websocket closed")
|
||||
hub.pushState()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
var m struct {
|
||||
T string `json:"t"`
|
||||
Text string `json:"text"`
|
||||
Name string `json:"name"`
|
||||
SDP string `json:"sdp"`
|
||||
Candidate *webrtc.ICECandidateInit `json:"candidate"`
|
||||
}
|
||||
if err := c.ReadJSON(&m); err != nil {
|
||||
return
|
||||
}
|
||||
hub.mu.Lock()
|
||||
current := hub.guest == me && hub.token == tok
|
||||
hub.mu.Unlock()
|
||||
if !current {
|
||||
return
|
||||
}
|
||||
if !sigRL.allow() {
|
||||
continue
|
||||
}
|
||||
switch m.T {
|
||||
case "name":
|
||||
n := cleanText(strings.ReplaceAll(m.Name, "\n", " "), 40)
|
||||
hub.mu.Lock()
|
||||
hub.guestName = n
|
||||
hub.mu.Unlock()
|
||||
hub.pushState()
|
||||
case "chat":
|
||||
t := cleanText(m.Text, 1000)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if !chatRL.allow() {
|
||||
me.send(map[string]any{"t": "notice", "text": "Slow down a little - message not sent."})
|
||||
continue
|
||||
}
|
||||
hub.mu.Lock()
|
||||
name := hub.guestName
|
||||
hub.mu.Unlock()
|
||||
if name == "" {
|
||||
name = "Guest"
|
||||
}
|
||||
hub.addChat(chatMsg{From: name, Who: "guest", Text: t, TS: time.Now().UnixMilli()})
|
||||
case "offer":
|
||||
hub.endCall() // one call at a time; a new offer replaces the old one
|
||||
call, answer, err := newCall(m.SDP)
|
||||
if err != nil {
|
||||
log.Printf("offer failed: %v", err)
|
||||
me.send(map[string]any{"t": "error", "text": "Could not start audio: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
hub.mu.Lock()
|
||||
hub.call = call
|
||||
hub.mu.Unlock()
|
||||
me.send(map[string]any{"t": "answer", "sdp": answer})
|
||||
hub.pushState()
|
||||
case "candidate":
|
||||
hub.mu.Lock()
|
||||
call := hub.call
|
||||
hub.mu.Unlock()
|
||||
if call != nil && m.Candidate != nil {
|
||||
call.pc.AddICECandidate(*m.Candidate)
|
||||
}
|
||||
case "hangup":
|
||||
hub.endCall()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- host side
|
||||
|
||||
func hostWS(w http.ResponseWriter, r *http.Request) {
|
||||
if !isPrivate(r.RemoteAddr) {
|
||||
http.Error(w, "LAN only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
c, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
me := &wsConn{c: c}
|
||||
hub.mu.Lock()
|
||||
hub.hosts[me] = true
|
||||
hist := append([]chatMsg(nil), hub.chat...)
|
||||
st := hub.stateLocked()
|
||||
hub.mu.Unlock()
|
||||
me.send(map[string]any{"t": "history", "history": hist, "hostLabel": *hostLabel})
|
||||
me.send(st)
|
||||
defer func() {
|
||||
hub.mu.Lock()
|
||||
delete(hub.hosts, me)
|
||||
hub.mu.Unlock()
|
||||
c.Close()
|
||||
}()
|
||||
for {
|
||||
var m struct {
|
||||
T string `json:"t"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ReadJSON(&m); err != nil {
|
||||
return
|
||||
}
|
||||
switch m.T {
|
||||
case "chat":
|
||||
if t := cleanText(m.Text, 2000); t != "" {
|
||||
hub.addChat(chatMsg{From: *hostLabel, Who: "host", Text: t, TS: time.Now().UnixMilli()})
|
||||
}
|
||||
case "newlink":
|
||||
hub.kickGuest("The host started a new session.")
|
||||
hub.mu.Lock()
|
||||
hub.token = randToken()
|
||||
hub.chat = nil
|
||||
hub.guestName = ""
|
||||
hub.saveToken()
|
||||
hub.mu.Unlock()
|
||||
log.Printf("new invite link created")
|
||||
hub.broadcastHosts(map[string]any{"t": "history", "history": []chatMsg{}, "hostLabel": *hostLabel})
|
||||
hub.pushState()
|
||||
case "revoke":
|
||||
hub.kickGuest("The host closed this session.")
|
||||
hub.mu.Lock()
|
||||
hub.token = ""
|
||||
hub.saveToken()
|
||||
hub.mu.Unlock()
|
||||
log.Printf("invite link revoked")
|
||||
hub.pushState()
|
||||
case "kick":
|
||||
hub.kickGuest("The host ended the call.")
|
||||
case "endcall":
|
||||
hub.mu.Lock()
|
||||
g := hub.guest
|
||||
hub.mu.Unlock()
|
||||
hub.endCall()
|
||||
if g != nil {
|
||||
g.send(map[string]any{"t": "callended"})
|
||||
}
|
||||
case "clearchat":
|
||||
hub.mu.Lock()
|
||||
hub.chat = nil
|
||||
g := hub.guest
|
||||
hub.mu.Unlock()
|
||||
clr := map[string]any{"t": "history", "history": []chatMsg{}, "hostLabel": *hostLabel}
|
||||
hub.broadcastHosts(clr)
|
||||
if g != nil {
|
||||
g.send(clr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- main
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(log.LstdFlags)
|
||||
hub.loadToken()
|
||||
|
||||
udp, err := net.ListenUDP("udp4", &net.UDPAddr{Port: *udpPort})
|
||||
if err != nil {
|
||||
log.Fatalf("udp listen: %v", err)
|
||||
}
|
||||
hub.udp = udp
|
||||
hub.mux = webrtc.NewICEUDPMux(nil, udp)
|
||||
|
||||
static, _ := fs.Sub(webFS, "web/static")
|
||||
staticH := http.StripPrefix("/static/", http.FileServer(http.FS(static)))
|
||||
|
||||
pub := http.NewServeMux()
|
||||
pub.Handle("/static/", staticH)
|
||||
pub.HandleFunc("/j/", serveFile("guest.html"))
|
||||
pub.HandleFunc("/ws", guestWS)
|
||||
pub.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
|
||||
pub.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveFile("index.html")(w, r)
|
||||
})
|
||||
|
||||
host := http.NewServeMux()
|
||||
host.Handle("/static/", staticH)
|
||||
host.HandleFunc("/ws", hostWS)
|
||||
host.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isPrivate(r.RemoteAddr) {
|
||||
http.Error(w, "LAN only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveFile("host.html")(w, r)
|
||||
})
|
||||
|
||||
// Periodic status for the host console (levels, jitter buffer).
|
||||
go func() {
|
||||
for range time.Tick(150 * time.Millisecond) {
|
||||
hub.mu.Lock()
|
||||
active := hub.call != nil && len(hub.hosts) > 0
|
||||
hub.mu.Unlock()
|
||||
if active {
|
||||
hub.pushState()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
log.Printf("host console on http://%s", *hostAddr)
|
||||
log.Fatal(http.ListenAndServe(*hostAddr, securityHeaders(host)))
|
||||
}()
|
||||
log.Printf("guest listener on %s, media on udp/%d", *publicAddr, *udpPort)
|
||||
log.Fatal(http.ListenAndServe(*publicAddr, securityHeaders(pub)))
|
||||
}
|
||||
|
||||
// jsonString is used by the call code for compact logging.
|
||||
func jsonString(v any) string { b, _ := json.Marshal(v); return string(b) }
|
||||
Reference in new issue
Block a user