Add TLS + certificate pinning for the peer link

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).
This commit is contained in:
emmatherock committed 2026-09-17 21:51:22 -03:00
1 parent e1f7e6f529
commit e9fe10f0f7
12 files changed
+1198 -465

No files matched your search

+136 -88
View File
@@ -1,9 +1,10 @@
// duo.go: modo cooperativo. Cada jugador corre el programa en su propia
// PC leyendo su propio Elden Ring; uno hace de "hub" (sirve el overlay a
// OBS) y el resto se conectan a el por WebSocket y le empujan su contador.
// 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.
//
// La conexion la abre SIEMPRE el peer hacia el hub, que es lo comodo con
// Tailscale: alcanza con que el hub tenga una IP estable en la tailnet.
// 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 (
@@ -17,15 +18,15 @@ import (
"time"
)
// Si un peer no manda nada en este tiempo, lo damos por desconectado.
// El peer empuja una vez por segundo, asi que es holgado.
// 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
// Cuanto seguimos mostrando la interfaz de coop despues de que el
// compañero desaparece. Ver coopMode() para el porque.
// How long we keep showing the co-op layout after the partner disappears.
// See coopMode() for why.
const coopGrace = 2 * time.Minute
// --------------------------- registro de peers ---------------------------
// --------------------------- peer registry ---------------------------
type playerView struct {
Name string `json:"name"`
@@ -36,41 +37,41 @@ type playerView struct {
Self bool `json:"self"`
}
// peerEntry: OJO con la identidad. El nombre NO sirve como clave: desde
// que lo leemos del personaje, cambia cuando el jugador carga otra
// partida, y si indexaramos por nombre cada cambio de personaje crearia
// un jugador nuevo y dejaria el anterior colgado en pantalla. La
// identidad es el id que manda cada instancia (ver clientID), estable
// entre reconexiones y reinicios; el nombre es solo algo que mostramos.
// 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 // como lo indexamos: el id, o "name:x" si es viejo
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 // lugar apartado por config, todavia sin dueño
reserved bool // slot reserved by config, not yet claimed
}
type peerRegistry struct {
mu sync.Mutex
peers map[string]*peerEntry
order []string // orden de aparicion, para que el overlay no baile
order []string // order of appearance, so the overlay doesn't reshuffle
forced bool // el config nombro un compañero: coop desde el arranque
latched bool // ya vimos un compañero en esta corrida
lastCoop time.Time // ultima vez que hubo alguien conectado
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 reserva el lugar de un compañero antes de que se conecte y
// fija el modo coop desde el arranque. Es opcional: sirve cuando querés
// que el overlay tenga el tamaño definitivo desde el minuto cero en vez
// de cambiar cuando el otro aparece.
// 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
@@ -85,13 +86,13 @@ func (r *peerRegistry) declare(name string) {
r.forced = true
}
// coopMode decide si el overlay va en dos columnas o en una.
// coopMode decides whether the overlay shows one column or two.
//
// Se engancha cuando aparece un compañero y NO se suelta al primer
// bache: si volviera a modo solo cada vez que se corta la red un
// segundo, el overlay se redibujaria en vivo en pleno stream. Recien
// vuelve al modo de un jugador cuando el otro estuvo ausente un buen
// rato, que es la señal de "se fue a dormir", no de "se le colgo el wifi".
// 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()
@@ -103,7 +104,7 @@ func (r *peerRegistry) coopMode() bool {
if p.everSeen && now.Sub(p.lastSeen) < peerTimeout {
if !r.latched {
r.latched = true
log.Printf("hay un compañero conectado: paso el overlay a modo coop")
log.Printf("a partner is connected: switching the overlay to co-op mode")
}
r.lastCoop = now
return true
@@ -114,13 +115,13 @@ func (r *peerRegistry) coopMode() bool {
return true
}
r.latched = false
log.Printf("hace %v que no aparece ningun compañero: vuelvo al overlay de un jugador", coopGrace)
log.Printf("no partner has shown up in %v: going back to the single-player overlay", coopGrace)
}
return false
}
// keyFor: el id manda. Si viniera vacio (una version vieja del otro
// lado) caemos al nombre, que es lo que habia antes.
// 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
@@ -128,8 +129,8 @@ func keyFor(m peerMessage) string {
return "name:" + strings.ToLower(strings.TrimSpace(m.Name))
}
// rekey mueve una entrada a otra clave conservando su lugar en el orden,
// para que el overlay no reordene columnas por debajo.
// 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 {
@@ -149,9 +150,9 @@ func (r *peerRegistry) update(m peerMessage) {
key := keyFor(m)
p := r.peers[key]
// Sin entrada propia: si hay un lugar apartado por config todavia sin
// dueño, lo toma el primero que llega. Apartarlo por nombre exacto no
// serviria, porque el nombre que manda es el del personaje.
// 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 {
@@ -170,7 +171,7 @@ func (r *peerRegistry) update(m peerMessage) {
if n := strings.TrimSpace(m.Name); n != "" && n != p.name {
if p.name != "" && p.everSeen {
log.Printf("%s cambio de personaje: ahora es %s", p.name, n)
log.Printf("%s switched characters: now %s", p.name, n)
}
p.name = n
}
@@ -188,8 +189,8 @@ func (r *peerRegistry) views() []playerView {
out := make([]playerView, 0, len(r.order))
for _, k := range r.order {
p := r.peers[k]
// Los que hace mucho que no aparecen se van del overlay: es el
// mismo umbral con el que volvemos al modo de un jugador.
// 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
}
@@ -205,16 +206,16 @@ func (r *peerRegistry) views() []playerView {
return out
}
// ------------------------- mensaje entre los dos -------------------------
// ------------------------- message between the two -------------------------
// authChallenge lo manda el hub apenas se abre la conexion.
// 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 es la respuesta del peer: prueba que conoce el token sin
// mandarlo. Ver auth.go.
// 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"`
@@ -222,8 +223,8 @@ type authReply struct {
}
type peerMessage struct {
// ID identifica a la instalacion, no al personaje: es lo que permite
// que cambiar de personaje no cree un jugador nuevo en el overlay.
// 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"`
@@ -232,19 +233,19 @@ type peerMessage struct {
PlayerLoaded bool `json:"playerLoaded"`
}
// ------------------------------- lado hub -------------------------------
// ------------------------------- hub side -------------------------------
func (r *peerRegistry) wsHandler(token string) http.HandlerFunc {
var activas atomic.Int32
var active atomic.Int32
return func(w http.ResponseWriter, req *http.Request) {
if n := activas.Add(1); n > maxPeerConns {
activas.Add(-1)
log.Printf("rechazo conexion de %s: ya hay %d conexiones abiertas", req.RemoteAddr, maxPeerConns)
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 activas.Add(-1)
defer active.Add(-1)
c, err := wsUpgrade(w, req)
if err != nil {
@@ -255,10 +256,10 @@ func (r *peerRegistry) wsHandler(token string) http.HandlerFunc {
remote := c.RemoteAddr()
// --- autenticacion: desafio al azar, el token no viaja ---
// --- authentication: random challenge, the token never travels ---
nonce, err := randomHex(nonceBytes)
if err != nil {
log.Printf("no pude generar el desafio para %s: %v", remote, err)
log.Printf("couldn't generate the challenge for %s: %v", remote, err)
return
}
ch, _ := json.Marshal(authChallenge{Type: "challenge", Nonce: nonce})
@@ -269,19 +270,19 @@ func (r *peerRegistry) wsHandler(token string) http.HandlerFunc {
c.SetReadDeadline(time.Now().Add(authTimeoutSeconds * time.Second))
raw, err := c.ReadMessage()
if err != nil {
log.Printf("%s se fue sin autenticarse: %v", remote, err)
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("rechazo %s: no mando una respuesta de autenticacion valida", remote)
log.Printf("rejecting %s: didn't send a valid authentication response", remote)
return
}
if !proofValid(token, nonce, reply.Proof) {
log.Printf("rechazo %s: el token no coincide", remote)
log.Printf("rejecting %s: token doesn't match", remote)
return
}
log.Printf("se autentico un compañero desde %s", remote)
log.Printf("a partner authenticated from %s", remote)
var who string
for {
@@ -289,79 +290,126 @@ func (r *peerRegistry) wsHandler(token string) http.HandlerFunc {
raw, err := c.ReadMessage()
if err != nil {
if who != "" {
log.Printf("se desconecto %s (%s): %v", who, remote, err)
log.Printf("%s disconnected (%s): %v", who, remote, err)
} else {
log.Printf("se desconecto %s: %v", remote, err)
log.Printf("%s disconnected: %v", remote, err)
}
return
}
var m peerMessage
if err := json.Unmarshal(raw, &m); err != nil {
log.Printf("mensaje ilegible de %s: %v", remote, err)
log.Printf("unreadable message from %s: %v", remote, err)
continue
}
// El id de la conexion autenticada manda: que un mensaje
// declare otro id no lo convierte en otro jugador.
// 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 = "Jugador 2"
m.Name = "Player 2"
}
if who == "" {
who = m.Name
log.Printf("%s entro al contador compartido", who)
log.Printf("%s joined the shared counter", who)
}
r.update(m)
}
}
}
// ------------------------------ lado peer ------------------------------
// ------------------------------ peer side ------------------------------
// peerLoop mantiene viva la conexion con el hub y le empuja el estado
// local una vez por segundo. Reintenta solo para siempre: si el hub se
// reinicia en medio del stream, se vuelve a enganchar sin tocar nada.
func peerLoop(cfg config) {
// 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("mi id de cliente es %s (cambiar de personaje no crea un jugador nuevo del otro lado)", id)
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 := wsDial(cfg.Hub, "/ws", 8*time.Second)
c, err := wsDialTLS(hub, "/ws", 8*time.Second, tlsCfg)
if err != nil {
log.Printf("no me pude conectar al hub %s (%v) - reintento en 5s", cfg.Hub, err)
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("el hub no me acepto (%v) - reintento en 5s", err)
log.Printf("the hub didn't accept me (%v) - retrying in 5s", err)
time.Sleep(5 * time.Second)
continue
}
log.Printf("conectado y autenticado con el hub %s", cfg.Hub)
log.Printf("connected and authenticated with the hub %s", hub)
err = pushLoop(c, cfg)
c.Close()
log.Printf("se corto la conexion con el hub (%v) - reintento en 5s", err)
log.Printf("connection to the hub dropped (%v) - retrying in 5s", err)
time.Sleep(5 * time.Second)
}
}
// authenticate responde el desafio del hub. El token no se manda: se
// manda un HMAC del desafio hecho con el token.
// 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("no llego el desafio: %w", err)
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("el desafio del hub no se entiende")
return fmt.Errorf("couldn't make sense of the hub's challenge")
}
if strings.TrimSpace(cfg.Token) == "" {
return fmt.Errorf("te falta el token en config.toml: pedíselo a quien corre el hub")
return fmt.Errorf("you're missing the token in config.toml: ask whoever runs the hub for it")
}
reply, _ := json.Marshal(authReply{
Type: "auth",
@@ -376,8 +424,8 @@ func authenticate(c *wsConn, cfg config) error {
}
func pushLoop(c *wsConn, cfg config) error {
// Un lector en segundo plano: no esperamos mensajes del hub, pero hay
// que atender sus pings y enterarnos si corta.
// 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 {
@@ -400,7 +448,7 @@ func pushLoop(c *wsConn, cfg config) error {
msg := peerMessage{
Type: "state",
ID: cfg.clientID,
Name: nombreMostrado(cfg),
Name: displayName(cfg),
Deaths: snap.Total,
BossFight: snap.BossFight,
PlayerLoaded: snap.PlayerLoaded,