Add TLS + certificate pinning for the peer link

The co-op link was authenticated (HMAC token, never sent over the wire)
but not encrypted. The hub now generates a self-signed cert on first run;
the peer pins its exact fingerprint (no CA involved — there isn't one for
a Tailscale/LAN address), delivered via a single invite-code paste that
also carries the token, replacing today's separate IP+token copy.

The peer link moves to its own TLS-only port (peer_listen, 47823) so the
plain overlay/panel port (47822, OBS-facing) never needs to be exposed
alongside it — today, opening the overlay port to a remote partner also
exposes /deaths and the panel to anyone.

Mandatory pinning, no insecure fallback: a half-configured peer (some but
not all of hub/token/fingerprint, or a broken invite) fails loudly at
startup rather than connecting unpinned. An unconfigured peer still runs
fine as a local-only overlay, same as before.

New: tlscert.go (cert generation/persistence), pin.go (fingerprint
pinning), invite.go (invite-code encode/decode, host auto-detection),
each with tests. main.go/config.go/duo.go/ws.go carry the wiring for
this — the dual listener, new config keys, and the TLS-aware WebSocket
dial — and were rewritten in English in the process, per the project's
new English-only code convention (see CLAUDE.md).
This commit is contained in:
emmatherock committed 2026-09-17 21:51:22 -03:00
1 parent e1f7e6f529
commit e9fe10f0f7
12 files changed
+1195 -462

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ client-id.txt
config.toml config.toml
offset.txt offset.txt
token.txt token.txt
hub-cert.pem
hub-key.pem
# config local de Claude Code (auto mode), especifica de esta maquina # config local de Claude Code (auto mode), especifica de esta maquina
.claude/settings.local.json .claude/settings.local.json
+68 -81
View File
@@ -19,16 +19,17 @@ error del código: es que `main.go` e `i18n.go` solo compilan para Windows.
## Idioma del código ## Idioma del código
El repo va a publicarse en GitHub. **Identificadores, comentarios y El repo va a publicarse en GitHub. **Identificadores, comentarios y
mensajes de log/consola van en inglés** — código nuevo se escribe mensajes de log/consola van en inglés.** Ya se hizo la pasada completa:
directamente en inglés, sin excepción. `names.go` y `names_test.go` son todo `.go` (identificadores, comentarios, logs) y los comentarios de
el primer archivo escrito bajo esta regla; úsenlo de referencia. `overlay.html` están en inglés.
El resto del código (`main.go`, `i18n.go`, `counter.go`, `totals.go`, Esto **no** afecta a los textos que ve el usuario final en el
`config.go`, `duo.go`, `ws.go`, `auth.go`, comentarios de `overlay.html`) overlay/panel (`locales/*.json`, `overlay.html`): esos siguen soportando
todavía está en español — ver el pendiente de traducción más abajo. Esto español e inglés vía el sistema de i18n existente (`i18n.go`), elegido
**no** afecta a los textos que ve el usuario final en el overlay/panel por idioma del sistema. Son cosas distintas a propósito: el código es
(`locales/*.json`, `overlay.html`): esos siguen soportando español e para quien lo lee en GitHub, el overlay es para quien lo mira en el
inglés vía el sistema de i18n existente, elegido por idioma del sistema. stream — y por eso el inglés del código no absorbió el sistema de
idiomas del overlay, ni al revés.
## Estructura ## 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 | | `config.go` | Parser TOML propio + `config.toml` | portable |
| `duo.go` | Modo co-op: registro de peers, hub y peer | portable | | `duo.go` | Modo co-op: registro de peers, hub y peer | portable |
| `ws.go` | WebSocket hecho a mano (RFC 6455, subconjunto) | 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` | — | | `overlay.html` | Overlay e interfaz, embebido con `go:embed` | — |
`counter.go` está separado de `main.go` **a propósito**: es donde vivieron `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 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. 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 ## Offsets de memoria
@@ -79,7 +83,48 @@ Cheat Engine.
El transporte **no se asume confiable**: puede ser Tailscale, ZeroTier, El transporte **no se asume confiable**: puede ser Tailscale, ZeroTier,
WireGuard, o un puerto abierto directo a internet. La conexión se defiende 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 - El token es **obligatorio**. Lo genera el programa (`token.txt`, 128
bits); `token` en `config.toml` lo pisa si alguien quiere elegirlo. 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 - El `id` que vale es el de la conexión autenticada, no el que declare
cada mensaje. cada mensaje.
**Lo que esto NO hace: cifrar.** Los mensajes (nombre y número de muertes) Los tests de esto están en `auth_test.go` (token correcto, token
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
equivocado, mensajes sin autenticar, que el token no aparezca en el 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 ## Reglas que salieron de bugs reales
@@ -149,10 +191,10 @@ No son preferencias de estilo. Cada una costó un bug en producción.
## Pendientes ## Pendientes
- Pasada completa a inglés: identificadores, comentarios, mensajes de - No hay `README.md` todavía. Escribir uno en inglés (instalación, modo
log/consola y el README (hoy todo está en español salvo `names.go` y hub/peer, capturas) es lo único que falta del pendiente de idioma — el
`names_test.go`, ver "Idioma del código" arriba). Conviene que sea un código ya está en inglés de punta a punta, ver "Idioma del código"
commit aparte del resto, dado el volumen del diff. arriba.
- La identificación por nombre (respaldo cuando no se lee el slot) mezcla - La identificación por nombre (respaldo cuando no se lee el slot) mezcla
personajes homónimos. Documentado, no resuelto. personajes homónimos. Documentado, no resuelto.
- **Compilar y correr en Linux (Proton).** Mucha gente juega Elden Ring - **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`. `GetUserDefaultLocaleName`.
No hay que tocar: los offsets, las firmas, `counter.go`, `names.go`, 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 `totals.go`, `duo.go`, `ws.go`, `tlscert.go`, `pin.go`, `invite.go`,
portables. `config.go` ni el overlay. Ya son portables.
**Modo sólo-hub, de regalo.** Un hub que no lee memoria —que sólo junta **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 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), [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/). [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 - El README debería mencionar que meter todo adentro de una VPN sigue
conexión está autenticada pero no cifrada. siendo una opción perfectamente válida, TLS+pinning aparte.
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.
+103 -62
View File
@@ -1,16 +1,16 @@
// config.go: lectura de config.toml. // config.go: reading config.toml.
// //
// Parser de TOML hecho a mano y a proposito ACOTADO. La razon de no usar // A hand-rolled, deliberately LIMITED TOML parser. The reason for not
// una libreria (BurntSushi/toml o similar) es que el entorno donde se // using a library (BurntSushi/toml or similar) is that the environment
// compila esto no tiene acceso al proxy de modulos de Go, y ademas // this was originally compiled in had no access to the Go module proxy,
// mantiene el programa como un unico .exe sin dependencias. // and it also keeps the program a single .exe with no dependencies.
// //
// Soporta: comentarios (#), cabeceras de seccion ([algo]), y claves // Supports: comments (#), section headers ([something]), and
// "clave = valor" donde el valor es un string ("..." o '...'), un // "key = value" entries where the value is a string ("..." or '...'), a
// booleano o un entero. NO soporta arrays, tablas inline, strings // boolean, or an integer. Does NOT support arrays, inline tables,
// multilinea ni claves con puntos. Cualquier cosa fuera de ese subconjunto // multiline strings, or dotted keys. Anything outside that subset gets
// se reporta con numero de linea en vez de ignorarse en silencio: es // reported with a line number instead of silently ignored: better to
// preferible enterarse al arrancar y no a mitad de un stream. // find out at startup than midway through a stream.
package main package main
import ( import (
@@ -25,28 +25,35 @@ import (
) )
const ( const (
// Escucha en todas las interfaces: OBS suele correr en otra PC de la // Listens on every interface: OBS often runs on another PC on the
// LAN, asi que 127.0.0.1 (solo local) no alcanza. // LAN, so 127.0.0.1 (local only) isn't enough.
listenAddr = "0.0.0.0:47822" 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 { type config struct {
Name string // como se muestra este jugador; vacio = nombre del personaje Name string // how this player is shown; empty = character name
Mode string // "hub" o "peer" Mode string // "hub" or "peer"
Listen string // donde se sirve el panel/overlay Listen string // where the panel/overlay is served (HTTP, no TLS)
Hub string // modo peer: direccion del hub PeerListen string // hub: where it listens for the peer connection (TLS)
Token string // secreto compartido opcional Hub string // peer mode: the hub's address (host:PeerListen)
Partner string // opcional: fuerza el modo coop reservando este nombre Token string // optional shared secret
Language string // idioma de la interfaz: "auto", "en", "es", ... 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 { func defaultConfig() config {
return config{ return config{
Mode: "hub", Mode: "hub",
Listen: listenAddr, Listen: listenAddr,
PeerListen: peerListenAddr,
Language: "auto", Language: "auto",
} }
} }
@@ -59,15 +66,15 @@ func configPath() string {
return filepath.Join(filepath.Dir(exe), "config.toml") return filepath.Join(filepath.Dir(exe), "config.toml")
} }
// tomlValue es un valor ya parseado: guardamos el texto crudo porque // tomlValue is an already-parsed value: we keep the raw text because all
// todas nuestras claves son strings, pero validamos el tipo igual. // our keys are strings, but we still validate the type.
type tomlValue struct { type tomlValue struct {
str string str string
line int line int
} }
// parseTOML devuelve el mapa de claves de la tabla raiz. Las claves // parseTOML returns the root table's key map. Keys inside a section come
// dentro de una seccion quedan como "seccion.clave". // out as "section.key".
func parseTOML(src string) (map[string]tomlValue, []string) { func parseTOML(src string) (map[string]tomlValue, []string) {
out := map[string]tomlValue{} out := map[string]tomlValue{}
var problems []string var problems []string
@@ -81,7 +88,7 @@ func parseTOML(src string) (map[string]tomlValue, []string) {
} }
if strings.HasPrefix(line, "[") { if strings.HasPrefix(line, "[") {
if !strings.HasSuffix(line, "]") || 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 continue
} }
section = strings.TrimSpace(line[1 : len(line)-1]) 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, "=") key, rest, ok := strings.Cut(line, "=")
if !ok { 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 continue
} }
key = strings.TrimSpace(key) key = strings.TrimSpace(key)
if 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 continue
} }
if section != "" { if section != "" {
@@ -104,7 +111,7 @@ func parseTOML(src string) (map[string]tomlValue, []string) {
val, err := parseTOMLValue(strings.TrimSpace(rest)) val, err := parseTOMLValue(strings.TrimSpace(rest))
if err != nil { 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 continue
} }
out[strings.ToLower(key)] = tomlValue{str: val, line: lineNo} 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) { func parseTOMLValue(s string) (string, error) {
if s == "" { if s == "" {
return "", fmt.Errorf("falta el valor") return "", fmt.Errorf("missing value")
} }
switch s[0] { switch s[0] {
case '"': case '"':
@@ -122,10 +129,10 @@ func parseTOMLValue(s string) (string, error) {
case '\'': case '\'':
return parseQuoted(s, '\'', false) return parseQuoted(s, '\'', false)
case '[', '{': 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 { if idx := strings.Index(s, "#"); idx >= 0 {
s = strings.TrimSpace(s[:idx]) s = strings.TrimSpace(s[:idx])
} }
@@ -138,19 +145,19 @@ func parseTOMLValue(s string) (string, error) {
if _, err := strconv.ParseInt(s, 10, 64); err == nil { if _, err := strconv.ParseInt(s, 10, 64); err == nil {
return s, 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 // parseQuoted reads a quoted string and discards whatever comes after it
// despues si es un comentario. Con comillas dobles interpreta los // if it's a comment. Double quotes interpret the usual escapes; single
// escapes mas comunes; con simples el contenido es literal, como en TOML. // quotes are literal, like in TOML.
func parseQuoted(s string, quote byte, escapes bool) (string, error) { func parseQuoted(s string, quote byte, escapes bool) (string, error) {
var sb strings.Builder var sb strings.Builder
for i := 1; i < len(s); i++ { for i := 1; i < len(s); i++ {
c := s[i] c := s[i]
if escapes && c == '\\' { if escapes && c == '\\' {
if i+1 >= len(s) { 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++ i++
switch s[i] { switch s[i] {
@@ -165,26 +172,26 @@ func parseQuoted(s string, quote byte, escapes bool) (string, error) {
case '\\': case '\\':
sb.WriteByte('\\') sb.WriteByte('\\')
default: default:
return "", fmt.Errorf("escape no soportado: \\%c", s[i]) return "", fmt.Errorf("unsupported escape: \\%c", s[i])
} }
continue continue
} }
if c == quote { if c == quote {
trailing := strings.TrimSpace(s[i+1:]) trailing := strings.TrimSpace(s[i+1:])
if trailing != "" && !strings.HasPrefix(trailing, "#") { 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 return sb.String(), nil
} }
sb.WriteByte(c) sb.WriteByte(c)
} }
return "", fmt.Errorf("falta la comilla de cierre") return "", fmt.Errorf("missing closing quote")
} }
var knownKeys = map[string]bool{ var knownKeys = map[string]bool{
"name": true, "mode": true, "listen": true, "name": true, "mode": true, "listen": true, "peer_listen": true,
"hub": true, "token": true, "partner": true, "hub": true, "token": true, "invite": true, "fingerprint": true,
"language": true, "partner": true, "language": true,
} }
func loadConfig() config { func loadConfig() config {
@@ -193,7 +200,7 @@ func loadConfig() config {
data, err := os.ReadFile(configPath()) data, err := os.ReadFile(configPath())
if err != nil { if err != nil {
if !os.IsNotExist(err) { 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 return cfg
} }
@@ -205,7 +212,7 @@ func loadConfig() config {
for k, v := range values { for k, v := range values {
if !knownKeys[k] { 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 continue
} }
switch k { switch k {
@@ -217,10 +224,18 @@ func loadConfig() config {
if v.str != "" { if v.str != "" {
cfg.Listen = v.str cfg.Listen = v.str
} }
case "peer_listen":
if v.str != "" {
cfg.PeerListen = v.str
}
case "hub": case "hub":
cfg.Hub = v.str cfg.Hub = v.str
case "token": case "token":
cfg.Token = v.str cfg.Token = v.str
case "invite":
cfg.Invite = v.str
case "fingerprint":
cfg.Fingerprint = v.str
case "partner": case "partner":
cfg.Partner = v.str cfg.Partner = v.str
case "language": case "language":
@@ -229,7 +244,7 @@ func loadConfig() config {
} }
if cfg.Mode != "hub" && cfg.Mode != "peer" { 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" cfg.Mode = "hub"
} }
return cfg return cfg
@@ -246,19 +261,43 @@ name = ""
# "peer" = this PC only reads its own game and pushes to the hub. # "peer" = this PC only reads its own game and pushes to the hub.
mode = "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" 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 = "" 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 # 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 # The token itself never travels over the network: the hub sends a random
# challenge and the peer answers with an HMAC of it. # challenge and the peer answers with an HMAC of it.
token = "" 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 # Interface language. "auto" follows your Windows language and falls
# back to English. Any file in locales/ is a valid value, e.g. "en", "es". # back to English. Any file in locales/ is a valid value, e.g. "en", "es".
language = "auto" language = "auto"
@@ -269,13 +308,14 @@ language = "auto"
partner = "" partner = ""
` `
// clientID devuelve un identificador estable de ESTA instalacion, // clientID returns a stable identifier for THIS installation, saved to
// guardado en client-id.txt al lado del programa. // client-id.txt next to the program.
// //
// Existe porque el nombre no sirve para identificar a un jugador: desde // It exists because the name doesn't work as a player's identity: from
// que lo leemos del personaje, cambia cada vez que cargan otra partida. // the moment we read it off the character, it changes every time they
// Con un id propio, el hub sigue viendo al mismo compañero aunque cambie // load a different save. With its own id, the hub keeps seeing the same
// de personaje, se reconecte o reinicie el programa. // partner even if they switch characters, reconnect, or restart the
// program.
func clientID() string { func clientID() string {
path := "client-id.txt" path := "client-id.txt"
if exe, err := os.Executable(); err == nil { if exe, err := os.Executable(); err == nil {
@@ -288,25 +328,26 @@ func clientID() string {
} }
var b [8]byte var b [8]byte
if _, err := rand.Read(b[:]); err != nil { 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()) return fmt.Sprintf("pid-%d", os.Getpid())
} }
id := hex.EncodeToString(b[:]) id := hex.EncodeToString(b[:])
if err := os.WriteFile(path, []byte(id), 0644); err != nil { 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 return id
} }
// writeSampleConfig deja un config.toml comentado la primera vez. // writeSampleConfig leaves a commented config.toml the first time.
func writeSampleConfig() { func writeSampleConfig() {
path := configPath() path := configPath()
if _, err := os.Stat(path); err == nil { if _, err := os.Stat(path); err == nil {
return return
} }
if err := os.WriteFile(path, []byte(sampleConfig), 0644); err != nil { 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 return
} }
log.Printf("dejé un config.toml de ejemplo al lado del programa") log.Printf("left a sample config.toml next to the program")
} }
+137 -89
View File
@@ -1,9 +1,10 @@
// duo.go: modo cooperativo. Cada jugador corre el programa en su propia // duo.go: co-op mode. Each player runs the program on their own PC,
// PC leyendo su propio Elden Ring; uno hace de "hub" (sirve el overlay a // reading their own Elden Ring; one acts as the "hub" (serves the overlay
// OBS) y el resto se conectan a el por WebSocket y le empujan su contador. // 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 // The connection is ALWAYS opened by the peer toward the hub, which is
// Tailscale: alcanza con que el hub tenga una IP estable en la tailnet. // what's convenient with Tailscale: it's enough for the hub to have a
// stable IP on the tailnet.
package main package main
import ( import (
@@ -17,15 +18,15 @@ import (
"time" "time"
) )
// Si un peer no manda nada en este tiempo, lo damos por desconectado. // If a peer sends nothing for this long, we consider it disconnected.
// El peer empuja una vez por segundo, asi que es holgado. // The peer pushes once a second, so this is generous.
const peerTimeout = 6 * time.Second const peerTimeout = 6 * time.Second
// Cuanto seguimos mostrando la interfaz de coop despues de que el // How long we keep showing the co-op layout after the partner disappears.
// compañero desaparece. Ver coopMode() para el porque. // See coopMode() for why.
const coopGrace = 2 * time.Minute const coopGrace = 2 * time.Minute
// --------------------------- registro de peers --------------------------- // --------------------------- peer registry ---------------------------
type playerView struct { type playerView struct {
Name string `json:"name"` Name string `json:"name"`
@@ -36,41 +37,41 @@ type playerView struct {
Self bool `json:"self"` Self bool `json:"self"`
} }
// peerEntry: OJO con la identidad. El nombre NO sirve como clave: desde // peerEntry: WATCH OUT for identity. The name does NOT work as a key:
// que lo leemos del personaje, cambia cuando el jugador carga otra // from the moment we read it off the character, it changes whenever the
// partida, y si indexaramos por nombre cada cambio de personaje crearia // player loads a different save, and indexing by name would spawn a new
// un jugador nuevo y dejaria el anterior colgado en pantalla. La // player on every character switch while leaving the old one stuck on
// identidad es el id que manda cada instancia (ver clientID), estable // screen. Identity is the id each instance sends (see clientID), stable
// entre reconexiones y reinicios; el nombre es solo algo que mostramos. // across reconnects and restarts; the name is only something we display.
type peerEntry struct { 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 name string
deaths int64 deaths int64
bossFight bool bossFight bool
playerLoaded bool playerLoaded bool
lastSeen time.Time lastSeen time.Time
everSeen bool everSeen bool
reserved bool // lugar apartado por config, todavia sin dueño reserved bool // slot reserved by config, not yet claimed
} }
type peerRegistry struct { type peerRegistry struct {
mu sync.Mutex mu sync.Mutex
peers map[string]*peerEntry 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 forced bool // the config named a partner: co-op from startup
latched bool // ya vimos un compañero en esta corrida latched bool // we've already seen a partner this run
lastCoop time.Time // ultima vez que hubo alguien conectado lastCoop time.Time // last time someone was connected
} }
func newPeerRegistry() *peerRegistry { func newPeerRegistry() *peerRegistry {
return &peerRegistry{peers: map[string]*peerEntry{}} return &peerRegistry{peers: map[string]*peerEntry{}}
} }
// declare reserva el lugar de un compañero antes de que se conecte y // declare reserves a partner's slot before they connect and locks in
// fija el modo coop desde el arranque. Es opcional: sirve cuando querés // co-op mode from startup. Optional: useful when you want the overlay at
// que el overlay tenga el tamaño definitivo desde el minuto cero en vez // its final size from minute zero instead of resizing once the other
// de cambiar cuando el otro aparece. // player shows up.
func (r *peerRegistry) declare(name string) { func (r *peerRegistry) declare(name string) {
if name == "" { if name == "" {
return return
@@ -85,13 +86,13 @@ func (r *peerRegistry) declare(name string) {
r.forced = true 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 // It latches on when a partner shows up and does NOT let go at the first
// bache: si volviera a modo solo cada vez que se corta la red un // hiccup: if it fell back to solo mode every time the network dropped for
// segundo, el overlay se redibujaria en vivo en pleno stream. Recien // a second, the overlay would redraw live mid-stream. It only returns to
// vuelve al modo de un jugador cuando el otro estuvo ausente un buen // single-player once the other player has been gone for a good while,
// rato, que es la señal de "se fue a dormir", no de "se le colgo el wifi". // which is the signal for "went to sleep", not "wifi hiccuped".
func (r *peerRegistry) coopMode() bool { func (r *peerRegistry) coopMode() bool {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -103,7 +104,7 @@ func (r *peerRegistry) coopMode() bool {
if p.everSeen && now.Sub(p.lastSeen) < peerTimeout { if p.everSeen && now.Sub(p.lastSeen) < peerTimeout {
if !r.latched { if !r.latched {
r.latched = true 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 r.lastCoop = now
return true return true
@@ -114,13 +115,13 @@ func (r *peerRegistry) coopMode() bool {
return true return true
} }
r.latched = false 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 return false
} }
// keyFor: el id manda. Si viniera vacio (una version vieja del otro // keyFor: the id wins. If it came in empty (an old version on the other
// lado) caemos al nombre, que es lo que habia antes. // end), fall back to the name, which is how it used to work.
func keyFor(m peerMessage) string { func keyFor(m peerMessage) string {
if id := strings.TrimSpace(m.ID); id != "" { if id := strings.TrimSpace(m.ID); id != "" {
return "id:" + id return "id:" + id
@@ -128,8 +129,8 @@ func keyFor(m peerMessage) string {
return "name:" + strings.ToLower(strings.TrimSpace(m.Name)) return "name:" + strings.ToLower(strings.TrimSpace(m.Name))
} }
// rekey mueve una entrada a otra clave conservando su lugar en el orden, // rekey moves an entry to another key while keeping its place in the
// para que el overlay no reordene columnas por debajo. // order, so the overlay doesn't reshuffle columns underneath it.
func (r *peerRegistry) rekey(p *peerEntry, newKey string) { func (r *peerRegistry) rekey(p *peerEntry, newKey string) {
delete(r.peers, p.key) delete(r.peers, p.key)
for i, k := range r.order { for i, k := range r.order {
@@ -149,9 +150,9 @@ func (r *peerRegistry) update(m peerMessage) {
key := keyFor(m) key := keyFor(m)
p := r.peers[key] p := r.peers[key]
// Sin entrada propia: si hay un lugar apartado por config todavia sin // No entry of its own: if there's a slot reserved by config still
// dueño, lo toma el primero que llega. Apartarlo por nombre exacto no // unclaimed, the first one to arrive takes it. Reserving it by exact
// serviria, porque el nombre que manda es el del personaje. // name wouldn't work, since the name sent is the character's.
if p == nil { if p == nil {
for _, k := range r.order { for _, k := range r.order {
if e := r.peers[k]; e.reserved && !e.everSeen { 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 n := strings.TrimSpace(m.Name); n != "" && n != p.name {
if p.name != "" && p.everSeen { 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 p.name = n
} }
@@ -188,8 +189,8 @@ func (r *peerRegistry) views() []playerView {
out := make([]playerView, 0, len(r.order)) out := make([]playerView, 0, len(r.order))
for _, k := range r.order { for _, k := range r.order {
p := r.peers[k] p := r.peers[k]
// Los que hace mucho que no aparecen se van del overlay: es el // Anyone who hasn't shown up in a long while leaves the overlay:
// mismo umbral con el que volvemos al modo de un jugador. // same threshold we use to fall back to single-player mode.
if p.everSeen && now.Sub(p.lastSeen) > coopGrace { if p.everSeen && now.Sub(p.lastSeen) > coopGrace {
continue continue
} }
@@ -205,16 +206,16 @@ func (r *peerRegistry) views() []playerView {
return out 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 authChallenge struct {
Type string `json:"type"` // "challenge" Type string `json:"type"` // "challenge"
Nonce string `json:"nonce"` Nonce string `json:"nonce"`
} }
// authReply es la respuesta del peer: prueba que conoce el token sin // authReply is the peer's response: proves it knows the token without
// mandarlo. Ver auth.go. // sending it. See auth.go.
type authReply struct { type authReply struct {
Type string `json:"type"` // "auth" Type string `json:"type"` // "auth"
ID string `json:"id"` ID string `json:"id"`
@@ -222,8 +223,8 @@ type authReply struct {
} }
type peerMessage struct { type peerMessage struct {
// ID identifica a la instalacion, no al personaje: es lo que permite // ID identifies the installation, not the character: it's what lets a
// que cambiar de personaje no cree un jugador nuevo en el overlay. // character switch avoid spawning a new player in the overlay.
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"` // "state" Type string `json:"type,omitempty"` // "state"
Name string `json:"name"` Name string `json:"name"`
@@ -232,19 +233,19 @@ type peerMessage struct {
PlayerLoaded bool `json:"playerLoaded"` PlayerLoaded bool `json:"playerLoaded"`
} }
// ------------------------------- lado hub ------------------------------- // ------------------------------- hub side -------------------------------
func (r *peerRegistry) wsHandler(token string) http.HandlerFunc { func (r *peerRegistry) wsHandler(token string) http.HandlerFunc {
var activas atomic.Int32 var active atomic.Int32
return func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) {
if n := activas.Add(1); n > maxPeerConns { if n := active.Add(1); n > maxPeerConns {
activas.Add(-1) active.Add(-1)
log.Printf("rechazo conexion de %s: ya hay %d conexiones abiertas", req.RemoteAddr, maxPeerConns) log.Printf("rejecting connection from %s: already %d connections open", req.RemoteAddr, maxPeerConns)
http.Error(w, "too many connections", http.StatusServiceUnavailable) http.Error(w, "too many connections", http.StatusServiceUnavailable)
return return
} }
defer activas.Add(-1) defer active.Add(-1)
c, err := wsUpgrade(w, req) c, err := wsUpgrade(w, req)
if err != nil { if err != nil {
@@ -255,10 +256,10 @@ func (r *peerRegistry) wsHandler(token string) http.HandlerFunc {
remote := c.RemoteAddr() remote := c.RemoteAddr()
// --- autenticacion: desafio al azar, el token no viaja --- // --- authentication: random challenge, the token never travels ---
nonce, err := randomHex(nonceBytes) nonce, err := randomHex(nonceBytes)
if err != nil { 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 return
} }
ch, _ := json.Marshal(authChallenge{Type: "challenge", Nonce: nonce}) 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)) c.SetReadDeadline(time.Now().Add(authTimeoutSeconds * time.Second))
raw, err := c.ReadMessage() raw, err := c.ReadMessage()
if err != nil { if err != nil {
log.Printf("%s se fue sin autenticarse: %v", remote, err) log.Printf("%s left without authenticating: %v", remote, err)
return return
} }
var reply authReply var reply authReply
if err := json.Unmarshal(raw, &reply); err != nil || reply.Type != "auth" { 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 return
} }
if !proofValid(token, nonce, reply.Proof) { 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 return
} }
log.Printf("se autentico un compañero desde %s", remote) log.Printf("a partner authenticated from %s", remote)
var who string var who string
for { for {
@@ -289,79 +290,126 @@ func (r *peerRegistry) wsHandler(token string) http.HandlerFunc {
raw, err := c.ReadMessage() raw, err := c.ReadMessage()
if err != nil { if err != nil {
if who != "" { if who != "" {
log.Printf("se desconecto %s (%s): %v", who, remote, err) log.Printf("%s disconnected (%s): %v", who, remote, err)
} else { } else {
log.Printf("se desconecto %s: %v", remote, err) log.Printf("%s disconnected: %v", remote, err)
} }
return return
} }
var m peerMessage var m peerMessage
if err := json.Unmarshal(raw, &m); err != nil { 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 continue
} }
// El id de la conexion autenticada manda: que un mensaje // The authenticated connection's id wins: a message declaring
// declare otro id no lo convierte en otro jugador. // a different id doesn't turn it into another player.
if reply.ID != "" { if reply.ID != "" {
m.ID = reply.ID m.ID = reply.ID
} }
if strings.TrimSpace(m.Name) == "" { if strings.TrimSpace(m.Name) == "" {
m.Name = "Jugador 2" m.Name = "Player 2"
} }
if who == "" { if who == "" {
who = m.Name who = m.Name
log.Printf("%s entro al contador compartido", who) log.Printf("%s joined the shared counter", who)
} }
r.update(m) r.update(m)
} }
} }
} }
// ------------------------------ lado peer ------------------------------ // ------------------------------ peer side ------------------------------
// peerLoop mantiene viva la conexion con el hub y le empuja el estado // resolvePeerConn decides which hub address, token, and certificate
// local una vez por segundo. Reintenta solo para siempre: si el hub se // fingerprint to use for the peer connection — see invite.go/pin.go.
// reinicia en medio del stream, se vuelve a enganchar sin tocar nada. //
func peerLoop(cfg config) { // 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() 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.clientID = id
for { cfg.Token = token
c, err := wsDial(cfg.Hub, "/ws", 8*time.Second)
tlsCfg, err := pinnedClientTLSConfig(fingerprint)
if err != nil { if err != nil {
log.Printf("no me pude conectar al hub %s (%v) - reintento en 5s", cfg.Hub, err) log.Fatalf("invalid peer fingerprint: %v", err)
}
for {
c, err := wsDialTLS(hub, "/ws", 8*time.Second, tlsCfg)
if err != nil {
log.Printf("couldn't connect to the hub %s (%v) - retrying in 5s", hub, err)
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
continue continue
} }
if err := authenticate(c, cfg); err != nil { if err := authenticate(c, cfg); err != nil {
c.Close() 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) time.Sleep(5 * time.Second)
continue 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) err = pushLoop(c, cfg)
c.Close() 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) time.Sleep(5 * time.Second)
} }
} }
// authenticate responde el desafio del hub. El token no se manda: se // authenticate answers the hub's challenge. The token itself isn't sent:
// manda un HMAC del desafio hecho con el token. // an HMAC of the challenge, keyed by the token, is sent instead.
func authenticate(c *wsConn, cfg config) error { func authenticate(c *wsConn, cfg config) error {
c.SetReadDeadline(time.Now().Add(authTimeoutSeconds * time.Second)) c.SetReadDeadline(time.Now().Add(authTimeoutSeconds * time.Second))
raw, err := c.ReadMessage() raw, err := c.ReadMessage()
if err != nil { if err != nil {
return fmt.Errorf("no llego el desafio: %w", err) return fmt.Errorf("the challenge never arrived: %w", err)
} }
var ch authChallenge var ch authChallenge
if err := json.Unmarshal(raw, &ch); err != nil || ch.Type != "challenge" || ch.Nonce == "" { 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) == "" { 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{ reply, _ := json.Marshal(authReply{
Type: "auth", Type: "auth",
@@ -376,8 +424,8 @@ func authenticate(c *wsConn, cfg config) error {
} }
func pushLoop(c *wsConn, cfg config) error { func pushLoop(c *wsConn, cfg config) error {
// Un lector en segundo plano: no esperamos mensajes del hub, pero hay // A background reader: we don't expect messages from the hub, but we
// que atender sus pings y enterarnos si corta. // still need to answer its pings and notice if it hangs up.
readErr := make(chan error, 1) readErr := make(chan error, 1)
go func() { go func() {
for { for {
@@ -400,7 +448,7 @@ func pushLoop(c *wsConn, cfg config) error {
msg := peerMessage{ msg := peerMessage{
Type: "state", Type: "state",
ID: cfg.clientID, ID: cfg.clientID,
Name: nombreMostrado(cfg), Name: displayName(cfg),
Deaths: snap.Total, Deaths: snap.Total,
BossFight: snap.BossFight, BossFight: snap.BossFight,
PlayerLoaded: snap.PlayerLoaded, PlayerLoaded: snap.PlayerLoaded,
+98
View File
@@ -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
}
+56
View File
@@ -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)
}
}
}
+265 -206
View File
@@ -1,18 +1,19 @@
//go:build windows //go:build windows
// deathwatch: lee en solo-lectura el contador de muertes de Elden Ring // deathwatch: reads Elden Ring's death counter read-only, straight from
// directamente del proceso (mismo patron de bytes / offset que usa el // the process (the same byte pattern / offset used by LiveSplit's
// script ASL "eldenring_boss_timer.asl" de LiveSplit, ya verificado a // "eldenring_boss_timer.asl" ASL script, verified by hand on this PC).
// mano en esta PC). No escribe nada en la memoria del juego. // Writes nothing to the game's memory.
// //
// Expone: // Exposes:
// //
// GET / -> panel de estado (HTML) // GET / -> status panel (HTML)
// GET /?view=overlay -> version transparente para OBS Browser Source // GET /?view=overlay -> transparent version for OBS Browser Source
// GET /deaths -> {"deaths":N,"players":[...],"character":"...","slot":N,...} // GET /deaths -> {"deaths":N,"players":[...],"character":"...","slot":N,...}
package main package main
import ( import (
"crypto/tls"
_ "embed" _ "embed"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
@@ -59,9 +60,9 @@ var (
procVerQueryValueW = versionDLL.NewProc("VerQueryValueW") procVerQueryValueW = versionDLL.NewProc("VerQueryValueW")
) )
// vsFixedFileInfo es la estructura VS_FIXEDFILEINFO de Windows: la usamos // vsFixedFileInfo is Windows's VS_FIXEDFILEINFO struct: used to pull the
// para sacar la version del juego del propio eldenring.exe, igual que // game's version straight from eldenring.exe, same as SoulMemory (which
// SoulMemory (que lee MainModule.FileVersionInfo.ProductVersion). // reads MainModule.FileVersionInfo.ProductVersion).
type vsFixedFileInfo struct { type vsFixedFileInfo struct {
Signature uint32 Signature uint32
StrucVersion uint32 StrucVersion uint32
@@ -109,7 +110,7 @@ type moduleEntry32 struct {
func findProcessID(name string) (uint32, error) { func findProcessID(name string) (uint32, error) {
snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapProcess), 0) snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapProcess), 0)
if snap == 0 || snap == uintptr(^uintptr(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) defer procCloseHandle.Call(snap)
@@ -117,7 +118,7 @@ func findProcessID(name string) (uint32, error) {
pe.Size = uint32(unsafe.Sizeof(pe)) pe.Size = uint32(unsafe.Sizeof(pe))
r, _, _ := procProcess32FirstW.Call(snap, uintptr(unsafe.Pointer(&pe))) r, _, _ := procProcess32FirstW.Call(snap, uintptr(unsafe.Pointer(&pe)))
if r == 0 { if r == 0 {
return 0, fmt.Errorf("Process32First fallo") return 0, fmt.Errorf("Process32First failed")
} }
for { for {
exe := syscall.UTF16ToString(pe.ExeFile[:]) exe := syscall.UTF16ToString(pe.ExeFile[:])
@@ -129,13 +130,13 @@ func findProcessID(name string) (uint32, error) {
break 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) { func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) {
snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapModule|th32csSnapModule32), uintptr(pid)) snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapModule|th32csSnapModule32), uintptr(pid))
if snap == 0 || snap == uintptr(^uintptr(0)) { 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) defer procCloseHandle.Call(snap)
@@ -143,7 +144,7 @@ func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) {
me.Size = uint32(unsafe.Sizeof(me)) me.Size = uint32(unsafe.Sizeof(me))
r, _, _ := procModule32FirstW.Call(snap, uintptr(unsafe.Pointer(&me))) r, _, _ := procModule32FirstW.Call(snap, uintptr(unsafe.Pointer(&me)))
if r == 0 { if r == 0 {
return 0, 0, "", fmt.Errorf("Module32First fallo") return 0, 0, "", fmt.Errorf("Module32First failed")
} }
for { for {
mname := syscall.UTF16ToString(me.ModuleName[:]) mname := syscall.UTF16ToString(me.ModuleName[:])
@@ -155,13 +156,13 @@ func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) {
break 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 // productVersion reads the game executable's version. label carries both
// dos versiones completas (producto y archivo) porque no siempre // full version numbers (product and file) because they don't always
// coinciden, y sirve para diagnosticar si algun dia hay que ajustar el // match, which helps diagnose whether the PlayerIns offset ever needs
// offset de PlayerIns. // adjusting.
func productVersion(path string) (major, minor uint16, label string, ok bool) { func productVersion(path string) (major, minor uint16, label string, ok bool) {
if path == "" { if path == "" {
return 0, 0, "", false 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 { quad := func(ms, ls uint32) string {
return fmt.Sprintf("%d.%d.%d.%d", ms>>16, ms&0xFFFF, ls>>16, ls&0xFFFF) 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.ProductVersionMS, info.ProductVersionLS),
quad(info.FileVersionMS, info.FileVersionLS)) quad(info.FileVersionMS, info.FileVersionLS))
return uint16(info.ProductVersionMS >> 16), uint16(info.ProductVersionMS & 0xFFFF), label, true return uint16(info.ProductVersionMS >> 16), uint16(info.ProductVersionMS & 0xFFFF), label, true
} }
// playerInsOffsetForVersion replica la tabla de SoulMemory // playerInsOffsetForVersion mirrors SoulMemory's table (InitializeOffsets):
// (InitializeOffsets): hasta 1.06 el offset de PlayerIns dentro de // up to 1.06 the PlayerIns offset inside WorldChrMan is 0x18468, from
// WorldChrMan es 0x18468, de 1.07 en adelante 0x1E508. // 1.07 onward it's 0x1E508.
// //
// OJO: la version que reporta el exe NO es la que muestra el juego en // WATCH OUT: the version the exe reports is NOT what the game shows on
// pantalla (el exe puede decir 2.7.1.0 mientras el juego dice 1.17.1), y // screen (the exe can say 2.7.1.0 while the game says 1.17.1), and
// la tabla de SoulMemory esta escrita con los numeros del juego. Por eso // SoulMemory's table is written with the game's own numbers. So this is
// esto es solo una CORAZONADA para decidir cual probar primero: quien // only a HUNCH for deciding which one to try first: what actually
// decide de verdad es playerInsCandidates + la verificacion en memoria. // decides is playerInsCandidates plus the in-memory verification.
func playerInsOffsetForVersion(major, minor uint16, ok bool) uintptr { func playerInsOffsetForVersion(major, minor uint16, ok bool) uintptr {
if ok && major == 1 && minor <= 6 { if ok && major == 1 && minor <= 6 {
return 0x18468 return 0x18468
@@ -219,8 +220,8 @@ func playerInsOffsetForVersion(major, minor uint16, ok bool) uintptr {
return 0x1E508 return 0x1E508
} }
// playerInsCandidates devuelve los offsets conocidos a probar, con el que // playerInsCandidates returns the known offsets to try, with the one the
// sugiere la version primero. // version suggests listed first.
func playerInsCandidates(major, minor uint16, ok bool) []uintptr { func playerInsCandidates(major, minor uint16, ok bool) []uintptr {
if playerInsOffsetForVersion(major, minor, ok) == 0x18468 { if playerInsOffsetForVersion(major, minor, ok) == 0x18468 {
return []uintptr{0x18468, 0x1E508} return []uintptr{0x18468, 0x1E508}
@@ -251,24 +252,25 @@ func readMemory(h syscall.Handle, addr uintptr, size int) ([]byte, bool) {
// ------------------------- signature scans ------------------------- // ------------------------- signature scans -------------------------
// //
// Dos firmas, las dos con la misma forma: instruccion de 7 bytes // Two signatures, both the same shape: a 7-byte "mov reg,[rip+disp32]"
// "mov reg,[rip+disp32]", donde los 3 primeros bytes son el opcode y los // instruction, where the first 3 bytes are the opcode and the next 4 are
// 4 siguientes el desplazamiento. El slot estatico resuelto contiene el // the displacement. The resolved static slot holds the pointer to the
// puntero al objeto (una dereferencia mas). // object (one more dereference needed).
// //
// GameDataMan -> tiene el contador de muertes (+0x94) y el flag de jefe (+0xC0). // GameDataMan -> holds the death counter (+0x94) and the boss-fight flag
// Mismo patron que usa eldenring_boss_timer.asl (LiveSplit). // (+0xC0). Same pattern eldenring_boss_timer.asl (LiveSplit)
// WorldChrMan -> tiene el puntero a PlayerIns (+playerInsOffset). Si ese // uses.
// puntero es nulo, no hay personaje en el mundo: estas en el // WorldChrMan -> holds the pointer to PlayerIns (+playerInsOffset). If
// menu principal o en una pantalla de carga. Es exactamente // that pointer is null, there's no character in the world:
// lo que hace SoulMemory.IsPlayerLoaded(). // you're at the main menu or on a loading screen. Exactly
// what SoulMemory.IsPlayerLoaded() does.
type patByte struct { type patByte struct {
val byte val byte
wildcard bool wildcard bool
} }
// parsePattern acepta "48 8B 05 ?? ?? ?? ??" (?? = comodin). // parsePattern accepts "48 8B 05 ?? ?? ?? ??" (?? = wildcard).
func parsePattern(s string) []patByte { func parsePattern(s string) []patByte {
var out []patByte var out []patByte
for _, tok := range strings.Fields(s) { for _, tok := range strings.Fields(s) {
@@ -278,7 +280,7 @@ func parsePattern(s string) []patByte {
} }
v, err := strconv.ParseUint(tok, 16, 8) v, err := strconv.ParseUint(tok, 16, 8)
if err != nil { if err != nil {
panic("patron invalido: " + tok) panic("invalid pattern: " + tok)
} }
out = append(out, patByte{val: byte(v)}) out = append(out, patByte{val: byte(v)})
} }
@@ -288,16 +290,16 @@ func parsePattern(s string) []patByte {
var ( var (
// mov rax,[rip+disp32]; test rax,rax; jz +5; mov rax,[rax+58]; ret; ret // 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") 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") 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 // 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") gameManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 80 B8 ?? ?? ?? ?? 0D 0F 94 C0 C3")
) )
// saveSlotOffset: donde GameMan guarda el indice de slot (0-9) de la // saveSlotOffset: where GameMan stores the save-slot index (0-9) of the
// partida cargada. Es la identidad REAL de un personaje: no depende del // loaded game. This is a character's REAL identity: it doesn't depend on
// nombre, asi que dos personajes que se llamen igual no se mezclan. // the name, so two characters sharing a name never mix.
const saveSlotOffset = 0xAC0 const saveSlotOffset = 0xAC0
func matchAt(buf []byte, i int, pattern []patByte) bool { func matchAt(buf []byte, i int, pattern []patByte) bool {
@@ -312,9 +314,9 @@ func matchAt(buf []byte, i int, pattern []patByte) bool {
return true return true
} }
// scanModule busca varios patrones en una sola pasada por el modulo, // scanModule looks for several patterns in a single pass over the module,
// leyendolo en chunks (con solape, por si un patron cae justo en el borde // reading it in chunks (with overlap, in case a pattern straddles a chunk
// de un chunk). Devuelve, por cada patron, la direccion del match o 0. // boundary). Returns, for each pattern, the match address or 0.
func scanModule(h syscall.Handle, base uintptr, size uint32, patterns [][]patByte) []uintptr { func scanModule(h syscall.Handle, base uintptr, size uint32, patterns [][]patByte) []uintptr {
const chunk = 1 << 20 // 1 MiB const chunk = 1 << 20 // 1 MiB
const overlap = 64 const overlap = 64
@@ -350,48 +352,48 @@ func scanModule(h syscall.Handle, base uintptr, size uint32, patterns [][]patByt
return found return found
} }
// ripSlot convierte la direccion de una instruccion "mov reg,[rip+disp32]" // ripSlot turns the address of a 7-byte "mov reg,[rip+disp32]"
// de 7 bytes en la direccion del slot estatico al que apunta. // instruction into the address of the static slot it points to.
func ripSlot(h syscall.Handle, matchAddr uintptr) (uintptr, error) { func ripSlot(h syscall.Handle, matchAddr uintptr) (uintptr, error) {
if matchAddr == 0 { 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) dispBytes, ok := readMemory(h, codeLocation, 4)
if !ok { 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)) disp := int32(binary.LittleEndian.Uint32(dispBytes))
return codeLocation + 4 + uintptr(int64(disp)), nil return codeLocation + 4 + uintptr(int64(disp)), nil
} }
// gamePointers junta todo lo que se resuelve una sola vez por sesion de // gamePointers gathers everything resolved just once per process session:
// proceso: los slots estaticos (que no se mueven) y la version del juego. // the static slots (which don't move) and the game's version.
type gamePointers struct { type gamePointers struct {
gameDataManSlot uintptr gameDataManSlot uintptr
worldChrManSlot uintptr // 0 si no se encontro el patron (seguimos sin el chequeo de menu) worldChrManSlot uintptr // 0 if the pattern wasn't found (we keep going without the menu check)
gameManSlot uintptr // 0 si no se encontro: caemos a identificar por nombre gameManSlot uintptr // 0 if not found: falls back to identifying by name
playerInsOffset uintptr // el que estamos usando (o el candidato preferido) playerInsOffset uintptr // the one currently in use (or the preferred candidate)
playerInsTried []uintptr playerInsTried []uintptr
offsetConfirmed bool // true cuando lo verificamos leyendo memoria de verdad offsetConfirmed bool // true once verified by actually reading memory
versionLabel string versionLabel string
nameChain nameCandidate // como llegamos al nombre del personaje nameChain nameCandidate // how we got to the character's name
nameConfirmed bool nameConfirmed bool
namePending string // candidato a la espera de repetirse (ver resolveCharName) namePending string // candidate waiting to repeat (see resolveCharName)
namePendingOf nameCandidate namePendingOf nameCandidate
} }
// resolvePointers hace los escaneos de firma (caro: recorre todo el // resolvePointers does the signature scans (expensive: walks the whole
// modulo) una sola vez por sesion de proceso. A proposito NO devuelve los // module) just once per process session. Deliberately does NOT return
// objetos ya resueltos: esos punteros se re-leen en cada tick, porque el // the resolved objects themselves: those pointers get re-read every tick,
// juego puede destruir y recrear GameDataMan (por ejemplo al volver al // because the game can destroy and recreate GameDataMan (e.g. going back
// menu principal y cargar de nuevo). Si nos quedaramos con una direccion // to the main menu and loading again). If we cached a stale address,
// vieja en cache, seguiriamos leyendola con exito (la pagina de memoria // we'd keep reading it successfully (the memory page is still valid) but
// sigue siendo valida) pero el contenido pasaria a ser datos de otra cosa // its contents would belong to something else entirely — the most likely
// - la causa mas probable de un contador que "sube solo" sin que hayas // cause of a counter that "goes up on its own" without an actual death.
// muerto en verdad. SoulMemory hace lo mismo: su clase Pointer resuelve la // SoulMemory does the same: its Pointer class resolves the whole chain on
// cadena entera en cada lectura, no cachea la direccion final. // every read, never caching the final address.
func resolvePointers(h syscall.Handle, pid uint32) (gamePointers, error) { func resolvePointers(h syscall.Handle, pid uint32) (gamePointers, error) {
var gp gamePointers var gp gamePointers
@@ -405,30 +407,30 @@ func resolvePointers(h syscall.Handle, pid uint32) (gamePointers, error) {
gp.playerInsOffset = gp.playerInsTried[0] gp.playerInsOffset = gp.playerInsTried[0]
gp.versionLabel = label gp.versionLabel = label
if !okVer { if !okVer {
gp.versionLabel = "desconocida" gp.versionLabel = "unknown"
} }
matches := scanModule(h, base, size, [][]patByte{gameDataManPattern, worldChrManPattern, gameManPattern}) matches := scanModule(h, base, size, [][]patByte{gameDataManPattern, worldChrManPattern, gameManPattern})
gp.gameDataManSlot, err = ripSlot(h, matches[0]) gp.gameDataManSlot, err = ripSlot(h, matches[0])
if err != nil { 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, // WorldChrMan is optional: if it's missing, we keep counting deaths,
// solo perdemos la deteccion de menu/pantalla de carga. // we just lose menu/loading-screen detection.
if slot, werr := ripSlot(h, matches[1]); werr == nil { if slot, werr := ripSlot(h, matches[1]); werr == nil {
gp.worldChrManSlot = slot 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 { if slot, gerr := ripSlot(h, matches[2]); gerr == nil {
gp.gameManSlot = slot gp.gameManSlot = slot
} }
return gp, nil return gp, nil
} }
// derefPointer lee un slot estatico (barato: 8 bytes) y devuelve la // derefPointer reads a static slot (cheap: 8 bytes) and returns the
// direccion ACTUAL del objeto. Se llama en cada tick, no solo una vez. // object's CURRENT address. Called every tick, not just once.
func derefPointer(h syscall.Handle, slot uintptr) (uintptr, bool) { func derefPointer(h syscall.Handle, slot uintptr) (uintptr, bool) {
if slot == 0 { if slot == 0 {
return 0, false return 0, false
@@ -440,30 +442,30 @@ func derefPointer(h syscall.Handle, slot uintptr) (uintptr, bool) {
return uintptr(binary.LittleEndian.Uint64(buf)), true return uintptr(binary.LittleEndian.Uint64(buf)), true
} }
// ------------------------- nombre del personaje ------------------------- // ------------------------- character name -------------------------
// //
// Dato sacado de la tabla de Cheat Engine: "GameDataMan +0C +9C, unicode, // Sourced from a Cheat Engine table: "GameDataMan +0C +9C, unicode,
// largo 19". Esa notacion admite mas de una lectura (¿0x0C es un puntero // length 19". That notation allows more than one reading (is 0x0C a
// que hay que dereferenciar, o los dos offsets se suman?), y ademas la // pointer to dereference, or do the two offsets just add up?), and on
// comunidad/el ASL usan GameDataMan+0x08 para llegar a PlayerGameData. // top of that the community/the ASL use GameDataMan+0x08 to reach
// Asi que no elegimos: probamos las tres y nos quedamos con la que // PlayerGameData. So instead of picking one, all three get tried, and
// devuelva algo que parezca un nombre de verdad. // whichever gives back something that looks like a real name wins.
type nameCandidate struct { type nameCandidate struct {
ptrOffset uintptr // offset donde vive el puntero (0 = sin dereferencia) ptrOffset uintptr // offset where the pointer lives (0 = no dereference)
nameOffset uintptr // offset del texto dentro del objeto nameOffset uintptr // offset of the text within the object
label string label string
} }
var nameCandidates = []nameCandidate{ var nameCandidates = []nameCandidate{
{0x08, 0x9C, "[GameDataMan+0x08]+0x9C (PlayerGameData)"}, {0x08, 0x9C, "[GameDataMan+0x08]+0x9C (PlayerGameData)"},
{0x0C, 0x9C, "[GameDataMan+0x0C]+0x9C"}, {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 // looksLikeName (and charNameMaxChars) live in names.go: they don't
// Windows, asi que quedan afuera de este archivo para poder testearlos // depend on Windows, so they're kept out of this file to be testable
// sin una PC con el juego abierto. // without a PC with the game open.
func readCharName(h syscall.Handle, gameDataMan uintptr, c nameCandidate) (string, bool) { func readCharName(h syscall.Handle, gameDataMan uintptr, c nameCandidate) (string, bool) {
base := gameDataMan base := gameDataMan
@@ -493,10 +495,11 @@ func readCharName(h syscall.Handle, gameDataMan uintptr, c nameCandidate) (strin
return s, true return s, true
} }
// resolveCharName devuelve el nombre del personaje. Antes de fijar una // resolveCharName returns the character's name. Before locking in a
// variante exige verla dar el MISMO texto dos lecturas seguidas: el // variant, it requires seeing it give the SAME text on two readings in a
// nombre real no cambia de un segundo a otro, pero un pedazo de memoria // row: the real name doesn't change from one second to the next, but a
// que casualmente pasa el filtro es mucho menos probable que se repita. // 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) { func resolveCharName(h syscall.Handle, gameDataMan uintptr, gp *gamePointers) (string, bool) {
if gp.nameConfirmed { if gp.nameConfirmed {
return readCharName(h, gameDataMan, gp.nameChain) return readCharName(h, gameDataMan, gp.nameChain)
@@ -510,7 +513,7 @@ func resolveCharName(h syscall.Handle, gameDataMan uintptr, gp *gamePointers) (s
gp.nameChain = c gp.nameChain = c
gp.nameConfirmed = true gp.nameConfirmed = true
gp.namePending = "" 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 return s, true
} }
gp.namePending = s gp.namePending = s
@@ -521,9 +524,9 @@ func resolveCharName(h syscall.Handle, gameDataMan uintptr, gp *gamePointers) (s
return "", false return "", false
} }
// readSaveSlot devuelve el indice de slot (0-9) de la partida cargada, o // readSaveSlot returns the loaded game's slot index (0-9), or -1 if it
// -1 si no lo pudimos leer. Elden Ring tiene 10 slots, asi que cualquier // couldn't be read. Elden Ring has 10 slots, so any other value is
// otro valor es basura y se descarta. // garbage and gets discarded.
func readSaveSlot(h syscall.Handle, gp gamePointers) int { func readSaveSlot(h syscall.Handle, gp gamePointers) int {
if gp.gameManSlot == 0 { if gp.gameManSlot == 0 {
return -1 return -1
@@ -543,15 +546,16 @@ func readSaveSlot(h syscall.Handle, gp gamePointers) int {
return slot return slot
} }
// isPlayerLoaded replica SoulMemory.IsPlayerLoaded(): resuelve // isPlayerLoaded mirrors SoulMemory.IsPlayerLoaded(): resolves
// WorldChrMan y lee el puntero a PlayerIns; si es nulo, no hay personaje // WorldChrMan and reads the pointer to PlayerIns; if it's null, there's
// en el mundo. El segundo valor indica si pudimos evaluarlo. // 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 // Until the offset is confirmed, instead of trusting the version number
// numero de version (que en Elden Ring no coincide con el que muestra el // (which in Elden Ring doesn't match what the game displays), the known
// juego) probamos los offsets conocidos y nos quedamos con el primero que // offsets are tried and whichever one first points at genuinely readable
// apunte a memoria realmente legible. Eso lo decide la maquina, no una // memory wins. That's decided by the machine, not by a table that can
// tabla que puede envejecer mal. // age badly.
func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool) { func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool) {
if gp.worldChrManSlot == 0 { if gp.worldChrManSlot == 0 {
return true, false return true, false
@@ -577,14 +581,14 @@ func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool
if !ok || playerIns == 0 { if !ok || playerIns == 0 {
continue continue
} }
// Un puntero de verdad apunta a memoria mapeada; uno de basura // A real pointer points at mapped memory; a garbage one almost
// casi nunca sobrevive esta lectura. // never survives this read.
if _, ok := readMemory(h, playerIns, 8); !ok { if _, ok := readMemory(h, playerIns, 8); !ok {
continue continue
} }
gp.playerInsOffset = cand gp.playerInsOffset = cand
gp.offsetConfirmed = true 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 true, true
} }
return false, true return false, true
@@ -592,11 +596,11 @@ func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool
// -------------------------------- poller loop -------------------------------- // -------------------------------- poller loop --------------------------------
// maxPlausibleDeltaPerTick: entre dos lecturas separadas por ~1s, con el // maxPlausibleDeltaPerTick: between two readings ~1s apart, with the
// personaje cargado todo el tiempo, el contador de muertes real no puede // character loaded the whole time, the real death counter can't go up by
// subir mas que esto (ni bajar nunca). Un salto mas grande casi siempre // more than this (and never goes down). A bigger jump almost always
// significa que agarramos memoria que ya no es GameDataMan (direccion // means we grabbed memory that's no longer GameDataMan (a stale/invalid
// vieja/invalida) y no una muerte real. // address), not an actual death.
const maxPlausibleDeltaPerTick = 3 const maxPlausibleDeltaPerTick = 3
func pollLoop() { func pollLoop() {
@@ -611,10 +615,10 @@ func pollLoop() {
warnedNoWCM bool warnedNoWCM bool
lastBossRead bool lastBossRead bool
// Vigilancia de la deteccion de menu: mientras creemos que no hay // Menu-detection watchdog: while we believe no character is
// personaje cargado, igual espiamos el contador de muertes. Si sube // loaded, we still peek at the death counter. If it climbs the
// como sube una muerte de verdad, entonces nuestra deteccion esta // way a real death does, our detection is lying (you were
// mintiendo (estabas jugando) y la desactivamos. // actually playing) and we turn it off.
unloadedRaw int32 unloadedRaw int32
unloadedRawFirst int32 unloadedRawFirst int32
haveUnloadedRaw bool haveUnloadedRaw bool
@@ -637,19 +641,19 @@ func pollLoop() {
if handle == 0 { if handle == 0 {
newPid, err := findProcessID(processName) newPid, err := findProcessID(processName)
if err != nil { if err != nil {
st.setDisconnected("esperando a eldenring.exe") st.setDisconnected("waiting for eldenring.exe")
time.Sleep(3 * time.Second) time.Sleep(3 * time.Second)
continue continue
} }
h, err := openProcessHandle(newPid) h, err := openProcessHandle(newPid)
if err != nil { 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) time.Sleep(3 * time.Second)
continue continue
} }
pid = newPid pid = newPid
handle = h handle = h
log.Printf("eldenring.exe encontrado (PID %d), escaneando firmas...", pid) log.Printf("eldenring.exe found (PID %d), scanning signatures...", pid)
} }
if !resolved { if !resolved {
@@ -657,7 +661,7 @@ func pollLoop() {
if err != nil { if err != nil {
st.setDisconnected(err.Error()) st.setDisconnected(err.Error())
time.Sleep(2 * time.Second) 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 { if _, ferr := findProcessID(processName); ferr != nil {
closeHandle() closeHandle()
} }
@@ -666,46 +670,48 @@ func pollLoop() {
gp = p gp = p
resolved = true resolved = true
haveLastRaw = false 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 { 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 { } 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 { 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 { } else if !warnedNoWCM {
warnedNoWCM = true 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;": // Same as LiveSplit's ASL, which does "if (!IsPlayerLoaded) return;":
// sin personaje en el mundo no leemos nada. El total queda congelado en // with no character in the world, nothing gets read. The total
// pantalla (no mostramos guion) para no parpadear en cada carga. // stays frozen on screen (no dash shown) so it doesn't flicker on
// every loading screen.
if loaded, known := isPlayerLoaded(handle, &gp); known && !loaded { if loaded, known := isPlayerLoaded(handle, &gp); known && !loaded {
// Red de seguridad. El offset de PlayerIns depende de la version // Safety net. The PlayerIns offset depends on the game's
// del juego: si algun parche lo mueve, leeriamos nulo para // version: if a patch ever moves it, we'd read null forever
// siempre y el contador quedaria congelado en pleno stream. // and the counter would freeze mid-stream.
// //
// El unico juez confiable es el contador de muertes en si: en el // The only judge we can trust is the death counter itself:
// menu de inicio NO sube nunca. Asi que espiamos el crudo sin // at the start menu it NEVER goes up. So the raw value gets
// usarlo, y si sube como sube una muerte real (+1, +2, +3), // peeked at without being used, and if it climbs the way a
// entonces estabas jugando y nuestra deteccion estaba mintiendo. // real death does (+1, +2, +3), you were actually playing
// A diferencia de un timeout, esto no puede dispararse por dejar // and our detection was lying. Unlike a timeout, this can't
// el juego parado en el menu un rato largo. // fire just from leaving the game sitting at the menu a while.
if gdm, ok := derefPointer(handle, gp.gameDataManSlot); ok && gdm != 0 { if gdm, ok := derefPointer(handle, gp.gameDataManSlot); ok && gdm != 0 {
if buf, ok := readMemory(handle, gdm+0x94, 4); ok { if buf, ok := readMemory(handle, gdm+0x94, 4); ok {
raw := int32(binary.LittleEndian.Uint32(buf)) raw := int32(binary.LittleEndian.Uint32(buf))
if raw >= 0 && raw < 1_000_000 { if raw >= 0 && raw < 1_000_000 {
if haveUnloadedRaw { if haveUnloadedRaw {
if d := raw - unloadedRaw; d >= 1 && d <= maxPlausibleDeltaPerTick { 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 gp.worldChrManSlot = 0
// Rescatamos lo ocurrido durante el rato confundido: // Recover what happened during the confused
// dejamos la referencia en la primera lectura de ese // stretch: keep the reference at that
// periodo para que la logica de "cruce de carga" // period's first reading so the "crossed a
// acredite las muertes si fueron pocas. // loading screen" logic can credit the
// deaths if there weren't many.
lastRaw = unloadedRawFirst lastRaw = unloadedRawFirst
haveLastRaw = true haveLastRaw = true
sawUnloaded = true sawUnloaded = true
@@ -720,26 +726,27 @@ func pollLoop() {
} }
} }
} }
// Ojo: NO tocamos haveLastRaw/lastRaw, justamente para poder // Careful: haveLastRaw/lastRaw are NOT touched, precisely so
// comparar contra la ultima lectura buena cuando vuelva el mundo. // they can be compared against the last good reading once
st.setPlayerUnloaded("menu principal o pantalla de carga") // the world comes back.
st.setPlayerUnloaded("main menu or loading screen")
sawUnloaded = true sawUnloaded = true
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
continue continue
} }
haveUnloadedRaw = false haveUnloadedRaw = false
// Re-dereferenciamos el slot en CADA tick (no solo al conectar) para // Re-dereference the slot on EVERY tick (not just on connect) to
// nunca quedarnos con una direccion vieja de GameDataMan. // never end up stuck with a stale GameDataMan address.
gameDataMan, ok := derefPointer(handle, gp.gameDataManSlot) gameDataMan, ok := derefPointer(handle, gp.gameDataManSlot)
if !ok { 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() closeHandle()
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
continue continue
} }
if gameDataMan == 0 { if gameDataMan == 0 {
st.setPlayerUnloaded("sin partida cargada") st.setPlayerUnloaded("no game loaded")
sawUnloaded = true sawUnloaded = true
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
continue continue
@@ -748,7 +755,7 @@ func pollLoop() {
deathsBuf, ok1 := readMemory(handle, gameDataMan+0x94, 4) deathsBuf, ok1 := readMemory(handle, gameDataMan+0x94, 4)
bossBuf, ok2 := readMemory(handle, gameDataMan+0xC0, 1) bossBuf, ok2 := readMemory(handle, gameDataMan+0xC0, 1)
if !ok1 { 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() closeHandle()
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
continue continue
@@ -760,21 +767,21 @@ func pollLoop() {
lastBossRead = boss lastBossRead = boss
} }
if raw < 0 || raw > 1_000_000 { 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 resolved = false
haveLastRaw = false haveLastRaw = false
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
continue continue
} }
// Dentro de una misma partida el contador no baja ni pega saltos: // Within the same save, the counter never goes down or jumps: if
// si pasa, es memoria que ya no es GameDataMan. Cruzando una carga // it does, it's memory that's no longer GameDataMan. Crossing a
// en cambio puede cambiar a cualquier cosa, porque puede ser otro // load, on the other hand, can change to anything, since it might
// personaje, y de eso se encarga setCharacter. // be a different character — and setCharacter handles that case.
if haveLastRaw && !sawUnloaded { if haveLastRaw && !sawUnloaded {
delta := int64(raw) - int64(lastRaw) delta := int64(raw) - int64(lastRaw)
if delta < 0 || delta > maxPlausibleDeltaPerTick { 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 resolved = false
haveLastRaw = false haveLastRaw = false
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
@@ -782,9 +789,9 @@ func pollLoop() {
} }
} }
// Quien es este personaje se resuelve ANTES de registrar la // Which character this is gets resolved BEFORE recording the
// lectura: si cambiaste de personaje, el total salta al suyo en // reading: if you switched characters, the total jumps to theirs
// esta misma vuelta y no hay que esperar a que alguien muera. // on this very pass, with no need to wait for a death.
name, _ := resolveCharName(handle, gameDataMan, &gp) name, _ := resolveCharName(handle, gameDataMan, &gp)
st.setCharacter(readSaveSlot(handle, gp), name, raw) st.setCharacter(readSaveSlot(handle, gp), name, raw)
@@ -800,9 +807,9 @@ func pollLoop() {
// ---------------------------------- HTTP ---------------------------------- // ---------------------------------- HTTP ----------------------------------
// puertoDe saca el puerto de una direccion tipo "0.0.0.0:47822", para // portOf pulls the port out of an address like "0.0.0.0:47822", so your
// poder decirle al compañero exactamente que escribir en su config. // partner can be told exactly what to put in their config.
func puertoDe(addr string) string { func portOf(addr string) string {
if _, port, ok := strings.Cut(addr, ":"); ok { if _, port, ok := strings.Cut(addr, ":"); ok {
return port return port
} }
@@ -816,15 +823,15 @@ func withCORS(w http.ResponseWriter) {
func main() { func main() {
log.SetFlags(log.Ltime) log.SetFlags(log.Ltime)
log.Println("=== Elden Ring Death Counter (lectura local, solo lectura) ===") log.Println("=== Elden Ring Death Counter (local, read-only) ===")
loadLocales() loadLocales()
cfg := loadConfig() cfg := loadConfig()
writeSampleConfig() writeSampleConfig()
esPeer := cfg.Mode == "peer" isPeer := cfg.Mode == "peer"
lang := resolveLang(cfg.Language) 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(), ", ")) buildTag, os.Getpid(), cfg.Mode, lang, strings.Join(availableLangs(), ", "))
totals = newTotalsStore() totals = newTotalsStore()
@@ -832,47 +839,77 @@ func main() {
go pollLoop() go pollLoop()
registro := newPeerRegistry() registry := newPeerRegistry()
// El token es obligatorio en las dos puntas: sin el, cualquiera que // The token is required on both ends: without it, anyone who can
// alcance el puerto podria inyectar datos en el overlay. // 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 var token string
if !esPeer { var hubCert tls.Certificate
t, generado, err := resolveToken(cfg) var certFingerprint string
if !isPeer {
t, generated, err := resolveToken(cfg)
if err != nil { if err != nil {
log.Fatalf("no pude preparar el token: %v", err) log.Fatalf("couldn't prepare the token: %v", err)
} }
token = t 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 = \"<the right IP>:%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 = \"<your IP>:%s\", token = \"%s\", fingerprint = \"%s\"", peerPort, token, certFingerprint)
}
} }
if esPeer { var peerHub string
if cfg.Hub == "" { if isPeer {
log.Println("¡ojo! modo peer sin 'hub' en el config.toml: no tengo a donde mandar el contador") hub, tok, fp, ok, err := resolvePeerConn(cfg)
} else if strings.TrimSpace(cfg.Token) == "" { if err != nil {
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.") log.Fatalf("peer config problem: %v", err)
}
if ok {
peerHub = hub
go peerLoop(cfg, hub, tok, fp)
} else { } 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 { } else {
registro.declare(cfg.Partner) registry.declare(cfg.Partner)
} }
mux := http.NewServeMux() mux := http.NewServeMux()
if !esPeer {
mux.HandleFunc("/ws", registro.wsHandler(token))
}
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Build", buildTag) w.Header().Set("X-Build", buildTag)
w.Write(overlayHTML) w.Write(overlayHTML)
}) })
// Los textos de la interfaz: la pagina los pide una vez al cargar. // The UI's text: the page requests it once on load. ?lang= lets you
// ?lang= permite forzar un idioma sin tocar el config, comodo para // force a language without touching the config, handy for having the
// tener el overlay en un idioma y el panel en otro. // overlay in one language and the panel in another.
mux.HandleFunc("/strings.json", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/strings.json", func(w http.ResponseWriter, r *http.Request) {
withCORS(w) withCORS(w)
want := lang want := lang
@@ -887,18 +924,18 @@ func main() {
withCORS(w) withCORS(w)
snap := st.snapshot() 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{{ players := []playerView{{
Name: nombreMostrado(cfg), Name: displayName(cfg),
Deaths: snap.Total, Deaths: snap.Total,
BossFight: snap.BossFight, BossFight: snap.BossFight,
PlayerLoaded: snap.PlayerLoaded, PlayerLoaded: snap.PlayerLoaded,
Connected: snap.Connected, Connected: snap.Connected,
Self: true, Self: true,
}} }}
// Modo coop solo cuando hay (o hubo recien) un compañero conectado. // Co-op mode only when a partner is (or was just recently) connected.
if registro.coopMode() { if registry.coopMode() {
players = append(players, registro.views()...) players = append(players, registry.views()...)
} }
var combined int64 var combined int64
@@ -910,8 +947,8 @@ func main() {
"players": players, "players": players,
"combined": combined, "combined": combined,
"build": buildTag, "build": buildTag,
// Campos de la version de un solo jugador: los dejamos para no // Single-player-version fields: kept so nothing already
// romper nada que ya este apuntando aca. // pointing at them breaks.
"deaths": snap.Total, "deaths": snap.Total,
"rawDeaths": snap.RawDeaths, "rawDeaths": snap.RawDeaths,
"character": snap.CharName, "character": snap.CharName,
@@ -925,15 +962,37 @@ func main() {
}) })
log.Printf("Panel: http://%s/", cfg.Listen) log.Printf("Panel: http://%s/", cfg.Listen)
if esPeer { if isPeer {
log.Printf("Mandando el contador al hub %s. Esta ventana tiene que quedar abierta mientras jugás.", cfg.Hub) if peerHub != "" {
} else { log.Printf("Pushing the counter to the hub %s. This window needs to stay open while you play.", peerHub)
log.Printf("OBS URL: http://%s/?view=overlay", cfg.Listen) }
log.Printf("Tu compañero tiene que poner en su config.toml: hub = \"<tu IP>:%s\" y el token de arriba", puertoDe(cfg.Listen)) if err := http.ListenAndServe(cfg.Listen, mux); err != nil {
log.Println("Dejá esta ventana abierta mientras streameás. Ctrl+C para cerrar.") log.Fatalf("couldn't start the local server: %v", err)
}
return
} }
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 { if err := http.ListenAndServe(cfg.Listen, mux); err != nil {
log.Fatalf("no se pudo iniciar el servidor local: %v", err) 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)
} }
} }
+65
View File
@@ -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,
}
}
+125
View File
@@ -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")
}
}
+146
View File
@@ -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
}
+84
View File
@@ -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")
}
}
+46 -24
View File
@@ -1,17 +1,21 @@
// ws.go: implementacion minima de WebSocket (RFC 6455) con la libreria // ws.go: a minimal WebSocket (RFC 6455) implementation on the standard
// estandar, servidor y cliente. No usamos gorilla/websocket para que el // library, server and client. Not using gorilla/websocket so the program
// programa siga siendo un unico .exe sin dependencias que haya que // stays a single .exe with no dependencies to ship or vendor.
// repartir ni vendorear.
// //
// Alcance a proposito acotado a lo que necesitamos: mensajes de texto // Scope is deliberately narrow, just what we need: small text messages,
// chicos, sin fragmentacion, sin compresion, sin TLS (va por Tailscale, // no fragmentation, no compression.
// que ya cifra el tramo entre las dos PCs). //
// 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 package main
import ( import (
"bufio" "bufio"
"crypto/rand" "crypto/rand"
"crypto/sha1" "crypto/sha1"
"crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
@@ -33,13 +37,13 @@ const (
opPing = 0x9 opPing = 0x9
opPong = 0xA 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 { type wsConn struct {
conn net.Conn conn net.Conn
br *bufio.Reader br *bufio.Reader
isClient bool // solo el cliente enmascara, segun el RFC isClient bool // only the client masks, per the RFC
wmu sync.Mutex wmu sync.Mutex
closed bool closed bool
} }
@@ -50,20 +54,20 @@ func wsAcceptKey(key string) string {
return base64.StdEncoding.EncodeToString(h.Sum(nil)) return base64.StdEncoding.EncodeToString(h.Sum(nil))
} }
// wsUpgrade convierte una peticion HTTP entrante en una conexion // wsUpgrade turns an incoming HTTP request into a WebSocket connection
// WebSocket (lado servidor). // (server side).
func wsUpgrade(w http.ResponseWriter, r *http.Request) (*wsConn, error) { func wsUpgrade(w http.ResponseWriter, r *http.Request) (*wsConn, error) {
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") ||
!strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { !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") key := r.Header.Get("Sec-WebSocket-Key")
if 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) hj, ok := w.(http.Hijacker)
if !ok { 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() conn, brw, err := hj.Hijack()
if err != nil { 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 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) { func wsDial(addr, path string, timeout time.Duration) (*wsConn, error) {
conn, err := net.DialTimeout("tcp", addr, timeout) conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil { if err != nil {
return nil, err 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 var keyBytes [16]byte
if _, err := rand.Read(keyBytes[:]); err != nil { if _, err := rand.Read(keyBytes[:]); err != nil {
conn.Close() conn.Close()
@@ -114,11 +136,11 @@ func wsDial(addr, path string, timeout time.Duration) (*wsConn, error) {
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != http.StatusSwitchingProtocols { if resp.StatusCode != http.StatusSwitchingProtocols {
conn.Close() 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)) { if !strings.EqualFold(resp.Header.Get("Sec-WebSocket-Accept"), wsAcceptKey(key)) {
conn.Close() 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{}) conn.SetDeadline(time.Time{})
return &wsConn{conn: conn, br: br, isClient: true}, nil 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[:]) v := binary.BigEndian.Uint64(ext[:])
if v > maxFrameSize { if v > maxFrameSize {
err = fmt.Errorf("frame demasiado grande (%d bytes)", v) err = fmt.Errorf("frame too large (%d bytes)", v)
return return
} }
n = int(v) n = int(v)
} }
if n > maxFrameSize { if n > maxFrameSize {
err = fmt.Errorf("frame demasiado grande (%d bytes)", n) err = fmt.Errorf("frame too large (%d bytes)", n)
return return
} }
@@ -229,13 +251,13 @@ func (c *wsConn) readFrame() (opcode byte, payload []byte, err error) {
} }
} }
if !fin || opcode == opContinuation { if !fin || opcode == opContinuation {
err = fmt.Errorf("frames fragmentados no soportados") err = fmt.Errorf("fragmented frames aren't supported")
} }
return return
} }
// ReadMessage devuelve el proximo mensaje de texto/binario, respondiendo // ReadMessage returns the next text/binary message, answering pings
// los pings por dentro. Un close del otro lado se reporta como io.EOF. // internally along the way. A close from the other side reports as io.EOF.
func (c *wsConn) ReadMessage() ([]byte, error) { func (c *wsConn) ReadMessage() ([]byte, error) {
for { for {
op, payload, err := c.readFrame() op, payload, err := c.readFrame()
@@ -250,12 +272,12 @@ func (c *wsConn) ReadMessage() ([]byte, error) {
return nil, err return nil, err
} }
case opPong: case opPong:
// nada que hacer // nothing to do
case opClose: case opClose:
c.writeFrame(opClose, nil) c.writeFrame(opClose, nil)
return nil, io.EOF return nil, io.EOF
default: default:
return nil, fmt.Errorf("opcode desconocido: 0x%X", op) return nil, fmt.Errorf("unknown opcode: 0x%X", op)
} }
} }
} }