The co-op link was authenticated (HMAC token, never sent over the wire) but not encrypted. The hub now generates a self-signed cert on first run; the peer pins its exact fingerprint (no CA involved — there isn't one for a Tailscale/LAN address), delivered via a single invite-code paste that also carries the token, replacing today's separate IP+token copy. The peer link moves to its own TLS-only port (peer_listen, 47823) so the plain overlay/panel port (47822, OBS-facing) never needs to be exposed alongside it — today, opening the overlay port to a remote partner also exposes /deaths and the panel to anyone. Mandatory pinning, no insecure fallback: a half-configured peer (some but not all of hub/token/fingerprint, or a broken invite) fails loudly at startup rather than connecting unpinned. An unconfigured peer still runs fine as a local-only overlay, same as before. New: tlscert.go (cert generation/persistence), pin.go (fingerprint pinning), invite.go (invite-code encode/decode, host auto-detection), each with tests. main.go/config.go/duo.go/ws.go carry the wiring for this — the dual listener, new config keys, and the TLS-aware WebSocket dial — and were rewritten in English in the process, per the project's new English-only code convention (see CLAUDE.md).
466 lines
14 KiB
Go
466 lines
14 KiB
Go
// duo.go: co-op mode. Each player runs the program on their own PC,
|
|
// reading their own Elden Ring; one acts as the "hub" (serves the overlay
|
|
// to OBS) and the rest connect to it over WebSocket, pushing their count.
|
|
//
|
|
// The connection is ALWAYS opened by the peer toward the hub, which is
|
|
// what's convenient with Tailscale: it's enough for the hub to have a
|
|
// stable IP on the tailnet.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// If a peer sends nothing for this long, we consider it disconnected.
|
|
// The peer pushes once a second, so this is generous.
|
|
const peerTimeout = 6 * time.Second
|
|
|
|
// How long we keep showing the co-op layout after the partner disappears.
|
|
// See coopMode() for why.
|
|
const coopGrace = 2 * time.Minute
|
|
|
|
// --------------------------- peer registry ---------------------------
|
|
|
|
type playerView struct {
|
|
Name string `json:"name"`
|
|
Deaths int64 `json:"deaths"`
|
|
BossFight bool `json:"bossFight"`
|
|
PlayerLoaded bool `json:"playerLoaded"`
|
|
Connected bool `json:"connected"`
|
|
Self bool `json:"self"`
|
|
}
|
|
|
|
// peerEntry: WATCH OUT for identity. The name does NOT work as a key:
|
|
// from the moment we read it off the character, it changes whenever the
|
|
// player loads a different save, and indexing by name would spawn a new
|
|
// player on every character switch while leaving the old one stuck on
|
|
// screen. Identity is the id each instance sends (see clientID), stable
|
|
// across reconnects and restarts; the name is only something we display.
|
|
type peerEntry struct {
|
|
key string // how we index it: the id, or "name:x" for an old client
|
|
name string
|
|
deaths int64
|
|
bossFight bool
|
|
playerLoaded bool
|
|
lastSeen time.Time
|
|
everSeen bool
|
|
reserved bool // slot reserved by config, not yet claimed
|
|
}
|
|
|
|
type peerRegistry struct {
|
|
mu sync.Mutex
|
|
peers map[string]*peerEntry
|
|
order []string // order of appearance, so the overlay doesn't reshuffle
|
|
|
|
forced bool // the config named a partner: co-op from startup
|
|
latched bool // we've already seen a partner this run
|
|
lastCoop time.Time // last time someone was connected
|
|
}
|
|
|
|
func newPeerRegistry() *peerRegistry {
|
|
return &peerRegistry{peers: map[string]*peerEntry{}}
|
|
}
|
|
|
|
// declare reserves a partner's slot before they connect and locks in
|
|
// co-op mode from startup. Optional: useful when you want the overlay at
|
|
// its final size from minute zero instead of resizing once the other
|
|
// player shows up.
|
|
func (r *peerRegistry) declare(name string) {
|
|
if name == "" {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
key := "reserved:" + name
|
|
if _, ok := r.peers[key]; !ok {
|
|
r.peers[key] = &peerEntry{key: key, name: name, reserved: true}
|
|
r.order = append(r.order, key)
|
|
}
|
|
r.forced = true
|
|
}
|
|
|
|
// coopMode decides whether the overlay shows one column or two.
|
|
//
|
|
// It latches on when a partner shows up and does NOT let go at the first
|
|
// hiccup: if it fell back to solo mode every time the network dropped for
|
|
// a second, the overlay would redraw live mid-stream. It only returns to
|
|
// single-player once the other player has been gone for a good while,
|
|
// which is the signal for "went to sleep", not "wifi hiccuped".
|
|
func (r *peerRegistry) coopMode() bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.forced {
|
|
return true
|
|
}
|
|
now := time.Now()
|
|
for _, p := range r.peers {
|
|
if p.everSeen && now.Sub(p.lastSeen) < peerTimeout {
|
|
if !r.latched {
|
|
r.latched = true
|
|
log.Printf("a partner is connected: switching the overlay to co-op mode")
|
|
}
|
|
r.lastCoop = now
|
|
return true
|
|
}
|
|
}
|
|
if r.latched {
|
|
if now.Sub(r.lastCoop) < coopGrace {
|
|
return true
|
|
}
|
|
r.latched = false
|
|
log.Printf("no partner has shown up in %v: going back to the single-player overlay", coopGrace)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// keyFor: the id wins. If it came in empty (an old version on the other
|
|
// end), fall back to the name, which is how it used to work.
|
|
func keyFor(m peerMessage) string {
|
|
if id := strings.TrimSpace(m.ID); id != "" {
|
|
return "id:" + id
|
|
}
|
|
return "name:" + strings.ToLower(strings.TrimSpace(m.Name))
|
|
}
|
|
|
|
// rekey moves an entry to another key while keeping its place in the
|
|
// order, so the overlay doesn't reshuffle columns underneath it.
|
|
func (r *peerRegistry) rekey(p *peerEntry, newKey string) {
|
|
delete(r.peers, p.key)
|
|
for i, k := range r.order {
|
|
if k == p.key {
|
|
r.order[i] = newKey
|
|
break
|
|
}
|
|
}
|
|
p.key = newKey
|
|
r.peers[newKey] = p
|
|
}
|
|
|
|
func (r *peerRegistry) update(m peerMessage) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
key := keyFor(m)
|
|
p := r.peers[key]
|
|
|
|
// No entry of its own: if there's a slot reserved by config still
|
|
// unclaimed, the first one to arrive takes it. Reserving it by exact
|
|
// name wouldn't work, since the name sent is the character's.
|
|
if p == nil {
|
|
for _, k := range r.order {
|
|
if e := r.peers[k]; e.reserved && !e.everSeen {
|
|
e.reserved = false
|
|
r.rekey(e, key)
|
|
p = e
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if p == nil {
|
|
p = &peerEntry{key: key}
|
|
r.peers[key] = p
|
|
r.order = append(r.order, key)
|
|
}
|
|
|
|
if n := strings.TrimSpace(m.Name); n != "" && n != p.name {
|
|
if p.name != "" && p.everSeen {
|
|
log.Printf("%s switched characters: now %s", p.name, n)
|
|
}
|
|
p.name = n
|
|
}
|
|
p.deaths = m.Deaths
|
|
p.bossFight = m.BossFight
|
|
p.playerLoaded = m.PlayerLoaded
|
|
p.lastSeen = time.Now()
|
|
p.everSeen = true
|
|
}
|
|
|
|
func (r *peerRegistry) views() []playerView {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
now := time.Now()
|
|
out := make([]playerView, 0, len(r.order))
|
|
for _, k := range r.order {
|
|
p := r.peers[k]
|
|
// Anyone who hasn't shown up in a long while leaves the overlay:
|
|
// same threshold we use to fall back to single-player mode.
|
|
if p.everSeen && now.Sub(p.lastSeen) > coopGrace {
|
|
continue
|
|
}
|
|
out = append(out, playerView{
|
|
Name: p.name,
|
|
Deaths: p.deaths,
|
|
BossFight: p.bossFight,
|
|
PlayerLoaded: p.playerLoaded,
|
|
Connected: p.everSeen && now.Sub(p.lastSeen) < peerTimeout,
|
|
Self: false,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ------------------------- message between the two -------------------------
|
|
|
|
// authChallenge is sent by the hub as soon as the connection opens.
|
|
type authChallenge struct {
|
|
Type string `json:"type"` // "challenge"
|
|
Nonce string `json:"nonce"`
|
|
}
|
|
|
|
// authReply is the peer's response: proves it knows the token without
|
|
// sending it. See auth.go.
|
|
type authReply struct {
|
|
Type string `json:"type"` // "auth"
|
|
ID string `json:"id"`
|
|
Proof string `json:"proof"`
|
|
}
|
|
|
|
type peerMessage struct {
|
|
// ID identifies the installation, not the character: it's what lets a
|
|
// character switch avoid spawning a new player in the overlay.
|
|
ID string `json:"id,omitempty"`
|
|
Type string `json:"type,omitempty"` // "state"
|
|
Name string `json:"name"`
|
|
Deaths int64 `json:"deaths"`
|
|
BossFight bool `json:"bossFight"`
|
|
PlayerLoaded bool `json:"playerLoaded"`
|
|
}
|
|
|
|
// ------------------------------- hub side -------------------------------
|
|
|
|
func (r *peerRegistry) wsHandler(token string) http.HandlerFunc {
|
|
var active atomic.Int32
|
|
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
if n := active.Add(1); n > maxPeerConns {
|
|
active.Add(-1)
|
|
log.Printf("rejecting connection from %s: already %d connections open", req.RemoteAddr, maxPeerConns)
|
|
http.Error(w, "too many connections", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer active.Add(-1)
|
|
|
|
c, err := wsUpgrade(w, req)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer c.Close()
|
|
|
|
remote := c.RemoteAddr()
|
|
|
|
// --- authentication: random challenge, the token never travels ---
|
|
nonce, err := randomHex(nonceBytes)
|
|
if err != nil {
|
|
log.Printf("couldn't generate the challenge for %s: %v", remote, err)
|
|
return
|
|
}
|
|
ch, _ := json.Marshal(authChallenge{Type: "challenge", Nonce: nonce})
|
|
if err := c.WriteText(ch); err != nil {
|
|
return
|
|
}
|
|
|
|
c.SetReadDeadline(time.Now().Add(authTimeoutSeconds * time.Second))
|
|
raw, err := c.ReadMessage()
|
|
if err != nil {
|
|
log.Printf("%s left without authenticating: %v", remote, err)
|
|
return
|
|
}
|
|
var reply authReply
|
|
if err := json.Unmarshal(raw, &reply); err != nil || reply.Type != "auth" {
|
|
log.Printf("rejecting %s: didn't send a valid authentication response", remote)
|
|
return
|
|
}
|
|
if !proofValid(token, nonce, reply.Proof) {
|
|
log.Printf("rejecting %s: token doesn't match", remote)
|
|
return
|
|
}
|
|
log.Printf("a partner authenticated from %s", remote)
|
|
|
|
var who string
|
|
for {
|
|
c.SetReadDeadline(time.Now().Add(15 * time.Second))
|
|
raw, err := c.ReadMessage()
|
|
if err != nil {
|
|
if who != "" {
|
|
log.Printf("%s disconnected (%s): %v", who, remote, err)
|
|
} else {
|
|
log.Printf("%s disconnected: %v", remote, err)
|
|
}
|
|
return
|
|
}
|
|
var m peerMessage
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
log.Printf("unreadable message from %s: %v", remote, err)
|
|
continue
|
|
}
|
|
// The authenticated connection's id wins: a message declaring
|
|
// a different id doesn't turn it into another player.
|
|
if reply.ID != "" {
|
|
m.ID = reply.ID
|
|
}
|
|
if strings.TrimSpace(m.Name) == "" {
|
|
m.Name = "Player 2"
|
|
}
|
|
if who == "" {
|
|
who = m.Name
|
|
log.Printf("%s joined the shared counter", who)
|
|
}
|
|
r.update(m)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------ peer side ------------------------------
|
|
|
|
// resolvePeerConn decides which hub address, token, and certificate
|
|
// fingerprint to use for the peer connection — see invite.go/pin.go.
|
|
//
|
|
// ok=false, err=nil means peer mode has nothing configured yet (no
|
|
// invite, no manual hub/token/fingerprint): that's not an error, it's
|
|
// today's "not set up" state, and the caller should keep running as a
|
|
// local-only overlay instead of trying to connect anywhere.
|
|
//
|
|
// A non-nil err means something WAS configured but is broken: a bad
|
|
// invite code, or only some of hub/token/fingerprint set by hand. This
|
|
// never falls back to an unpinned connection — an incomplete
|
|
// configuration is meant to fail loudly at startup, not connect insecurely.
|
|
func resolvePeerConn(cfg config) (hub, token, fingerprint string, ok bool, err error) {
|
|
if inv := strings.TrimSpace(cfg.Invite); inv != "" {
|
|
code, derr := decodeInvite(inv)
|
|
if derr != nil {
|
|
return "", "", "", false, fmt.Errorf("invalid invite code: %w", derr)
|
|
}
|
|
hub = code.Host + ":" + code.Port
|
|
if h := strings.TrimSpace(cfg.Hub); h != "" {
|
|
hub = h // manual override of just the host:port, e.g. a wrong auto-detected host
|
|
}
|
|
return hub, code.Token, code.Fingerprint, true, nil
|
|
}
|
|
|
|
h := strings.TrimSpace(cfg.Hub)
|
|
t := strings.TrimSpace(cfg.Token)
|
|
f := strings.TrimSpace(cfg.Fingerprint)
|
|
switch {
|
|
case h == "" && t == "" && f == "":
|
|
return "", "", "", false, nil
|
|
case h == "" || t == "" || f == "":
|
|
return "", "", "", false, fmt.Errorf("'hub', 'token', and 'fingerprint' all need to be set together in config.toml (or use 'invite' instead)")
|
|
}
|
|
return h, t, f, true, nil
|
|
}
|
|
|
|
// peerLoop keeps the connection to the hub alive and pushes local state
|
|
// once a second. Retries forever: if the hub restarts mid-stream, it
|
|
// reconnects without any intervention.
|
|
//
|
|
// hub/token/fingerprint come from resolvePeerConn, already validated as
|
|
// complete by the caller — see main.go.
|
|
func peerLoop(cfg config, hub, token, fingerprint string) {
|
|
id := clientID()
|
|
log.Printf("my client id is %s (switching characters doesn't create a new player on the other end)", id)
|
|
cfg.clientID = id
|
|
cfg.Token = token
|
|
|
|
tlsCfg, err := pinnedClientTLSConfig(fingerprint)
|
|
if err != nil {
|
|
log.Fatalf("invalid peer fingerprint: %v", err)
|
|
}
|
|
|
|
for {
|
|
c, err := wsDialTLS(hub, "/ws", 8*time.Second, tlsCfg)
|
|
if err != nil {
|
|
log.Printf("couldn't connect to the hub %s (%v) - retrying in 5s", hub, err)
|
|
time.Sleep(5 * time.Second)
|
|
continue
|
|
}
|
|
if err := authenticate(c, cfg); err != nil {
|
|
c.Close()
|
|
log.Printf("the hub didn't accept me (%v) - retrying in 5s", err)
|
|
time.Sleep(5 * time.Second)
|
|
continue
|
|
}
|
|
log.Printf("connected and authenticated with the hub %s", hub)
|
|
|
|
err = pushLoop(c, cfg)
|
|
c.Close()
|
|
log.Printf("connection to the hub dropped (%v) - retrying in 5s", err)
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
}
|
|
|
|
// authenticate answers the hub's challenge. The token itself isn't sent:
|
|
// an HMAC of the challenge, keyed by the token, is sent instead.
|
|
func authenticate(c *wsConn, cfg config) error {
|
|
c.SetReadDeadline(time.Now().Add(authTimeoutSeconds * time.Second))
|
|
raw, err := c.ReadMessage()
|
|
if err != nil {
|
|
return fmt.Errorf("the challenge never arrived: %w", err)
|
|
}
|
|
var ch authChallenge
|
|
if err := json.Unmarshal(raw, &ch); err != nil || ch.Type != "challenge" || ch.Nonce == "" {
|
|
return fmt.Errorf("couldn't make sense of the hub's challenge")
|
|
}
|
|
if strings.TrimSpace(cfg.Token) == "" {
|
|
return fmt.Errorf("you're missing the token in config.toml: ask whoever runs the hub for it")
|
|
}
|
|
reply, _ := json.Marshal(authReply{
|
|
Type: "auth",
|
|
ID: cfg.clientID,
|
|
Proof: proofFor(cfg.Token, ch.Nonce),
|
|
})
|
|
if err := c.WriteText(reply); err != nil {
|
|
return err
|
|
}
|
|
c.SetReadDeadline(time.Time{})
|
|
return nil
|
|
}
|
|
|
|
func pushLoop(c *wsConn, cfg config) error {
|
|
// A background reader: we don't expect messages from the hub, but we
|
|
// still need to answer its pings and notice if it hangs up.
|
|
readErr := make(chan error, 1)
|
|
go func() {
|
|
for {
|
|
if _, err := c.ReadMessage(); err != nil {
|
|
readErr <- err
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
tick := time.NewTicker(1 * time.Second)
|
|
defer tick.Stop()
|
|
|
|
for {
|
|
select {
|
|
case err := <-readErr:
|
|
return err
|
|
case <-tick.C:
|
|
snap := st.snapshot()
|
|
msg := peerMessage{
|
|
Type: "state",
|
|
ID: cfg.clientID,
|
|
Name: displayName(cfg),
|
|
Deaths: snap.Total,
|
|
BossFight: snap.BossFight,
|
|
PlayerLoaded: snap.PlayerLoaded,
|
|
}
|
|
b, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := c.WriteText(b); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|