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

+106 -65
View File
@@ -1,16 +1,16 @@
// config.go: lectura de config.toml.
// config.go: reading config.toml.
//
// Parser de TOML hecho a mano y a proposito ACOTADO. La razon de no usar
// una libreria (BurntSushi/toml o similar) es que el entorno donde se
// compila esto no tiene acceso al proxy de modulos de Go, y ademas
// mantiene el programa como un unico .exe sin dependencias.
// A hand-rolled, deliberately LIMITED TOML parser. The reason for not
// using a library (BurntSushi/toml or similar) is that the environment
// this was originally compiled in had no access to the Go module proxy,
// and it also keeps the program a single .exe with no dependencies.
//
// Soporta: comentarios (#), cabeceras de seccion ([algo]), y claves
// "clave = valor" donde el valor es un string ("..." o '...'), un
// booleano o un entero. NO soporta arrays, tablas inline, strings
// multilinea ni claves con puntos. Cualquier cosa fuera de ese subconjunto
// se reporta con numero de linea en vez de ignorarse en silencio: es
// preferible enterarse al arrancar y no a mitad de un stream.
// Supports: comments (#), section headers ([something]), and
// "key = value" entries where the value is a string ("..." or '...'), a
// boolean, or an integer. Does NOT support arrays, inline tables,
// multiline strings, or dotted keys. Anything outside that subset gets
// reported with a line number instead of silently ignored: better to
// find out at startup than midway through a stream.
package main
import (
@@ -25,29 +25,36 @@ import (
)
const (
// Escucha en todas las interfaces: OBS suele correr en otra PC de la
// LAN, asi que 127.0.0.1 (solo local) no alcanza.
// Listens on every interface: OBS often runs on another PC on the
// LAN, so 127.0.0.1 (local only) isn't enough.
listenAddr = "0.0.0.0:47822"
buildTag = "build-20-auth-obligatoria"
// Separate port, TLS only, for the peer alone: see pin.go/tlscert.go.
// The overlay/panel/deaths NEVER go through here, on purpose.
peerListenAddr = "0.0.0.0:47823"
buildTag = "build-21-tls-peer-link"
)
type config struct {
Name string // como se muestra este jugador; vacio = nombre del personaje
Mode string // "hub" o "peer"
Listen string // donde se sirve el panel/overlay
Hub string // modo peer: direccion del hub
Token string // secreto compartido opcional
Partner string // opcional: fuerza el modo coop reservando este nombre
Language string // idioma de la interfaz: "auto", "en", "es", ...
Name string // how this player is shown; empty = character name
Mode string // "hub" or "peer"
Listen string // where the panel/overlay is served (HTTP, no TLS)
PeerListen string // hub: where it listens for the peer connection (TLS)
Hub string // peer mode: the hub's address (host:PeerListen)
Token string // optional shared secret
Invite string // peer mode: invitation code (see invite.go)
Fingerprint string // peer mode: the hub's certificate fingerprint, manual
Partner string // optional: forces co-op mode, reserving this name
Language string // interface language: "auto", "en", "es", ...
clientID string // no viene del archivo: ver clientID()
clientID string // not read from the file: see clientID()
}
func defaultConfig() config {
return config{
Mode: "hub",
Listen: listenAddr,
Language: "auto",
Mode: "hub",
Listen: listenAddr,
PeerListen: peerListenAddr,
Language: "auto",
}
}
@@ -59,15 +66,15 @@ func configPath() string {
return filepath.Join(filepath.Dir(exe), "config.toml")
}
// tomlValue es un valor ya parseado: guardamos el texto crudo porque
// todas nuestras claves son strings, pero validamos el tipo igual.
// tomlValue is an already-parsed value: we keep the raw text because all
// our keys are strings, but we still validate the type.
type tomlValue struct {
str string
line int
}
// parseTOML devuelve el mapa de claves de la tabla raiz. Las claves
// dentro de una seccion quedan como "seccion.clave".
// parseTOML returns the root table's key map. Keys inside a section come
// out as "section.key".
func parseTOML(src string) (map[string]tomlValue, []string) {
out := map[string]tomlValue{}
var problems []string
@@ -81,7 +88,7 @@ func parseTOML(src string) (map[string]tomlValue, []string) {
}
if strings.HasPrefix(line, "[") {
if !strings.HasSuffix(line, "]") || strings.HasPrefix(line, "[[") {
problems = append(problems, fmt.Sprintf("linea %d: cabecera de seccion que no entiendo: %s", lineNo, line))
problems = append(problems, fmt.Sprintf("line %d: section header I don't understand: %s", lineNo, line))
continue
}
section = strings.TrimSpace(line[1 : len(line)-1])
@@ -90,12 +97,12 @@ func parseTOML(src string) (map[string]tomlValue, []string) {
key, rest, ok := strings.Cut(line, "=")
if !ok {
problems = append(problems, fmt.Sprintf("linea %d: esperaba \"clave = valor\" y encontre: %s", lineNo, line))
problems = append(problems, fmt.Sprintf("line %d: expected \"key = value\" and found: %s", lineNo, line))
continue
}
key = strings.TrimSpace(key)
if key == "" {
problems = append(problems, fmt.Sprintf("linea %d: falta el nombre de la clave", lineNo))
problems = append(problems, fmt.Sprintf("line %d: missing key name", lineNo))
continue
}
if section != "" {
@@ -104,7 +111,7 @@ func parseTOML(src string) (map[string]tomlValue, []string) {
val, err := parseTOMLValue(strings.TrimSpace(rest))
if err != nil {
problems = append(problems, fmt.Sprintf("linea %d (%s): %v", lineNo, key, err))
problems = append(problems, fmt.Sprintf("line %d (%s): %v", lineNo, key, err))
continue
}
out[strings.ToLower(key)] = tomlValue{str: val, line: lineNo}
@@ -114,7 +121,7 @@ func parseTOML(src string) (map[string]tomlValue, []string) {
func parseTOMLValue(s string) (string, error) {
if s == "" {
return "", fmt.Errorf("falta el valor")
return "", fmt.Errorf("missing value")
}
switch s[0] {
case '"':
@@ -122,10 +129,10 @@ func parseTOMLValue(s string) (string, error) {
case '\'':
return parseQuoted(s, '\'', false)
case '[', '{':
return "", fmt.Errorf("este programa no soporta listas ni tablas inline")
return "", fmt.Errorf("this program doesn't support lists or inline tables")
}
// Sin comillas: booleano o entero. Cortamos un comentario al final.
// No quotes: boolean or integer. Trim a trailing comment.
if idx := strings.Index(s, "#"); idx >= 0 {
s = strings.TrimSpace(s[:idx])
}
@@ -138,19 +145,19 @@ func parseTOMLValue(s string) (string, error) {
if _, err := strconv.ParseInt(s, 10, 64); err == nil {
return s, nil
}
return "", fmt.Errorf("valor sin comillas que no es booleano ni entero: %q (¿te faltaron las comillas?)", s)
return "", fmt.Errorf("unquoted value that isn't a boolean or an integer: %q (missing quotes?)", s)
}
// parseQuoted lee un string entre comillas y descarta lo que venga
// despues si es un comentario. Con comillas dobles interpreta los
// escapes mas comunes; con simples el contenido es literal, como en TOML.
// parseQuoted reads a quoted string and discards whatever comes after it
// if it's a comment. Double quotes interpret the usual escapes; single
// quotes are literal, like in TOML.
func parseQuoted(s string, quote byte, escapes bool) (string, error) {
var sb strings.Builder
for i := 1; i < len(s); i++ {
c := s[i]
if escapes && c == '\\' {
if i+1 >= len(s) {
return "", fmt.Errorf("la barra invertida final no escapa nada")
return "", fmt.Errorf("trailing backslash doesn't escape anything")
}
i++
switch s[i] {
@@ -165,26 +172,26 @@ func parseQuoted(s string, quote byte, escapes bool) (string, error) {
case '\\':
sb.WriteByte('\\')
default:
return "", fmt.Errorf("escape no soportado: \\%c", s[i])
return "", fmt.Errorf("unsupported escape: \\%c", s[i])
}
continue
}
if c == quote {
trailing := strings.TrimSpace(s[i+1:])
if trailing != "" && !strings.HasPrefix(trailing, "#") {
return "", fmt.Errorf("sobra texto despues del valor: %q", trailing)
return "", fmt.Errorf("extra text after the value: %q", trailing)
}
return sb.String(), nil
}
sb.WriteByte(c)
}
return "", fmt.Errorf("falta la comilla de cierre")
return "", fmt.Errorf("missing closing quote")
}
var knownKeys = map[string]bool{
"name": true, "mode": true, "listen": true,
"hub": true, "token": true, "partner": true,
"language": true,
"name": true, "mode": true, "listen": true, "peer_listen": true,
"hub": true, "token": true, "invite": true, "fingerprint": true,
"partner": true, "language": true,
}
func loadConfig() config {
@@ -193,7 +200,7 @@ func loadConfig() config {
data, err := os.ReadFile(configPath())
if err != nil {
if !os.IsNotExist(err) {
log.Printf("no pude leer config.toml (%v): sigo con los valores por defecto", err)
log.Printf("couldn't read config.toml (%v): continuing with the defaults", err)
}
return cfg
}
@@ -205,7 +212,7 @@ func loadConfig() config {
for k, v := range values {
if !knownKeys[k] {
log.Printf("config.toml: clave desconocida %q (linea %d), la ignoro", k, v.line)
log.Printf("config.toml: unknown key %q (line %d), ignoring it", k, v.line)
continue
}
switch k {
@@ -217,10 +224,18 @@ func loadConfig() config {
if v.str != "" {
cfg.Listen = v.str
}
case "peer_listen":
if v.str != "" {
cfg.PeerListen = v.str
}
case "hub":
cfg.Hub = v.str
case "token":
cfg.Token = v.str
case "invite":
cfg.Invite = v.str
case "fingerprint":
cfg.Fingerprint = v.str
case "partner":
cfg.Partner = v.str
case "language":
@@ -229,7 +244,7 @@ func loadConfig() config {
}
if cfg.Mode != "hub" && cfg.Mode != "peer" {
log.Printf("config.toml: mode = %q no es ni \"hub\" ni \"peer\", uso \"hub\"", cfg.Mode)
log.Printf("config.toml: mode = %q is neither \"hub\" nor \"peer\", using \"hub\"", cfg.Mode)
cfg.Mode = "hub"
}
return cfg
@@ -246,19 +261,43 @@ name = ""
# "peer" = this PC only reads its own game and pushes to the hub.
mode = "hub"
# Address the panel and overlay listen on.
# Address the panel and overlay listen on. Plain HTTP, meant for OBS and
# your own browser — never exposed to your co-op partner directly.
listen = "0.0.0.0:47822"
# Peer mode only: the hub's address, e.g. "100.101.102.103:47822"
# Hub only: address for the encrypted connection to your peer (TLS,
# certificate pinned). Separate port from "listen" above ON PURPOSE: OBS's
# browser source and the panel must never see a self-signed certificate,
# so only THIS port needs exposing to your partner's network.
peer_listen = "0.0.0.0:47823"
# Peer mode: paste the invite code the hub's owner gave you. It already
# contains the hub's address, port, certificate fingerprint, and the
# shared token, so this one line replaces filling in hub/token/fingerprint
# by hand below.
invite = ""
# Peer mode, manual alternative to invite (or an override of just the
# host:port from it, e.g. if the hub guessed the wrong network interface).
# NOTE: this has to be the hub's peer_listen port (47823 by default), NOT
# its listen port (47822) — that's the single most common mistake here.
hub = ""
# Connection secret. REQUIRED for co-op.
# Connection secret. REQUIRED for co-op, one way or another: either via
# invite above, or set by hand here together with fingerprint below (both
# together — there is no unencrypted, unpinned fallback).
# The hub generates one on first run and saves it to token.txt; it is
# also printed in the console. Peers must set that exact value here.
# also printed in the console alongside the invite code.
# The token itself never travels over the network: the hub sends a random
# challenge and the peer answers with an HMAC of it.
token = ""
# Peer mode, manual alternative to invite: the SHA-256 fingerprint of the
# hub's certificate (also printed in the hub's console). Pins the
# connection to that exact certificate — if the hub ever regenerates it
# (e.g. hub-cert.pem got deleted), this needs updating too.
fingerprint = ""
# Interface language. "auto" follows your Windows language and falls
# back to English. Any file in locales/ is a valid value, e.g. "en", "es".
language = "auto"
@@ -269,13 +308,14 @@ language = "auto"
partner = ""
`
// clientID devuelve un identificador estable de ESTA instalacion,
// guardado en client-id.txt al lado del programa.
// clientID returns a stable identifier for THIS installation, saved to
// client-id.txt next to the program.
//
// Existe porque el nombre no sirve para identificar a un jugador: desde
// que lo leemos del personaje, cambia cada vez que cargan otra partida.
// Con un id propio, el hub sigue viendo al mismo compañero aunque cambie
// de personaje, se reconecte o reinicie el programa.
// It exists because the name doesn't work as a player's identity: from
// the moment we read it off the character, it changes every time they
// load a different save. With its own id, the hub keeps seeing the same
// partner even if they switch characters, reconnect, or restart the
// program.
func clientID() string {
path := "client-id.txt"
if exe, err := os.Executable(); err == nil {
@@ -288,25 +328,26 @@ func clientID() string {
}
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// Sin aleatoriedad igual devolvemos algo estable dentro de la corrida.
// No randomness available: still return something stable for
// this run.
return fmt.Sprintf("pid-%d", os.Getpid())
}
id := hex.EncodeToString(b[:])
if err := os.WriteFile(path, []byte(id), 0644); err != nil {
log.Printf("no pude guardar client-id.txt (%v): uso un id nuevo en cada arranque", err)
log.Printf("couldn't save client-id.txt (%v): using a fresh id every startup", err)
}
return id
}
// writeSampleConfig deja un config.toml comentado la primera vez.
// writeSampleConfig leaves a commented config.toml the first time.
func writeSampleConfig() {
path := configPath()
if _, err := os.Stat(path); err == nil {
return
}
if err := os.WriteFile(path, []byte(sampleConfig), 0644); err != nil {
log.Printf("no pude escribir el config.toml de ejemplo: %v", err)
log.Printf("couldn't write the sample config.toml: %v", err)
return
}
log.Printf("dejé un config.toml de ejemplo al lado del programa")
log.Printf("left a sample config.toml next to the program")
}