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:
1 parent
e1f7e6f529
commit
e9fe10f0f7
12 files changed
+1198
-465
No files matched your search
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user