diff --git a/.gitignore b/.gitignore index f3f41c1..46b1d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ client-id.txt config.toml offset.txt token.txt +hub-cert.pem +hub-key.pem # config local de Claude Code (auto mode), especifica de esta maquina .claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 222fa1e..da034f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,16 +19,17 @@ error del código: es que `main.go` e `i18n.go` solo compilan para Windows. ## Idioma del código El repo va a publicarse en GitHub. **Identificadores, comentarios y -mensajes de log/consola van en inglés** — código nuevo se escribe -directamente en inglés, sin excepción. `names.go` y `names_test.go` son -el primer archivo escrito bajo esta regla; úsenlo de referencia. +mensajes de log/consola van en inglés.** Ya se hizo la pasada completa: +todo `.go` (identificadores, comentarios, logs) y los comentarios de +`overlay.html` están en inglés. -El resto del código (`main.go`, `i18n.go`, `counter.go`, `totals.go`, -`config.go`, `duo.go`, `ws.go`, `auth.go`, comentarios de `overlay.html`) -todavía está en español — ver el pendiente de traducción más abajo. Esto -**no** afecta a los textos que ve el usuario final en el overlay/panel -(`locales/*.json`, `overlay.html`): esos siguen soportando español e -inglés vía el sistema de i18n existente, elegido por idioma del sistema. +Esto **no** afecta a los textos que ve el usuario final en el +overlay/panel (`locales/*.json`, `overlay.html`): esos siguen soportando +español e inglés vía el sistema de i18n existente (`i18n.go`), elegido +por idioma del sistema. Son cosas distintas a propósito: el código es +para quien lo lee en GitHub, el overlay es para quien lo mira en el +stream — y por eso el inglés del código no absorbió el sistema de +idiomas del overlay, ni al revés. ## Estructura @@ -42,12 +43,15 @@ inglés vía el sistema de i18n existente, elegido por idioma del sistema. | `config.go` | Parser TOML propio + `config.toml` | portable | | `duo.go` | Modo co-op: registro de peers, hub y peer | portable | | `ws.go` | WebSocket hecho a mano (RFC 6455, subconjunto) | portable | +| `tlscert.go` | Certificado TLS autofirmado del hub, generación y persistencia | portable | +| `pin.go` | Certificate pinning: fingerprint, `tls.Config` del hub y del peer | portable | +| `invite.go` | Código de invitación (encode/decode) y adivinar el host | portable | | `overlay.html` | Overlay e interfaz, embebido con `go:embed` | — | `counter.go` está separado de `main.go` **a propósito**: es donde vivieron los tres bugs de conteo, y separarlo es lo que permite testearlo sin una PC con el juego abierto. Si agregás reglas de conteo, van ahí, con test. -`names.go` sigue el mismo principio para `looksLikeName`. +`names.go`, `tlscert.go`, `pin.go` e `invite.go` siguen el mismo principio. ## Offsets de memoria @@ -79,7 +83,48 @@ Cheat Engine. El transporte **no se asume confiable**: puede ser Tailscale, ZeroTier, WireGuard, o un puerto abierto directo a internet. La conexión se defiende -sola (`auth.go`). +sola, en dos capas independientes: TLS cifra y autentica al HUB frente al +peer (`tlscert.go`, `pin.go`), y el token autentica al PEER frente al hub +(`auth.go`). Ninguna reemplaza a la otra. + +**Dos puertos, no uno**, a propósito — no se puede servir HTTP y HTTPS en +el mismo puerto, y mezclarlos igual sería mala idea: + +| Puerto | Config | Sirve | Protocolo | Audiencia | +|---|---|---|---|---| +| 47822 | `listen` | overlay, panel, `/deaths`, `/strings.json` | HTTP plano | OBS y el navegador, local o LAN | +| 47823 | `peer_listen` | sólo `/ws` | TLS 1.3 + pinning | el peer, cruzando redes ajenas | + +Un certificado autofirmado haría que OBS y el navegador tiren advertencias +o no carguen; con los puertos separados el overlay nunca ve un +certificado. Y el beneficio mayor: quien abre el 47823 a internet para que +entre su compañero expone únicamente el endpoint autenticado+cifrado — el +panel y `/deaths` (que no piden nada y responden con +`Access-Control-Allow-Origin: *`) se quedan en 47822, en casa. + +**TLS: certificado propio + pinning, no autoridad certificante.** No hay +dominio ni CA posible para una IP de Tailscale o LAN, así que no se +intenta: el hub genera un certificado autofirmado (`ed25519` + +`crypto/x509`) la primera vez y lo guarda (`hub-cert.pem`/`hub-key.pem`, +gitignored, junto al token). El peer se conecta con `InsecureSkipVerify: +true` **y** un `VerifyPeerCertificate` que exige que la huella SHA-256 sea +exactamente la esperada (`pin.go`) — el nombre del flag asusta y en una +revisión parece un error: no lo es. Apaga la validación por CA/dominio, +que acá no aplica, y la reemplaza por *pinning*, que para este caso es +**más** estricto que la validación normal. Regenerar el certificado +(borrar los dos archivos) cambia la huella: cualquier invitación vieja +deja de servir. + +**Invitación de un solo paste.** El hub imprime un código — base64 de +`{host, puerto, huella, token}` (`invite.go`) — que el peer pega como +`invite = "..."` en su `config.toml`. Reemplaza copiar la IP y el token +por separado, sin agregar un paso. El `host` se adivina solo +(`candidateIPv4s`/`pickBestHost`, prefiere una IP de Tailscale), pero es +sólo una adivinanza: `hub =` en el config del peer pisa ese campo si hace +falta. También existe la ruta manual (`hub`+`token`+`fingerprint`, los +tres juntos) para quien prefiera no pegar el blob; nunca hay una conexión +sin fijar la huella — un peer a medio configurar falla fuerte al arrancar +en vez de conectarse sin pinning. - El token es **obligatorio**. Lo genera el programa (`token.txt`, 128 bits); `token` en `config.toml` lo pisa si alguien quiere elegirlo. @@ -93,14 +138,11 @@ sola (`auth.go`). - El `id` que vale es el de la conexión autenticada, no el que declare cada mensaje. -**Lo que esto NO hace: cifrar.** Los mensajes (nombre y número de muertes) -viajan en claro, y un atacante activo en el medio podría alterarlos. El -token garantiza que nadie **inyecte** datos falsos, no que nadie los lea. -Está pendiente cifrarlo (ver Pendientes). - -Los tests de esto están en `auth_test.go` y cubren: token correcto, token +Los tests de esto están en `auth_test.go` (token correcto, token equivocado, mensajes sin autenticar, que el token no aparezca en el -tráfico, y que una respuesta vieja no se pueda repetir. +tráfico, que una respuesta vieja no se pueda repetir), `pin_test.go` +(handshake TLS completo: acepta la huella correcta, rechaza cualquier +otra) e `invite_test.go` (ida y vuelta del código, entradas rotas). ## Reglas que salieron de bugs reales @@ -149,10 +191,10 @@ No son preferencias de estilo. Cada una costó un bug en producción. ## Pendientes -- Pasada completa a inglés: identificadores, comentarios, mensajes de - log/consola y el README (hoy todo está en español salvo `names.go` y - `names_test.go`, ver "Idioma del código" arriba). Conviene que sea un - commit aparte del resto, dado el volumen del diff. +- No hay `README.md` todavía. Escribir uno en inglés (instalación, modo + hub/peer, capturas) es lo único que falta del pendiente de idioma — el + código ya está en inglés de punta a punta, ver "Idioma del código" + arriba. - La identificación por nombre (respaldo cuando no se lee el slot) mezcla personajes homónimos. Documentado, no resuelto. - **Compilar y correr en Linux (Proton).** Mucha gente juega Elden Ring @@ -204,8 +246,8 @@ No son preferencias de estilo. Cada una costó un bug en producción. `GetUserDefaultLocaleName`. No hay que tocar: los offsets, las firmas, `counter.go`, `names.go`, - `totals.go`, `duo.go`, `ws.go`, `config.go` ni el overlay. Ya son - portables. + `totals.go`, `duo.go`, `ws.go`, `tlscert.go`, `pin.go`, `invite.go`, + `config.go` ni el overlay. Ya son portables. **Modo sólo-hub, de regalo.** Un hub que no lee memoria —que sólo junta lo de los peers y sirve el overlay— no necesita `setcap` ni permiso @@ -217,60 +259,5 @@ No son preferencias de estilo. Cada una costó un bug en producción. [cheat-engine-linux](https://github.com/wleeaf/cheat-engine-linux), [un trainer para D2R en Linux paso a paso](https://axiom0x0.sh/posts/d2r-memory-trainer-part2/). -- **Cifrar el enlace con TLS (certificado propio + pinning).** Hoy la - conexión está autenticada pero no cifrada. - - La forma seria es **TLS 1.3 de la biblioteca estándar**, no un protocolo - hecho a mano. El obstáculo aparente es que no hay dominio ni autoridad - certificadora posible: nadie emite un certificado para `100.x.y.z`. Pero - eso deja de importar al ver que **el problema de confianza ya está - resuelto fuera de banda**: los dos jugadores ya se pasan un secreto a - mano. Ese mismo canal puede llevar la huella del certificado. - - 1. El hub genera un certificado autofirmado en el primer arranque - (`crypto/x509` + `ed25519`) y lo guarda junto al token. - 2. El peer se conecta con `tls.Client` usando `InsecureSkipVerify: true` - **y** un `VerifyPeerCertificate` que exige que la huella SHA-256 sea - exactamente la esperada. El nombre del flag asusta y en una revisión - parece un error: no lo es. Apaga la validación por CA y nombre de - dominio, que acá no aplican, y la reemplaza por *pinning*, que para - este caso es **más** estricto que la validación normal. - 3. Un solo **código de invitación** que el hub imprime y el peer pega: - base64 de `{host, puerto, huella, token}`. Como ya tenían que copiar - algo, esto no agrega ni un paso, y a cambio la conexión pasa a estar - cifrada de verdad. Para quien lo usa, el tema desaparece. - - Con eso se obtiene TLS 1.3 real: forward secrecy, AEAD y un handshake - revisado por medio mundo, sin dependencias externas y sin que nadie - tenga que entender nada. - - **Dos puertos, no uno.** No se puede servir HTTP y HTTPS en el mismo - puerto, así que la separación no es un detalle de implementación sino - parte del diseño: - - | Puerto | Sirve | Protocolo | Audiencia | - |---|---|---|---| - | 47822 | overlay, panel, `/deaths`, `/strings.json` | HTTP plano | OBS y el navegador, local o LAN | - | 47823 | sólo `/ws` | TLS + pinning | el peer, cruzando redes ajenas | - - Esto resuelve lo obvio — un certificado autofirmado hace que OBS y el - navegador tiren advertencias o no carguen, y con puertos separados el - overlay nunca ve un certificado — pero el beneficio mayor es otro y - conviene no perderlo de vista: - - **Achica lo que queda expuesto.** Hoy, quien abra el 47822 a internet - para que entre su compañero está publicando también el panel y - `/deaths`, que no piden nada y encima responden con - `Access-Control-Allow-Origin: *`: cualquiera puede leer los nombres de - los personajes y los contadores. Con la separación, el único puerto que - hace falta exponer sirve exclusivamente el endpoint autenticado, y el - del overlay se queda en casa. Si se implementa TLS, esta parte va - primero: vale por sí sola aunque el cifrado quede para después. - - Implica una clave nueva en el config (algo como `peer_listen`), y que el - código de invitación lleve ese puerto y no el del overlay. - - Detalles: si se regenera el certificado (reinstalación, borrado del - archivo) cambia la huella y hay que pasar un código nuevo; documentarlo. - Y el README igual debería mencionar que meter todo adentro de una VPN - sigue siendo una opción perfectamente válida. +- El README debería mencionar que meter todo adentro de una VPN sigue + siendo una opción perfectamente válida, TLS+pinning aparte. diff --git a/config.go b/config.go index fc84f40..c491e74 100644 --- a/config.go +++ b/config.go @@ -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") } diff --git a/duo.go b/duo.go index b978534..76a254e 100644 --- a/duo.go +++ b/duo.go @@ -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, diff --git a/invite.go b/invite.go new file mode 100644 index 0000000..88cb3f4 --- /dev/null +++ b/invite.go @@ -0,0 +1,98 @@ +// invite.go: the one-paste invitation code a hub prints and a peer pastes +// into their config, replacing separately copying an IP and a token. +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net" + "strings" +) + +type inviteCode struct { + Host string `json:"host"` + Port string `json:"port"` + Fingerprint string `json:"fingerprint"` + Token string `json:"token"` +} + +func encodeInvite(c inviteCode) string { + b, _ := json.Marshal(c) // a struct of plain strings: Marshal can't fail + return base64.RawURLEncoding.EncodeToString(b) +} + +func decodeInvite(s string) (inviteCode, error) { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(s)) + if err != nil { + return inviteCode{}, fmt.Errorf("invite code isn't valid base64: %w", err) + } + var c inviteCode + if err := json.Unmarshal(raw, &c); err != nil { + return inviteCode{}, fmt.Errorf("invite code doesn't decode to a valid invite: %w", err) + } + if c.Host == "" || c.Port == "" || c.Token == "" { + return inviteCode{}, fmt.Errorf("invite code is missing a host, port, or token") + } + if !validFingerprint(c.Fingerprint) { + return inviteCode{}, fmt.Errorf("invite code's fingerprint doesn't look like a SHA-256 hex digest") + } + return c, nil +} + +// tailscaleCGNAT is the address range Tailscale assigns its clients from. +var tailscaleCGNAT = mustParseCIDR("100.64.0.0/10") + +func mustParseCIDR(s string) *net.IPNet { + _, n, err := net.ParseCIDR(s) + if err != nil { + panic(err) // a hardcoded literal: only fails if this code is wrong + } + return n +} + +// pickBestHost picks the address a co-op partner is most likely to be +// able to reach: a Tailscale address over a plain private-LAN one (a VPN +// like ZeroTier or WireGuard usually also hands out a private-range +// address, so this still prefers it over nothing) over nothing at all. +// It's a guess, not a guarantee — the hub operator can always override it +// with the "hub" config key if it's wrong. +func pickBestHost(addrs []string) string { + var private string + for _, a := range addrs { + ip := net.ParseIP(a) + if ip == nil { + continue + } + if tailscaleCGNAT.Contains(ip) { + return a + } + if private == "" && ip.IsPrivate() { + private = a + } + } + return private +} + +// candidateIPv4s lists this machine's non-loopback IPv4 addresses, for +// pickBestHost to choose from and for logging the full list so a human +// can pick a different one if the guess is wrong. +func candidateIPv4s() []string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return nil + } + var out []string + for _, a := range addrs { + ipNet, ok := a.(*net.IPNet) + if !ok || ipNet.IP.IsLoopback() { + continue + } + ip4 := ipNet.IP.To4() + if ip4 == nil { + continue + } + out = append(out, ip4.String()) + } + return out +} diff --git a/invite_test.go b/invite_test.go new file mode 100644 index 0000000..458182c --- /dev/null +++ b/invite_test.go @@ -0,0 +1,56 @@ +package main + +import "testing" + +func validTestFingerprint() string { + certDER, _, _ := generateHubCert() + return certFingerprint(certDER) +} + +func TestInviteRoundtrip(t *testing.T) { + fp := validTestFingerprint() + want := inviteCode{Host: "100.101.102.103", Port: "47823", Fingerprint: fp, Token: "sekrit"} + + got, err := decodeInvite(encodeInvite(want)) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("roundtrip mismatch: got %+v, want %+v", got, want) + } +} + +func TestDecodeInviteRejectsMalformed(t *testing.T) { + fp := validTestFingerprint() + + cases := map[string]string{ + "not base64 at all": "!!! not base64 !!!", + "base64 but not json": "aGVsbG8gd29ybGQ", // "hello world" + "missing token": encodeInvite(inviteCode{Host: "h", Port: "1", Fingerprint: fp}), + "missing host": encodeInvite(inviteCode{Port: "1", Fingerprint: fp, Token: "t"}), + "bad fingerprint": encodeInvite(inviteCode{Host: "h", Port: "1", Fingerprint: "not-a-fingerprint", Token: "t"}), + } + for name, code := range cases { + if _, err := decodeInvite(code); err == nil { + t.Errorf("%s: expected an error, got none", name) + } + } +} + +func TestPickBestHost(t *testing.T) { + cases := []struct { + name string + addrs []string + want string + }{ + {"tailscale preferred over LAN", []string{"192.168.1.5", "100.101.102.103"}, "100.101.102.103"}, + {"LAN only", []string{"192.168.1.5"}, "192.168.1.5"}, + {"nothing usable", []string{"8.8.8.8"}, ""}, + {"empty", nil, ""}, + } + for _, c := range cases { + if got := pickBestHost(c.addrs); got != c.want { + t.Errorf("%s: pickBestHost(%v) = %q, want %q", c.name, c.addrs, got, c.want) + } + } +} diff --git a/main.go b/main.go index b48b267..beffff4 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,19 @@ //go:build windows -// deathwatch: lee en solo-lectura el contador de muertes de Elden Ring -// directamente del proceso (mismo patron de bytes / offset que usa el -// script ASL "eldenring_boss_timer.asl" de LiveSplit, ya verificado a -// mano en esta PC). No escribe nada en la memoria del juego. +// deathwatch: reads Elden Ring's death counter read-only, straight from +// the process (the same byte pattern / offset used by LiveSplit's +// "eldenring_boss_timer.asl" ASL script, verified by hand on this PC). +// Writes nothing to the game's memory. // -// Expone: +// Exposes: // -// GET / -> panel de estado (HTML) -// GET /?view=overlay -> version transparente para OBS Browser Source +// GET / -> status panel (HTML) +// GET /?view=overlay -> transparent version for OBS Browser Source // GET /deaths -> {"deaths":N,"players":[...],"character":"...","slot":N,...} package main import ( + "crypto/tls" _ "embed" "encoding/binary" "encoding/json" @@ -59,9 +60,9 @@ var ( procVerQueryValueW = versionDLL.NewProc("VerQueryValueW") ) -// vsFixedFileInfo es la estructura VS_FIXEDFILEINFO de Windows: la usamos -// para sacar la version del juego del propio eldenring.exe, igual que -// SoulMemory (que lee MainModule.FileVersionInfo.ProductVersion). +// vsFixedFileInfo is Windows's VS_FIXEDFILEINFO struct: used to pull the +// game's version straight from eldenring.exe, same as SoulMemory (which +// reads MainModule.FileVersionInfo.ProductVersion). type vsFixedFileInfo struct { Signature uint32 StrucVersion uint32 @@ -109,7 +110,7 @@ type moduleEntry32 struct { func findProcessID(name string) (uint32, error) { snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapProcess), 0) if snap == 0 || snap == uintptr(^uintptr(0)) { - return 0, fmt.Errorf("no se pudo tomar snapshot de procesos") + return 0, fmt.Errorf("couldn't take a process snapshot") } defer procCloseHandle.Call(snap) @@ -117,7 +118,7 @@ func findProcessID(name string) (uint32, error) { pe.Size = uint32(unsafe.Sizeof(pe)) r, _, _ := procProcess32FirstW.Call(snap, uintptr(unsafe.Pointer(&pe))) if r == 0 { - return 0, fmt.Errorf("Process32First fallo") + return 0, fmt.Errorf("Process32First failed") } for { exe := syscall.UTF16ToString(pe.ExeFile[:]) @@ -129,13 +130,13 @@ func findProcessID(name string) (uint32, error) { break } } - return 0, fmt.Errorf("proceso no encontrado: %s", name) + return 0, fmt.Errorf("process not found: %s", name) } func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) { snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapModule|th32csSnapModule32), uintptr(pid)) if snap == 0 || snap == uintptr(^uintptr(0)) { - return 0, 0, "", fmt.Errorf("no se pudo tomar snapshot de modulos") + return 0, 0, "", fmt.Errorf("couldn't take a module snapshot") } defer procCloseHandle.Call(snap) @@ -143,7 +144,7 @@ func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) { me.Size = uint32(unsafe.Sizeof(me)) r, _, _ := procModule32FirstW.Call(snap, uintptr(unsafe.Pointer(&me))) if r == 0 { - return 0, 0, "", fmt.Errorf("Module32First fallo") + return 0, 0, "", fmt.Errorf("Module32First failed") } for { mname := syscall.UTF16ToString(me.ModuleName[:]) @@ -155,13 +156,13 @@ func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) { break } } - return 0, 0, "", fmt.Errorf("modulo no encontrado: %s", name) + return 0, 0, "", fmt.Errorf("module not found: %s", name) } -// productVersion lee la version del ejecutable del juego. label trae las -// dos versiones completas (producto y archivo) porque no siempre -// coinciden, y sirve para diagnosticar si algun dia hay que ajustar el -// offset de PlayerIns. +// productVersion reads the game executable's version. label carries both +// full version numbers (product and file) because they don't always +// match, which helps diagnose whether the PlayerIns offset ever needs +// adjusting. func productVersion(path string) (major, minor uint16, label string, ok bool) { if path == "" { return 0, 0, "", false @@ -197,21 +198,21 @@ func productVersion(path string) (major, minor uint16, label string, ok bool) { quad := func(ms, ls uint32) string { return fmt.Sprintf("%d.%d.%d.%d", ms>>16, ms&0xFFFF, ls>>16, ls&0xFFFF) } - label = fmt.Sprintf("producto %s / archivo %s", + label = fmt.Sprintf("product %s / file %s", quad(info.ProductVersionMS, info.ProductVersionLS), quad(info.FileVersionMS, info.FileVersionLS)) return uint16(info.ProductVersionMS >> 16), uint16(info.ProductVersionMS & 0xFFFF), label, true } -// playerInsOffsetForVersion replica la tabla de SoulMemory -// (InitializeOffsets): hasta 1.06 el offset de PlayerIns dentro de -// WorldChrMan es 0x18468, de 1.07 en adelante 0x1E508. +// playerInsOffsetForVersion mirrors SoulMemory's table (InitializeOffsets): +// up to 1.06 the PlayerIns offset inside WorldChrMan is 0x18468, from +// 1.07 onward it's 0x1E508. // -// OJO: la version que reporta el exe NO es la que muestra el juego en -// pantalla (el exe puede decir 2.7.1.0 mientras el juego dice 1.17.1), y -// la tabla de SoulMemory esta escrita con los numeros del juego. Por eso -// esto es solo una CORAZONADA para decidir cual probar primero: quien -// decide de verdad es playerInsCandidates + la verificacion en memoria. +// WATCH OUT: the version the exe reports is NOT what the game shows on +// screen (the exe can say 2.7.1.0 while the game says 1.17.1), and +// SoulMemory's table is written with the game's own numbers. So this is +// only a HUNCH for deciding which one to try first: what actually +// decides is playerInsCandidates plus the in-memory verification. func playerInsOffsetForVersion(major, minor uint16, ok bool) uintptr { if ok && major == 1 && minor <= 6 { return 0x18468 @@ -219,8 +220,8 @@ func playerInsOffsetForVersion(major, minor uint16, ok bool) uintptr { return 0x1E508 } -// playerInsCandidates devuelve los offsets conocidos a probar, con el que -// sugiere la version primero. +// playerInsCandidates returns the known offsets to try, with the one the +// version suggests listed first. func playerInsCandidates(major, minor uint16, ok bool) []uintptr { if playerInsOffsetForVersion(major, minor, ok) == 0x18468 { return []uintptr{0x18468, 0x1E508} @@ -251,24 +252,25 @@ func readMemory(h syscall.Handle, addr uintptr, size int) ([]byte, bool) { // ------------------------- signature scans ------------------------- // -// Dos firmas, las dos con la misma forma: instruccion de 7 bytes -// "mov reg,[rip+disp32]", donde los 3 primeros bytes son el opcode y los -// 4 siguientes el desplazamiento. El slot estatico resuelto contiene el -// puntero al objeto (una dereferencia mas). +// Two signatures, both the same shape: a 7-byte "mov reg,[rip+disp32]" +// instruction, where the first 3 bytes are the opcode and the next 4 are +// the displacement. The resolved static slot holds the pointer to the +// object (one more dereference needed). // -// GameDataMan -> tiene el contador de muertes (+0x94) y el flag de jefe (+0xC0). -// Mismo patron que usa eldenring_boss_timer.asl (LiveSplit). -// WorldChrMan -> tiene el puntero a PlayerIns (+playerInsOffset). Si ese -// puntero es nulo, no hay personaje en el mundo: estas en el -// menu principal o en una pantalla de carga. Es exactamente -// lo que hace SoulMemory.IsPlayerLoaded(). +// GameDataMan -> holds the death counter (+0x94) and the boss-fight flag +// (+0xC0). Same pattern eldenring_boss_timer.asl (LiveSplit) +// uses. +// WorldChrMan -> holds the pointer to PlayerIns (+playerInsOffset). If +// that pointer is null, there's no character in the world: +// you're at the main menu or on a loading screen. Exactly +// what SoulMemory.IsPlayerLoaded() does. type patByte struct { val byte wildcard bool } -// parsePattern acepta "48 8B 05 ?? ?? ?? ??" (?? = comodin). +// parsePattern accepts "48 8B 05 ?? ?? ?? ??" (?? = wildcard). func parsePattern(s string) []patByte { var out []patByte for _, tok := range strings.Fields(s) { @@ -278,7 +280,7 @@ func parsePattern(s string) []patByte { } v, err := strconv.ParseUint(tok, 16, 8) if err != nil { - panic("patron invalido: " + tok) + panic("invalid pattern: " + tok) } out = append(out, patByte{val: byte(v)}) } @@ -288,16 +290,16 @@ func parsePattern(s string) []patByte { var ( // mov rax,[rip+disp32]; test rax,rax; jz +5; mov rax,[rax+58]; ret; ret gameDataManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 48 85 C0 74 05 48 8B 40 58 C3 C3") - // mov rsi,[rip+disp32]; test rsi,rsi; ... (WorldChrManImp, igual que SoulMemory) + // mov rsi,[rip+disp32]; test rsi,rsi; ... (WorldChrManImp, same as SoulMemory) worldChrManPattern = parsePattern("48 8B 35 ?? ?? ?? ?? 48 85 F6 ?? ?? BB 01 00 00 00 89 5C 24 20 48 8B B6") // mov rax,[rip+disp32]; cmp byte ptr [rax+disp32],0D; sete al; ret - // (GameMan: en +0xAC0 tiene el slot de guardado del personaje cargado) + // (GameMan: +0xAC0 holds the loaded character's save slot) gameManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 80 B8 ?? ?? ?? ?? 0D 0F 94 C0 C3") ) -// saveSlotOffset: donde GameMan guarda el indice de slot (0-9) de la -// partida cargada. Es la identidad REAL de un personaje: no depende del -// nombre, asi que dos personajes que se llamen igual no se mezclan. +// saveSlotOffset: where GameMan stores the save-slot index (0-9) of the +// loaded game. This is a character's REAL identity: it doesn't depend on +// the name, so two characters sharing a name never mix. const saveSlotOffset = 0xAC0 func matchAt(buf []byte, i int, pattern []patByte) bool { @@ -312,9 +314,9 @@ func matchAt(buf []byte, i int, pattern []patByte) bool { return true } -// scanModule busca varios patrones en una sola pasada por el modulo, -// leyendolo en chunks (con solape, por si un patron cae justo en el borde -// de un chunk). Devuelve, por cada patron, la direccion del match o 0. +// scanModule looks for several patterns in a single pass over the module, +// reading it in chunks (with overlap, in case a pattern straddles a chunk +// boundary). Returns, for each pattern, the match address or 0. func scanModule(h syscall.Handle, base uintptr, size uint32, patterns [][]patByte) []uintptr { const chunk = 1 << 20 // 1 MiB const overlap = 64 @@ -350,48 +352,48 @@ func scanModule(h syscall.Handle, base uintptr, size uint32, patterns [][]patByt return found } -// ripSlot convierte la direccion de una instruccion "mov reg,[rip+disp32]" -// de 7 bytes en la direccion del slot estatico al que apunta. +// ripSlot turns the address of a 7-byte "mov reg,[rip+disp32]" +// instruction into the address of the static slot it points to. func ripSlot(h syscall.Handle, matchAddr uintptr) (uintptr, error) { if matchAddr == 0 { - return 0, fmt.Errorf("patron no encontrado") + return 0, fmt.Errorf("pattern not found") } - codeLocation := matchAddr + 3 // los 3 primeros bytes son el opcode + codeLocation := matchAddr + 3 // the first 3 bytes are the opcode dispBytes, ok := readMemory(h, codeLocation, 4) if !ok { - return 0, fmt.Errorf("no se pudo leer el desplazamiento RIP-relativo") + return 0, fmt.Errorf("couldn't read the RIP-relative displacement") } disp := int32(binary.LittleEndian.Uint32(dispBytes)) return codeLocation + 4 + uintptr(int64(disp)), nil } -// gamePointers junta todo lo que se resuelve una sola vez por sesion de -// proceso: los slots estaticos (que no se mueven) y la version del juego. +// gamePointers gathers everything resolved just once per process session: +// the static slots (which don't move) and the game's version. type gamePointers struct { gameDataManSlot uintptr - worldChrManSlot uintptr // 0 si no se encontro el patron (seguimos sin el chequeo de menu) - gameManSlot uintptr // 0 si no se encontro: caemos a identificar por nombre - playerInsOffset uintptr // el que estamos usando (o el candidato preferido) + worldChrManSlot uintptr // 0 if the pattern wasn't found (we keep going without the menu check) + gameManSlot uintptr // 0 if not found: falls back to identifying by name + playerInsOffset uintptr // the one currently in use (or the preferred candidate) playerInsTried []uintptr - offsetConfirmed bool // true cuando lo verificamos leyendo memoria de verdad + offsetConfirmed bool // true once verified by actually reading memory versionLabel string - nameChain nameCandidate // como llegamos al nombre del personaje + nameChain nameCandidate // how we got to the character's name nameConfirmed bool - namePending string // candidato a la espera de repetirse (ver resolveCharName) + namePending string // candidate waiting to repeat (see resolveCharName) namePendingOf nameCandidate } -// resolvePointers hace los escaneos de firma (caro: recorre todo el -// modulo) una sola vez por sesion de proceso. A proposito NO devuelve los -// objetos ya resueltos: esos punteros se re-leen en cada tick, porque el -// juego puede destruir y recrear GameDataMan (por ejemplo al volver al -// menu principal y cargar de nuevo). Si nos quedaramos con una direccion -// vieja en cache, seguiriamos leyendola con exito (la pagina de memoria -// sigue siendo valida) pero el contenido pasaria a ser datos de otra cosa -// - la causa mas probable de un contador que "sube solo" sin que hayas -// muerto en verdad. SoulMemory hace lo mismo: su clase Pointer resuelve la -// cadena entera en cada lectura, no cachea la direccion final. +// resolvePointers does the signature scans (expensive: walks the whole +// module) just once per process session. Deliberately does NOT return +// the resolved objects themselves: those pointers get re-read every tick, +// because the game can destroy and recreate GameDataMan (e.g. going back +// to the main menu and loading again). If we cached a stale address, +// we'd keep reading it successfully (the memory page is still valid) but +// its contents would belong to something else entirely — the most likely +// cause of a counter that "goes up on its own" without an actual death. +// SoulMemory does the same: its Pointer class resolves the whole chain on +// every read, never caching the final address. func resolvePointers(h syscall.Handle, pid uint32) (gamePointers, error) { var gp gamePointers @@ -405,30 +407,30 @@ func resolvePointers(h syscall.Handle, pid uint32) (gamePointers, error) { gp.playerInsOffset = gp.playerInsTried[0] gp.versionLabel = label if !okVer { - gp.versionLabel = "desconocida" + gp.versionLabel = "unknown" } matches := scanModule(h, base, size, [][]patByte{gameDataManPattern, worldChrManPattern, gameManPattern}) gp.gameDataManSlot, err = ripSlot(h, matches[0]) if err != nil { - return gp, fmt.Errorf("no se encontro el patron de GameDataMan (¿cambio de version del juego?)") + return gp, fmt.Errorf("GameDataMan's pattern wasn't found (did the game update?)") } - // WorldChrMan es opcional: si no aparece, seguimos contando muertes, - // solo perdemos la deteccion de menu/pantalla de carga. + // WorldChrMan is optional: if it's missing, we keep counting deaths, + // we just lose menu/loading-screen detection. if slot, werr := ripSlot(h, matches[1]); werr == nil { gp.worldChrManSlot = slot } - // GameMan tambien es opcional: sin el, identificamos por nombre. + // GameMan is optional too: without it, we identify by name. if slot, gerr := ripSlot(h, matches[2]); gerr == nil { gp.gameManSlot = slot } return gp, nil } -// derefPointer lee un slot estatico (barato: 8 bytes) y devuelve la -// direccion ACTUAL del objeto. Se llama en cada tick, no solo una vez. +// derefPointer reads a static slot (cheap: 8 bytes) and returns the +// object's CURRENT address. Called every tick, not just once. func derefPointer(h syscall.Handle, slot uintptr) (uintptr, bool) { if slot == 0 { return 0, false @@ -440,30 +442,30 @@ func derefPointer(h syscall.Handle, slot uintptr) (uintptr, bool) { return uintptr(binary.LittleEndian.Uint64(buf)), true } -// ------------------------- nombre del personaje ------------------------- +// ------------------------- character name ------------------------- // -// Dato sacado de la tabla de Cheat Engine: "GameDataMan +0C +9C, unicode, -// largo 19". Esa notacion admite mas de una lectura (¿0x0C es un puntero -// que hay que dereferenciar, o los dos offsets se suman?), y ademas la -// comunidad/el ASL usan GameDataMan+0x08 para llegar a PlayerGameData. -// Asi que no elegimos: probamos las tres y nos quedamos con la que -// devuelva algo que parezca un nombre de verdad. +// Sourced from a Cheat Engine table: "GameDataMan +0C +9C, unicode, +// length 19". That notation allows more than one reading (is 0x0C a +// pointer to dereference, or do the two offsets just add up?), and on +// top of that the community/the ASL use GameDataMan+0x08 to reach +// PlayerGameData. So instead of picking one, all three get tried, and +// whichever gives back something that looks like a real name wins. type nameCandidate struct { - ptrOffset uintptr // offset donde vive el puntero (0 = sin dereferencia) - nameOffset uintptr // offset del texto dentro del objeto + ptrOffset uintptr // offset where the pointer lives (0 = no dereference) + nameOffset uintptr // offset of the text within the object label string } var nameCandidates = []nameCandidate{ {0x08, 0x9C, "[GameDataMan+0x08]+0x9C (PlayerGameData)"}, {0x0C, 0x9C, "[GameDataMan+0x0C]+0x9C"}, - {0x00, 0xA8, "GameDataMan+0xA8 (0x0C y 0x9C sumados)"}, + {0x00, 0xA8, "GameDataMan+0xA8 (0x0C and 0x9C added together)"}, } -// looksLikeName (y charNameMaxChars) viven en names.go: no dependen de -// Windows, asi que quedan afuera de este archivo para poder testearlos -// sin una PC con el juego abierto. +// looksLikeName (and charNameMaxChars) live in names.go: they don't +// depend on Windows, so they're kept out of this file to be testable +// without a PC with the game open. func readCharName(h syscall.Handle, gameDataMan uintptr, c nameCandidate) (string, bool) { base := gameDataMan @@ -493,10 +495,11 @@ func readCharName(h syscall.Handle, gameDataMan uintptr, c nameCandidate) (strin return s, true } -// resolveCharName devuelve el nombre del personaje. Antes de fijar una -// variante exige verla dar el MISMO texto dos lecturas seguidas: el -// nombre real no cambia de un segundo a otro, pero un pedazo de memoria -// que casualmente pasa el filtro es mucho menos probable que se repita. +// resolveCharName returns the character's name. Before locking in a +// variant, it requires seeing it give the SAME text on two readings in a +// row: the real name doesn't change from one second to the next, but a +// chunk of memory that happens to pass the filter is far less likely to +// repeat. func resolveCharName(h syscall.Handle, gameDataMan uintptr, gp *gamePointers) (string, bool) { if gp.nameConfirmed { return readCharName(h, gameDataMan, gp.nameChain) @@ -510,7 +513,7 @@ func resolveCharName(h syscall.Handle, gameDataMan uintptr, gp *gamePointers) (s gp.nameChain = c gp.nameConfirmed = true gp.namePending = "" - log.Printf("nombre del personaje: \"%s\" (leido con %s)", s, c.label) + log.Printf("character name: \"%s\" (read via %s)", s, c.label) return s, true } gp.namePending = s @@ -521,9 +524,9 @@ func resolveCharName(h syscall.Handle, gameDataMan uintptr, gp *gamePointers) (s return "", false } -// readSaveSlot devuelve el indice de slot (0-9) de la partida cargada, o -// -1 si no lo pudimos leer. Elden Ring tiene 10 slots, asi que cualquier -// otro valor es basura y se descarta. +// readSaveSlot returns the loaded game's slot index (0-9), or -1 if it +// couldn't be read. Elden Ring has 10 slots, so any other value is +// garbage and gets discarded. func readSaveSlot(h syscall.Handle, gp gamePointers) int { if gp.gameManSlot == 0 { return -1 @@ -543,15 +546,16 @@ func readSaveSlot(h syscall.Handle, gp gamePointers) int { return slot } -// isPlayerLoaded replica SoulMemory.IsPlayerLoaded(): resuelve -// WorldChrMan y lee el puntero a PlayerIns; si es nulo, no hay personaje -// en el mundo. El segundo valor indica si pudimos evaluarlo. +// isPlayerLoaded mirrors SoulMemory.IsPlayerLoaded(): resolves +// WorldChrMan and reads the pointer to PlayerIns; if it's null, there's +// no character in the world. The second return value says whether we +// were able to evaluate it at all. // -// Mientras no tengamos confirmado el offset, en vez de confiar en el -// numero de version (que en Elden Ring no coincide con el que muestra el -// juego) probamos los offsets conocidos y nos quedamos con el primero que -// apunte a memoria realmente legible. Eso lo decide la maquina, no una -// tabla que puede envejecer mal. +// Until the offset is confirmed, instead of trusting the version number +// (which in Elden Ring doesn't match what the game displays), the known +// offsets are tried and whichever one first points at genuinely readable +// memory wins. That's decided by the machine, not by a table that can +// age badly. func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool) { if gp.worldChrManSlot == 0 { return true, false @@ -577,14 +581,14 @@ func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool if !ok || playerIns == 0 { continue } - // Un puntero de verdad apunta a memoria mapeada; uno de basura - // casi nunca sobrevive esta lectura. + // A real pointer points at mapped memory; a garbage one almost + // never survives this read. if _, ok := readMemory(h, playerIns, 8); !ok { continue } gp.playerInsOffset = cand gp.offsetConfirmed = true - log.Printf("PlayerIns confirmado en +0x%X (verificado leyendo el objeto, no por el numero de version)", cand) + log.Printf("PlayerIns confirmed at +0x%X (verified by reading the object, not by version number)", cand) return true, true } return false, true @@ -592,11 +596,11 @@ func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool // -------------------------------- poller loop -------------------------------- -// maxPlausibleDeltaPerTick: entre dos lecturas separadas por ~1s, con el -// personaje cargado todo el tiempo, el contador de muertes real no puede -// subir mas que esto (ni bajar nunca). Un salto mas grande casi siempre -// significa que agarramos memoria que ya no es GameDataMan (direccion -// vieja/invalida) y no una muerte real. +// maxPlausibleDeltaPerTick: between two readings ~1s apart, with the +// character loaded the whole time, the real death counter can't go up by +// more than this (and never goes down). A bigger jump almost always +// means we grabbed memory that's no longer GameDataMan (a stale/invalid +// address), not an actual death. const maxPlausibleDeltaPerTick = 3 func pollLoop() { @@ -611,10 +615,10 @@ func pollLoop() { warnedNoWCM bool lastBossRead bool - // Vigilancia de la deteccion de menu: mientras creemos que no hay - // personaje cargado, igual espiamos el contador de muertes. Si sube - // como sube una muerte de verdad, entonces nuestra deteccion esta - // mintiendo (estabas jugando) y la desactivamos. + // Menu-detection watchdog: while we believe no character is + // loaded, we still peek at the death counter. If it climbs the + // way a real death does, our detection is lying (you were + // actually playing) and we turn it off. unloadedRaw int32 unloadedRawFirst int32 haveUnloadedRaw bool @@ -637,19 +641,19 @@ func pollLoop() { if handle == 0 { newPid, err := findProcessID(processName) if err != nil { - st.setDisconnected("esperando a eldenring.exe") + st.setDisconnected("waiting for eldenring.exe") time.Sleep(3 * time.Second) continue } h, err := openProcessHandle(newPid) if err != nil { - st.setDisconnected("no se pudo abrir el proceso (¿permisos?): " + err.Error()) + st.setDisconnected("couldn't open the process (permissions?): " + err.Error()) time.Sleep(3 * time.Second) continue } pid = newPid handle = h - log.Printf("eldenring.exe encontrado (PID %d), escaneando firmas...", pid) + log.Printf("eldenring.exe found (PID %d), scanning signatures...", pid) } if !resolved { @@ -657,7 +661,7 @@ func pollLoop() { if err != nil { st.setDisconnected(err.Error()) time.Sleep(2 * time.Second) - // si el proceso murio, soltamos el handle para reintentar desde cero + // if the process died, release the handle to retry from scratch if _, ferr := findProcessID(processName); ferr != nil { closeHandle() } @@ -666,46 +670,48 @@ func pollLoop() { gp = p resolved = true haveLastRaw = false - log.Printf("version del juego: %s | GameDataMan slot 0x%X", gp.versionLabel, gp.gameDataManSlot) + log.Printf("game version: %s | GameDataMan slot 0x%X", gp.versionLabel, gp.gameDataManSlot) if gp.gameManSlot != 0 { - log.Printf("GameMan slot 0x%X (identifico personajes por su slot de guardado)", gp.gameManSlot) + log.Printf("GameMan slot 0x%X (identifying characters by their save slot)", gp.gameManSlot) } else { - log.Printf("aviso: no encontre el patron de GameMan; identifico personajes por nombre") + log.Printf("warning: GameMan's pattern wasn't found; identifying characters by name") } if gp.worldChrManSlot != 0 { - log.Printf("WorldChrMan slot 0x%X | PlayerIns: pruebo +0x%X y confirmo contra la memoria", gp.worldChrManSlot, gp.playerInsOffset) + log.Printf("WorldChrMan slot 0x%X | PlayerIns: trying +0x%X and confirming against memory", gp.worldChrManSlot, gp.playerInsOffset) } else if !warnedNoWCM { warnedNoWCM = true - log.Printf("aviso: no se encontro el patron de WorldChrMan; sigo contando muertes pero sin detectar menu/pantalla de carga") + log.Printf("warning: WorldChrMan's pattern wasn't found; still counting deaths but without menu/loading-screen detection") } } - // Igual que el ASL de LiveSplit, que hace "if (!IsPlayerLoaded) return;": - // sin personaje en el mundo no leemos nada. El total queda congelado en - // pantalla (no mostramos guion) para no parpadear en cada carga. + // Same as LiveSplit's ASL, which does "if (!IsPlayerLoaded) return;": + // with no character in the world, nothing gets read. The total + // stays frozen on screen (no dash shown) so it doesn't flicker on + // every loading screen. if loaded, known := isPlayerLoaded(handle, &gp); known && !loaded { - // Red de seguridad. El offset de PlayerIns depende de la version - // del juego: si algun parche lo mueve, leeriamos nulo para - // siempre y el contador quedaria congelado en pleno stream. + // Safety net. The PlayerIns offset depends on the game's + // version: if a patch ever moves it, we'd read null forever + // and the counter would freeze mid-stream. // - // El unico juez confiable es el contador de muertes en si: en el - // menu de inicio NO sube nunca. Asi que espiamos el crudo sin - // usarlo, y si sube como sube una muerte real (+1, +2, +3), - // entonces estabas jugando y nuestra deteccion estaba mintiendo. - // A diferencia de un timeout, esto no puede dispararse por dejar - // el juego parado en el menu un rato largo. + // The only judge we can trust is the death counter itself: + // at the start menu it NEVER goes up. So the raw value gets + // peeked at without being used, and if it climbs the way a + // real death does (+1, +2, +3), you were actually playing + // and our detection was lying. Unlike a timeout, this can't + // fire just from leaving the game sitting at the menu a while. if gdm, ok := derefPointer(handle, gp.gameDataManSlot); ok && gdm != 0 { if buf, ok := readMemory(handle, gdm+0x94, 4); ok { raw := int32(binary.LittleEndian.Uint32(buf)) if raw >= 0 && raw < 1_000_000 { if haveUnloadedRaw { if d := raw - unloadedRaw; d >= 1 && d <= maxPlausibleDeltaPerTick { - log.Printf("el contador de muertes subio de %d a %d mientras yo creia que no habia personaje cargado: la deteccion de menu esta equivocada en esta version, la desactivo y sigo contando", unloadedRaw, raw) + log.Printf("the death counter went from %d to %d while I thought no character was loaded: menu detection is wrong on this version, turning it off and continuing to count", unloadedRaw, raw) gp.worldChrManSlot = 0 - // Rescatamos lo ocurrido durante el rato confundido: - // dejamos la referencia en la primera lectura de ese - // periodo para que la logica de "cruce de carga" - // acredite las muertes si fueron pocas. + // Recover what happened during the confused + // stretch: keep the reference at that + // period's first reading so the "crossed a + // loading screen" logic can credit the + // deaths if there weren't many. lastRaw = unloadedRawFirst haveLastRaw = true sawUnloaded = true @@ -720,26 +726,27 @@ func pollLoop() { } } } - // Ojo: NO tocamos haveLastRaw/lastRaw, justamente para poder - // comparar contra la ultima lectura buena cuando vuelva el mundo. - st.setPlayerUnloaded("menu principal o pantalla de carga") + // Careful: haveLastRaw/lastRaw are NOT touched, precisely so + // they can be compared against the last good reading once + // the world comes back. + st.setPlayerUnloaded("main menu or loading screen") sawUnloaded = true time.Sleep(1 * time.Second) continue } haveUnloadedRaw = false - // Re-dereferenciamos el slot en CADA tick (no solo al conectar) para - // nunca quedarnos con una direccion vieja de GameDataMan. + // Re-dereference the slot on EVERY tick (not just on connect) to + // never end up stuck with a stale GameDataMan address. gameDataMan, ok := derefPointer(handle, gp.gameDataManSlot) if !ok { - st.setDisconnected("se perdio la lectura de memoria (el juego se cerro o reinicio)") + st.setDisconnected("lost the memory reading (the game closed or restarted)") closeHandle() time.Sleep(2 * time.Second) continue } if gameDataMan == 0 { - st.setPlayerUnloaded("sin partida cargada") + st.setPlayerUnloaded("no game loaded") sawUnloaded = true time.Sleep(1 * time.Second) continue @@ -748,7 +755,7 @@ func pollLoop() { deathsBuf, ok1 := readMemory(handle, gameDataMan+0x94, 4) bossBuf, ok2 := readMemory(handle, gameDataMan+0xC0, 1) if !ok1 { - st.setDisconnected("se perdio la lectura de memoria (el juego se cerro o reinicio)") + st.setDisconnected("lost the memory reading (the game closed or restarted)") closeHandle() time.Sleep(2 * time.Second) continue @@ -760,21 +767,21 @@ func pollLoop() { lastBossRead = boss } if raw < 0 || raw > 1_000_000 { - log.Printf("lectura imposible descartada (raw %d) - re-escaneando firmas", raw) + log.Printf("discarding an impossible reading (raw %d) - rescanning signatures", raw) resolved = false haveLastRaw = false time.Sleep(1 * time.Second) continue } - // Dentro de una misma partida el contador no baja ni pega saltos: - // si pasa, es memoria que ya no es GameDataMan. Cruzando una carga - // en cambio puede cambiar a cualquier cosa, porque puede ser otro - // personaje, y de eso se encarga setCharacter. + // Within the same save, the counter never goes down or jumps: if + // it does, it's memory that's no longer GameDataMan. Crossing a + // load, on the other hand, can change to anything, since it might + // be a different character — and setCharacter handles that case. if haveLastRaw && !sawUnloaded { delta := int64(raw) - int64(lastRaw) if delta < 0 || delta > maxPlausibleDeltaPerTick { - log.Printf("lectura sospechosa descartada (raw %d, anterior %d) - re-escaneando firmas", raw, lastRaw) + log.Printf("discarding a suspicious reading (raw %d, previous %d) - rescanning signatures", raw, lastRaw) resolved = false haveLastRaw = false time.Sleep(1 * time.Second) @@ -782,9 +789,9 @@ func pollLoop() { } } - // Quien es este personaje se resuelve ANTES de registrar la - // lectura: si cambiaste de personaje, el total salta al suyo en - // esta misma vuelta y no hay que esperar a que alguien muera. + // Which character this is gets resolved BEFORE recording the + // reading: if you switched characters, the total jumps to theirs + // on this very pass, with no need to wait for a death. name, _ := resolveCharName(handle, gameDataMan, &gp) st.setCharacter(readSaveSlot(handle, gp), name, raw) @@ -800,9 +807,9 @@ func pollLoop() { // ---------------------------------- HTTP ---------------------------------- -// puertoDe saca el puerto de una direccion tipo "0.0.0.0:47822", para -// poder decirle al compañero exactamente que escribir en su config. -func puertoDe(addr string) string { +// portOf pulls the port out of an address like "0.0.0.0:47822", so your +// partner can be told exactly what to put in their config. +func portOf(addr string) string { if _, port, ok := strings.Cut(addr, ":"); ok { return port } @@ -816,15 +823,15 @@ func withCORS(w http.ResponseWriter) { func main() { log.SetFlags(log.Ltime) - log.Println("=== Elden Ring Death Counter (lectura local, solo lectura) ===") + log.Println("=== Elden Ring Death Counter (local, read-only) ===") loadLocales() cfg := loadConfig() writeSampleConfig() - esPeer := cfg.Mode == "peer" + isPeer := cfg.Mode == "peer" lang := resolveLang(cfg.Language) - log.Printf("version: %s | PID %d | modo %s | idioma %s (disponibles: %s)", + log.Printf("version: %s | PID %d | mode %s | language %s (available: %s)", buildTag, os.Getpid(), cfg.Mode, lang, strings.Join(availableLangs(), ", ")) totals = newTotalsStore() @@ -832,47 +839,77 @@ func main() { go pollLoop() - registro := newPeerRegistry() + registry := newPeerRegistry() - // El token es obligatorio en las dos puntas: sin el, cualquiera que - // alcance el puerto podria inyectar datos en el overlay. + // The token is required on both ends: without it, anyone who can + // reach the port could inject data into the overlay. The hub also + // generates its own TLS certificate the first time: the peer pins it + // by fingerprint (pin.go), not by certificate-authority trust, which + // doesn't exist for a Tailscale or LAN address anyway. var token string - if !esPeer { - t, generado, err := resolveToken(cfg) + var hubCert tls.Certificate + var certFingerprint string + if !isPeer { + t, generated, err := resolveToken(cfg) if err != nil { - log.Fatalf("no pude preparar el token: %v", err) + log.Fatalf("couldn't prepare the token: %v", err) } token = t - logTokenBanner(token, generado) + logTokenBanner(token, generated) + + cert, fp, certGenerated, err := loadOrCreateHubCert() + if err != nil { + log.Fatalf("couldn't prepare the TLS certificate for the peer link: %v", err) + } + if certGenerated { + log.Println("generated a new TLS certificate for the peer link") + } + hubCert, certFingerprint = cert, fp + + candidates := candidateIPv4s() + host := pickBestHost(candidates) + if len(candidates) > 0 { + log.Printf("detected network addresses: %s", strings.Join(candidates, ", ")) + } + peerPort := portOf(cfg.PeerListen) + if host != "" { + invite := encodeInvite(inviteCode{Host: host, Port: peerPort, Fingerprint: certFingerprint, Token: token}) + log.Println("invite code for your co-op partner — paste it as invite = \"...\" in their config.toml:") + log.Println(invite) + log.Printf("(wrong address? they can override just the host with hub = \":%s\")", peerPort) + } else { + log.Println("couldn't auto-detect a network address to build an invite code with.") + log.Printf("have your partner set these by hand in their config.toml: hub = \":%s\", token = \"%s\", fingerprint = \"%s\"", peerPort, token, certFingerprint) + } } - if esPeer { - if cfg.Hub == "" { - log.Println("¡ojo! modo peer sin 'hub' en el config.toml: no tengo a donde mandar el contador") - } else if strings.TrimSpace(cfg.Token) == "" { - log.Println("¡ojo! modo peer sin 'token' en el config.toml: el hub te va a rechazar. Pedíle el token a quien lo corre.") + var peerHub string + if isPeer { + hub, tok, fp, ok, err := resolvePeerConn(cfg) + if err != nil { + log.Fatalf("peer config problem: %v", err) + } + if ok { + peerHub = hub + go peerLoop(cfg, hub, tok, fp) } else { - go peerLoop(cfg) + log.Println("peer mode with nothing configured yet (no 'invite', no 'hub'+'token'+'fingerprint'): running as a local-only overlay for now") } } else { - registro.declare(cfg.Partner) + registry.declare(cfg.Partner) } mux := http.NewServeMux() - if !esPeer { - mux.HandleFunc("/ws", registro.wsHandler(token)) - } - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("X-Build", buildTag) w.Write(overlayHTML) }) - // Los textos de la interfaz: la pagina los pide una vez al cargar. - // ?lang= permite forzar un idioma sin tocar el config, comodo para - // tener el overlay en un idioma y el panel en otro. + // The UI's text: the page requests it once on load. ?lang= lets you + // force a language without touching the config, handy for having the + // overlay in one language and the panel in another. mux.HandleFunc("/strings.json", func(w http.ResponseWriter, r *http.Request) { withCORS(w) want := lang @@ -887,18 +924,18 @@ func main() { withCORS(w) snap := st.snapshot() - // Este jugador primero, despues los compañeros en orden de aparicion. + // This player first, then partners in order of appearance. players := []playerView{{ - Name: nombreMostrado(cfg), + Name: displayName(cfg), Deaths: snap.Total, BossFight: snap.BossFight, PlayerLoaded: snap.PlayerLoaded, Connected: snap.Connected, Self: true, }} - // Modo coop solo cuando hay (o hubo recien) un compañero conectado. - if registro.coopMode() { - players = append(players, registro.views()...) + // Co-op mode only when a partner is (or was just recently) connected. + if registry.coopMode() { + players = append(players, registry.views()...) } var combined int64 @@ -910,8 +947,8 @@ func main() { "players": players, "combined": combined, "build": buildTag, - // Campos de la version de un solo jugador: los dejamos para no - // romper nada que ya este apuntando aca. + // Single-player-version fields: kept so nothing already + // pointing at them breaks. "deaths": snap.Total, "rawDeaths": snap.RawDeaths, "character": snap.CharName, @@ -925,15 +962,37 @@ func main() { }) log.Printf("Panel: http://%s/", cfg.Listen) - if esPeer { - log.Printf("Mandando el contador al hub %s. Esta ventana tiene que quedar abierta mientras jugás.", cfg.Hub) - } else { - log.Printf("OBS URL: http://%s/?view=overlay", cfg.Listen) - log.Printf("Tu compañero tiene que poner en su config.toml: hub = \":%s\" y el token de arriba", puertoDe(cfg.Listen)) - log.Println("Dejá esta ventana abierta mientras streameás. Ctrl+C para cerrar.") + if isPeer { + if peerHub != "" { + log.Printf("Pushing the counter to the hub %s. This window needs to stay open while you play.", peerHub) + } + if err := http.ListenAndServe(cfg.Listen, mux); err != nil { + log.Fatalf("couldn't start the local server: %v", err) + } + return } - if err := http.ListenAndServe(cfg.Listen, mux); err != nil { - log.Fatalf("no se pudo iniciar el servidor local: %v", err) + log.Printf("OBS URL: http://%s/?view=overlay", cfg.Listen) + log.Println("Keep this window open while you stream. Ctrl+C to close.") + + // Panel/overlay in plain HTTP, in the background; the peer server + // (TLS, with the certificate from above) blocks in the foreground as + // the process's main server. + go func() { + if err := http.ListenAndServe(cfg.Listen, mux); err != nil { + log.Fatalf("couldn't start the local server: %v", err) + } + }() + + peerMux := http.NewServeMux() + peerMux.HandleFunc("/ws", registry.wsHandler(token)) + peerSrv := &http.Server{ + Addr: cfg.PeerListen, + Handler: peerMux, + TLSConfig: hubServerTLSConfig(hubCert), + } + log.Printf("Peer link: %s (TLS, certificate pinned)", cfg.PeerListen) + if err := peerSrv.ListenAndServeTLS("", ""); err != nil { + log.Fatalf("couldn't start the peer server: %v", err) } } diff --git a/pin.go b/pin.go new file mode 100644 index 0000000..7849252 --- /dev/null +++ b/pin.go @@ -0,0 +1,65 @@ +// pin.go: certificate PINNING for the peer link, as opposed to CA trust. +// +// There's no certificate authority that can issue for a Tailscale/LAN IP, +// so the peer doesn't ask "was this signed by someone I trust?" — it asks +// "is this the exact certificate my invite code told me to expect?". That +// makes InsecureSkipVerify safe here: it turns off the check that doesn't +// apply (hostname/CA validation) and VerifyPeerCertificate replaces it +// with the one that does. +package main + +import ( + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "fmt" + "strings" +) + +func certMatchesFingerprint(rawCert []byte, wantHex string) bool { + sum := sha256.Sum256(rawCert) + return hex.EncodeToString(sum[:]) == strings.ToLower(strings.TrimSpace(wantHex)) +} + +func validFingerprint(s string) bool { + s = strings.TrimSpace(s) + if len(s) != sha256.Size*2 { + return false + } + _, err := hex.DecodeString(s) + return err == nil +} + +// pinnedClientTLSConfig builds a client TLS config that accepts exactly +// one certificate: the one whose SHA-256 fingerprint matches. Nothing +// else about the certificate (hostname, expiry chain, issuer) is checked. +func pinnedClientTLSConfig(fingerprint string) (*tls.Config, error) { + if !validFingerprint(fingerprint) { + return nil, fmt.Errorf("fingerprint %q doesn't look like a SHA-256 hex digest (want %d hex chars)", fingerprint, sha256.Size*2) + } + want := strings.ToLower(strings.TrimSpace(fingerprint)) + return &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS13, + VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return fmt.Errorf("the server didn't present a certificate") + } + if !certMatchesFingerprint(rawCerts[0], want) { + return fmt.Errorf("the server's certificate doesn't match the pinned fingerprint — wrong hub, or its certificate was regenerated (you'd need a fresh invite code)") + } + return nil + }, + }, nil +} + +// hubServerTLSConfig is the hub side: just present the certificate, no +// client-certificate verification (the peer proves itself at the +// application layer with the HMAC challenge/response, see auth.go). +func hubServerTLSConfig(cert tls.Certificate) *tls.Config { + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS13, + } +} diff --git a/pin_test.go b/pin_test.go new file mode 100644 index 0000000..624d574 --- /dev/null +++ b/pin_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "io" + "net" + "testing" +) + +func TestCertMatchesFingerprint(t *testing.T) { + certDER, _, err := generateHubCert() + if err != nil { + t.Fatal(err) + } + fp := certFingerprint(certDER) + + if !certMatchesFingerprint(certDER, fp) { + t.Fatal("should match its own fingerprint") + } + if !certMatchesFingerprint(certDER, " "+fp+" ") { + t.Fatal("should tolerate surrounding whitespace") + } + if certMatchesFingerprint(certDER, "00"+fp[2:]) { + t.Fatal("should not match a different fingerprint") + } +} + +func TestValidFingerprint(t *testing.T) { + certDER, _, err := generateHubCert() + if err != nil { + t.Fatal(err) + } + fp := certFingerprint(certDER) + + cases := []struct { + s string + ok bool + }{ + {fp, true}, + {"", false}, + {"not-hex-at-all-not-hex-at-all-not-hex-at-all-not-hex-at-all-00", false}, + {fp[:len(fp)-1], false}, // one char short + {fp + "0", false}, // one char long + } + for _, c := range cases { + if got := validFingerprint(c.s); got != c.ok { + t.Errorf("validFingerprint(%q) = %v, wanted %v", c.s, got, c.ok) + } + } +} + +func TestPinnedClientTLSConfig_RejectsBadFingerprint(t *testing.T) { + if _, err := pinnedClientTLSConfig("too short"); err == nil { + t.Fatal("expected an error for a malformed fingerprint") + } +} + +func testCert(t *testing.T) (cert tls.Certificate, fingerprint string) { + t.Helper() + certDER, keyDER, err := generateHubCert() + if err != nil { + t.Fatal(err) + } + priv, err := x509.ParsePKCS8PrivateKey(keyDER) + if err != nil { + t.Fatal(err) + } + return tls.Certificate{Certificate: [][]byte{certDER}, PrivateKey: priv}, certFingerprint(certDER) +} + +// listenTLS starts a TLS server on a random localhost port with the given +// certificate and returns its address. Connections are drained (not +// closed outright) so the TLS handshake — which crypto/tls only performs +// lazily, on first Read/Write — actually gets a chance to complete. +func listenTLS(t *testing.T, cert tls.Certificate) string { + t.Helper() + ln, err := tls.Listen("tcp", "127.0.0.1:0", hubServerTLSConfig(cert)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + io.Copy(io.Discard, c) + }(c) + } + }() + return ln.Addr().String() +} + +func TestPinnedClientTLSConfig_HandshakeEndToEnd(t *testing.T) { + certA, fpA := testCert(t) + _, fpB := testCert(t) // a different cert, never presented by the server + + addr := listenTLS(t, certA) + + // Correct fingerprint: handshake succeeds. + cfg, err := pinnedClientTLSConfig(fpA) + if err != nil { + t.Fatal(err) + } + conn, err := tls.Dial("tcp", addr, cfg) + if err != nil { + t.Fatalf("expected the handshake to succeed with the right fingerprint: %v", err) + } + conn.Close() + + // Pinned to a fingerprint the server never presents: handshake must + // fail, even though certB (fpB) is a perfectly valid certificate on + // its own — it's just not the one at this address. + cfgWrong, err := pinnedClientTLSConfig(fpB) + if err != nil { + t.Fatal(err) + } + if _, err := tls.Dial("tcp", addr, cfgWrong); err == nil { + t.Fatal("expected the handshake to fail: server presented a cert that doesn't match the pinned fingerprint") + } +} diff --git a/tlscert.go b/tlscert.go new file mode 100644 index 0000000..a6fd585 --- /dev/null +++ b/tlscert.go @@ -0,0 +1,146 @@ +// tlscert.go: the hub's self-signed TLS certificate for the peer link. +// +// There's no certificate authority that can issue for a Tailscale/LAN IP, +// so this isn't meant to be CA-trusted — it's meant to be PINNED (see +// pin.go). The hub generates one ed25519 keypair + cert on first run and +// reuses it after that; the peer verifies the connection by comparing the +// certificate's fingerprint against the one from the invite code, not by +// checking who signed it. +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "time" +) + +func hubCertPath() string { + dir, ok := exeDir() + if !ok { + return "hub-cert.pem" + } + return filepath.Join(dir, "hub-cert.pem") +} + +func hubKeyPath() string { + dir, ok := exeDir() + if !ok { + return "hub-key.pem" + } + return filepath.Join(dir, "hub-key.pem") +} + +// certFingerprint is the hex SHA-256 of the certificate's raw DER bytes — +// what gets pinned on the peer side. +func certFingerprint(der []byte) string { + sum := sha256.Sum256(der) + return hex.EncodeToString(sum[:]) +} + +// generateHubCert creates a fresh ed25519 keypair and a self-signed +// certificate around it. No SANs: nothing ever validates a hostname +// against this cert, pinning replaces that entirely. +func generateHubCert() (certDER, keyDER []byte, err error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("couldn't generate a keypair: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, fmt.Errorf("couldn't generate a serial number: %w", err) + } + + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "deathwatch-hub"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + certDER, err = x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv) + if err != nil { + return nil, nil, fmt.Errorf("couldn't create the certificate: %w", err) + } + keyDER, err = x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, nil, fmt.Errorf("couldn't encode the private key: %w", err) + } + return certDER, keyDER, nil +} + +func writePEM(path, blockType string, der []byte) error { + return os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der}), 0600) +} + +func readPEM(path, wantType string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(data) + if block == nil || block.Type != wantType { + return nil, fmt.Errorf("%s doesn't look like a valid %s", path, wantType) + } + return block.Bytes, nil +} + +// loadOrCreateHubCert loads the hub's certificate and key if both are +// already on disk, or generates and persists a new pair if neither is. +// A partial pair (one file present, one missing, or one that doesn't +// parse) is treated as a broken installation, not something to silently +// regenerate around: delete both and restart to get a fresh pair. +func loadOrCreateHubCert() (cert tls.Certificate, fingerprint string, generated bool, err error) { + certPath, keyPath := hubCertPath(), hubKeyPath() + certDER, certErr := readPEM(certPath, "CERTIFICATE") + keyDER, keyErr := readPEM(keyPath, "PRIVATE KEY") + + switch { + case certErr == nil && keyErr == nil: + // both present, fall through to build the tls.Certificate below + case os.IsNotExist(certErr) && os.IsNotExist(keyErr): + certDER, keyDER, err = generateHubCert() + if err != nil { + return tls.Certificate{}, "", false, err + } + if err := writePEM(certPath, "CERTIFICATE", certDER); err != nil { + return tls.Certificate{}, "", false, fmt.Errorf("couldn't save %s: %w", certPath, err) + } + if err := writePEM(keyPath, "PRIVATE KEY", keyDER); err != nil { + return tls.Certificate{}, "", false, fmt.Errorf("couldn't save %s: %w", keyPath, err) + } + generated = true + default: + return tls.Certificate{}, "", false, fmt.Errorf( + "%s and %s should both exist or both be missing (got cert: %v, key: %v) — delete both to generate a fresh pair", + certPath, keyPath, certErr, keyErr) + } + + priv, err := x509.ParsePKCS8PrivateKey(keyDER) + if err != nil { + return tls.Certificate{}, "", false, fmt.Errorf("%s doesn't parse as a private key: %w", keyPath, err) + } + leaf, err := x509.ParseCertificate(certDER) + if err != nil { + return tls.Certificate{}, "", false, fmt.Errorf("%s doesn't parse as a certificate: %w", certPath, err) + } + + cert = tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: priv, + Leaf: leaf, + } + return cert, certFingerprint(certDER), generated, nil +} diff --git a/tlscert_test.go b/tlscert_test.go new file mode 100644 index 0000000..4661704 --- /dev/null +++ b/tlscert_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "os" + "testing" +) + +func TestLoadOrCreateHubCert_GeneratesAndPersists(t *testing.T) { + testExeDir = t.TempDir() + defer func() { testExeDir = "" }() + + cert, fp, generated, err := loadOrCreateHubCert() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !generated { + t.Fatal("expected a fresh cert to be reported as generated") + } + if len(fp) != 64 { + t.Fatalf("fingerprint should be a 64-char hex SHA-256, got %d chars: %q", len(fp), fp) + } + if cert.Leaf == nil { + t.Fatal("expected cert.Leaf to be populated") + } + if _, err := os.Stat(hubCertPath()); err != nil { + t.Fatalf("cert file wasn't persisted: %v", err) + } + if _, err := os.Stat(hubKeyPath()); err != nil { + t.Fatalf("key file wasn't persisted: %v", err) + } +} + +func TestLoadOrCreateHubCert_ReusesSameFingerprint(t *testing.T) { + testExeDir = t.TempDir() + defer func() { testExeDir = "" }() + + _, fp1, gen1, err := loadOrCreateHubCert() + if err != nil || !gen1 { + t.Fatalf("first call should generate: gen=%v err=%v", gen1, err) + } + _, fp2, gen2, err := loadOrCreateHubCert() + if err != nil { + t.Fatalf("unexpected error on reload: %v", err) + } + if gen2 { + t.Fatal("second call should reuse the persisted cert, not generate a new one") + } + if fp1 != fp2 { + t.Fatalf("fingerprint changed across reload: %q vs %q", fp1, fp2) + } +} + +func TestLoadOrCreateHubCert_DifferentInstallationsDiffer(t *testing.T) { + testExeDir = t.TempDir() + _, fpA, _, err := loadOrCreateHubCert() + if err != nil { + t.Fatal(err) + } + + testExeDir = t.TempDir() + _, fpB, _, err := loadOrCreateHubCert() + testExeDir = "" + if err != nil { + t.Fatal(err) + } + + if fpA == fpB { + t.Fatal("two installations generated the same certificate fingerprint") + } +} + +func TestLoadOrCreateHubCert_PartialPairFails(t *testing.T) { + testExeDir = t.TempDir() + defer func() { testExeDir = "" }() + + if err := writePEM(hubCertPath(), "CERTIFICATE", []byte("not a real cert")); err != nil { + t.Fatal(err) + } + // key file intentionally left missing + + if _, _, _, err := loadOrCreateHubCert(); err == nil { + t.Fatal("expected an error for a partial/broken cert+key pair") + } +} diff --git a/ws.go b/ws.go index 933abd6..c01fe50 100644 --- a/ws.go +++ b/ws.go @@ -1,17 +1,21 @@ -// ws.go: implementacion minima de WebSocket (RFC 6455) con la libreria -// estandar, servidor y cliente. No usamos gorilla/websocket para que el -// programa siga siendo un unico .exe sin dependencias que haya que -// repartir ni vendorear. +// ws.go: a minimal WebSocket (RFC 6455) implementation on the standard +// library, server and client. Not using gorilla/websocket so the program +// stays a single .exe with no dependencies to ship or vendor. // -// Alcance a proposito acotado a lo que necesitamos: mensajes de texto -// chicos, sin fragmentacion, sin compresion, sin TLS (va por Tailscale, -// que ya cifra el tramo entre las dos PCs). +// Scope is deliberately narrow, just what we need: small text messages, +// no fragmentation, no compression. +// +// TLS is handled one layer up, not in here: wsUpgrade doesn't need to +// know about it (it terminates at the http.Server/listener level), and +// wsDialTLS just runs the same client handshake over a *tls.Conn instead +// of a plain one. See pin.go for certificate pinning. package main import ( "bufio" "crypto/rand" "crypto/sha1" + "crypto/tls" "encoding/base64" "encoding/binary" "fmt" @@ -33,13 +37,13 @@ const ( opPing = 0x9 opPong = 0xA - maxFrameSize = 1 << 20 // 1 MiB: nuestros mensajes son de ~100 bytes + maxFrameSize = 1 << 20 // 1 MiB: our messages run ~100 bytes ) type wsConn struct { conn net.Conn br *bufio.Reader - isClient bool // solo el cliente enmascara, segun el RFC + isClient bool // only the client masks, per the RFC wmu sync.Mutex closed bool } @@ -50,20 +54,20 @@ func wsAcceptKey(key string) string { return base64.StdEncoding.EncodeToString(h.Sum(nil)) } -// wsUpgrade convierte una peticion HTTP entrante en una conexion -// WebSocket (lado servidor). +// wsUpgrade turns an incoming HTTP request into a WebSocket connection +// (server side). func wsUpgrade(w http.ResponseWriter, r *http.Request) (*wsConn, error) { if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { - return nil, fmt.Errorf("no es un upgrade a websocket") + return nil, fmt.Errorf("not a websocket upgrade") } key := r.Header.Get("Sec-WebSocket-Key") if key == "" { - return nil, fmt.Errorf("falta la cabecera Sec-WebSocket-Key") + return nil, fmt.Errorf("missing the Sec-WebSocket-Key header") } hj, ok := w.(http.Hijacker) if !ok { - return nil, fmt.Errorf("este servidor no soporta hijack") + return nil, fmt.Errorf("this server doesn't support hijack") } conn, brw, err := hj.Hijack() if err != nil { @@ -80,12 +84,30 @@ func wsUpgrade(w http.ResponseWriter, r *http.Request) (*wsConn, error) { return &wsConn{conn: conn, br: brw.Reader}, nil } -// wsDial abre una conexion WebSocket contra un hub (lado cliente). +// wsDial opens a WebSocket connection to a hub (client side). func wsDial(addr, path string, timeout time.Duration) (*wsConn, error) { conn, err := net.DialTimeout("tcp", addr, timeout) if err != nil { return nil, err } + return wsHandshake(conn, addr, path, timeout) +} + +// wsDialTLS is wsDial over an encrypted connection: same handshake, dialed +// through tlsCfg instead of a plain net.Dial. The TLS handshake itself +// (including certificate verification, e.g. pinning — see pin.go) happens +// inside tls.DialWithDialer before the WebSocket upgrade is attempted. +func wsDialTLS(addr, path string, timeout time.Duration, tlsCfg *tls.Config) (*wsConn, error) { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", addr, tlsCfg) + if err != nil { + return nil, err + } + return wsHandshake(conn, addr, path, timeout) +} + +// wsHandshake does the WebSocket upgrade handshake (client side) over an +// already-established connection, plain or TLS — both satisfy net.Conn. +func wsHandshake(conn net.Conn, addr, path string, timeout time.Duration) (*wsConn, error) { var keyBytes [16]byte if _, err := rand.Read(keyBytes[:]); err != nil { conn.Close() @@ -114,11 +136,11 @@ func wsDial(addr, path string, timeout time.Duration) (*wsConn, error) { resp.Body.Close() if resp.StatusCode != http.StatusSwitchingProtocols { conn.Close() - return nil, fmt.Errorf("el hub respondio %s (esperaba 101)", resp.Status) + return nil, fmt.Errorf("the hub replied %s (expected 101)", resp.Status) } if !strings.EqualFold(resp.Header.Get("Sec-WebSocket-Accept"), wsAcceptKey(key)) { conn.Close() - return nil, fmt.Errorf("el handshake no valida (¿del otro lado hay un websocket?)") + return nil, fmt.Errorf("the handshake doesn't validate (is there really a websocket on the other end?)") } conn.SetDeadline(time.Time{}) return &wsConn{conn: conn, br: br, isClient: true}, nil @@ -201,13 +223,13 @@ func (c *wsConn) readFrame() (opcode byte, payload []byte, err error) { } v := binary.BigEndian.Uint64(ext[:]) if v > maxFrameSize { - err = fmt.Errorf("frame demasiado grande (%d bytes)", v) + err = fmt.Errorf("frame too large (%d bytes)", v) return } n = int(v) } if n > maxFrameSize { - err = fmt.Errorf("frame demasiado grande (%d bytes)", n) + err = fmt.Errorf("frame too large (%d bytes)", n) return } @@ -229,13 +251,13 @@ func (c *wsConn) readFrame() (opcode byte, payload []byte, err error) { } } if !fin || opcode == opContinuation { - err = fmt.Errorf("frames fragmentados no soportados") + err = fmt.Errorf("fragmented frames aren't supported") } return } -// ReadMessage devuelve el proximo mensaje de texto/binario, respondiendo -// los pings por dentro. Un close del otro lado se reporta como io.EOF. +// ReadMessage returns the next text/binary message, answering pings +// internally along the way. A close from the other side reports as io.EOF. func (c *wsConn) ReadMessage() ([]byte, error) { for { op, payload, err := c.readFrame() @@ -250,12 +272,12 @@ func (c *wsConn) ReadMessage() ([]byte, error) { return nil, err } case opPong: - // nada que hacer + // nothing to do case opClose: c.writeFrame(opClose, nil) return nil, io.EOF default: - return nil, fmt.Errorf("opcode desconocido: 0x%X", op) + return nil, fmt.Errorf("unknown opcode: 0x%X", op) } } }