Add TLS + certificate pinning for the peer link
The co-op link was authenticated (HMAC token, never sent over the wire) but not encrypted. The hub now generates a self-signed cert on first run; the peer pins its exact fingerprint (no CA involved — there isn't one for a Tailscale/LAN address), delivered via a single invite-code paste that also carries the token, replacing today's separate IP+token copy. The peer link moves to its own TLS-only port (peer_listen, 47823) so the plain overlay/panel port (47822, OBS-facing) never needs to be exposed alongside it — today, opening the overlay port to a remote partner also exposes /deaths and the panel to anyone. Mandatory pinning, no insecure fallback: a half-configured peer (some but not all of hub/token/fingerprint, or a broken invite) fails loudly at startup rather than connecting unpinned. An unconfigured peer still runs fine as a local-only overlay, same as before. New: tlscert.go (cert generation/persistence), pin.go (fingerprint pinning), invite.go (invite-code encode/decode, host auto-detection), each with tests. main.go/config.go/duo.go/ws.go carry the wiring for this — the dual listener, new config keys, and the TLS-aware WebSocket dial — and were rewritten in English in the process, per the project's new English-only code convention (see CLAUDE.md).
This commit is contained in:
1 parent
e1f7e6f529
commit
e9fe10f0f7
12 files changed
+1195
-462
No files matched your search
@@ -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
|
||||||
@@ -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.
|
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user