Add TLS + certificate pinning for the peer link
The co-op link was authenticated (HMAC token, never sent over the wire) but not encrypted. The hub now generates a self-signed cert on first run; the peer pins its exact fingerprint (no CA involved — there isn't one for a Tailscale/LAN address), delivered via a single invite-code paste that also carries the token, replacing today's separate IP+token copy. The peer link moves to its own TLS-only port (peer_listen, 47823) so the plain overlay/panel port (47822, OBS-facing) never needs to be exposed alongside it — today, opening the overlay port to a remote partner also exposes /deaths and the panel to anyone. Mandatory pinning, no insecure fallback: a half-configured peer (some but not all of hub/token/fingerprint, or a broken invite) fails loudly at startup rather than connecting unpinned. An unconfigured peer still runs fine as a local-only overlay, same as before. New: tlscert.go (cert generation/persistence), pin.go (fingerprint pinning), invite.go (invite-code encode/decode, host auto-detection), each with tests. main.go/config.go/duo.go/ws.go carry the wiring for this — the dual listener, new config keys, and the TLS-aware WebSocket dial — and were rewritten in English in the process, per the project's new English-only code convention (see CLAUDE.md).
This commit is contained in:
1 parent
e1f7e6f529
commit
e9fe10f0f7
12 files changed
+1198
-465
No files matched your search
@@ -1,17 +1,21 @@
|
||||
// ws.go: implementacion minima de WebSocket (RFC 6455) con la libreria
|
||||
// estandar, servidor y cliente. No usamos gorilla/websocket para que el
|
||||
// programa siga siendo un unico .exe sin dependencias que haya que
|
||||
// repartir ni vendorear.
|
||||
// ws.go: a minimal WebSocket (RFC 6455) implementation on the standard
|
||||
// library, server and client. Not using gorilla/websocket so the program
|
||||
// stays a single .exe with no dependencies to ship or vendor.
|
||||
//
|
||||
// Alcance a proposito acotado a lo que necesitamos: mensajes de texto
|
||||
// chicos, sin fragmentacion, sin compresion, sin TLS (va por Tailscale,
|
||||
// que ya cifra el tramo entre las dos PCs).
|
||||
// Scope is deliberately narrow, just what we need: small text messages,
|
||||
// no fragmentation, no compression.
|
||||
//
|
||||
// TLS is handled one layer up, not in here: wsUpgrade doesn't need to
|
||||
// know about it (it terminates at the http.Server/listener level), and
|
||||
// wsDialTLS just runs the same client handshake over a *tls.Conn instead
|
||||
// of a plain one. See pin.go for certificate pinning.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
@@ -33,13 +37,13 @@ const (
|
||||
opPing = 0x9
|
||||
opPong = 0xA
|
||||
|
||||
maxFrameSize = 1 << 20 // 1 MiB: nuestros mensajes son de ~100 bytes
|
||||
maxFrameSize = 1 << 20 // 1 MiB: our messages run ~100 bytes
|
||||
)
|
||||
|
||||
type wsConn struct {
|
||||
conn net.Conn
|
||||
br *bufio.Reader
|
||||
isClient bool // solo el cliente enmascara, segun el RFC
|
||||
isClient bool // only the client masks, per the RFC
|
||||
wmu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
@@ -50,20 +54,20 @@ func wsAcceptKey(key string) string {
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// wsUpgrade convierte una peticion HTTP entrante en una conexion
|
||||
// WebSocket (lado servidor).
|
||||
// wsUpgrade turns an incoming HTTP request into a WebSocket connection
|
||||
// (server side).
|
||||
func wsUpgrade(w http.ResponseWriter, r *http.Request) (*wsConn, error) {
|
||||
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") ||
|
||||
!strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
|
||||
return nil, fmt.Errorf("no es un upgrade a websocket")
|
||||
return nil, fmt.Errorf("not a websocket upgrade")
|
||||
}
|
||||
key := r.Header.Get("Sec-WebSocket-Key")
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("falta la cabecera Sec-WebSocket-Key")
|
||||
return nil, fmt.Errorf("missing the Sec-WebSocket-Key header")
|
||||
}
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("este servidor no soporta hijack")
|
||||
return nil, fmt.Errorf("this server doesn't support hijack")
|
||||
}
|
||||
conn, brw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
@@ -80,12 +84,30 @@ func wsUpgrade(w http.ResponseWriter, r *http.Request) (*wsConn, error) {
|
||||
return &wsConn{conn: conn, br: brw.Reader}, nil
|
||||
}
|
||||
|
||||
// wsDial abre una conexion WebSocket contra un hub (lado cliente).
|
||||
// wsDial opens a WebSocket connection to a hub (client side).
|
||||
func wsDial(addr, path string, timeout time.Duration) (*wsConn, error) {
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wsHandshake(conn, addr, path, timeout)
|
||||
}
|
||||
|
||||
// wsDialTLS is wsDial over an encrypted connection: same handshake, dialed
|
||||
// through tlsCfg instead of a plain net.Dial. The TLS handshake itself
|
||||
// (including certificate verification, e.g. pinning — see pin.go) happens
|
||||
// inside tls.DialWithDialer before the WebSocket upgrade is attempted.
|
||||
func wsDialTLS(addr, path string, timeout time.Duration, tlsCfg *tls.Config) (*wsConn, error) {
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wsHandshake(conn, addr, path, timeout)
|
||||
}
|
||||
|
||||
// wsHandshake does the WebSocket upgrade handshake (client side) over an
|
||||
// already-established connection, plain or TLS — both satisfy net.Conn.
|
||||
func wsHandshake(conn net.Conn, addr, path string, timeout time.Duration) (*wsConn, error) {
|
||||
var keyBytes [16]byte
|
||||
if _, err := rand.Read(keyBytes[:]); err != nil {
|
||||
conn.Close()
|
||||
@@ -114,11 +136,11 @@ func wsDial(addr, path string, timeout time.Duration) (*wsConn, error) {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("el hub respondio %s (esperaba 101)", resp.Status)
|
||||
return nil, fmt.Errorf("the hub replied %s (expected 101)", resp.Status)
|
||||
}
|
||||
if !strings.EqualFold(resp.Header.Get("Sec-WebSocket-Accept"), wsAcceptKey(key)) {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("el handshake no valida (¿del otro lado hay un websocket?)")
|
||||
return nil, fmt.Errorf("the handshake doesn't validate (is there really a websocket on the other end?)")
|
||||
}
|
||||
conn.SetDeadline(time.Time{})
|
||||
return &wsConn{conn: conn, br: br, isClient: true}, nil
|
||||
@@ -201,13 +223,13 @@ func (c *wsConn) readFrame() (opcode byte, payload []byte, err error) {
|
||||
}
|
||||
v := binary.BigEndian.Uint64(ext[:])
|
||||
if v > maxFrameSize {
|
||||
err = fmt.Errorf("frame demasiado grande (%d bytes)", v)
|
||||
err = fmt.Errorf("frame too large (%d bytes)", v)
|
||||
return
|
||||
}
|
||||
n = int(v)
|
||||
}
|
||||
if n > maxFrameSize {
|
||||
err = fmt.Errorf("frame demasiado grande (%d bytes)", n)
|
||||
err = fmt.Errorf("frame too large (%d bytes)", n)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -229,13 +251,13 @@ func (c *wsConn) readFrame() (opcode byte, payload []byte, err error) {
|
||||
}
|
||||
}
|
||||
if !fin || opcode == opContinuation {
|
||||
err = fmt.Errorf("frames fragmentados no soportados")
|
||||
err = fmt.Errorf("fragmented frames aren't supported")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ReadMessage devuelve el proximo mensaje de texto/binario, respondiendo
|
||||
// los pings por dentro. Un close del otro lado se reporta como io.EOF.
|
||||
// ReadMessage returns the next text/binary message, answering pings
|
||||
// internally along the way. A close from the other side reports as io.EOF.
|
||||
func (c *wsConn) ReadMessage() ([]byte, error) {
|
||||
for {
|
||||
op, payload, err := c.readFrame()
|
||||
@@ -250,12 +272,12 @@ func (c *wsConn) ReadMessage() ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
case opPong:
|
||||
// nada que hacer
|
||||
// nothing to do
|
||||
case opClose:
|
||||
c.writeFrame(opClose, nil)
|
||||
return nil, io.EOF
|
||||
default:
|
||||
return nil, fmt.Errorf("opcode desconocido: 0x%X", op)
|
||||
return nil, fmt.Errorf("unknown opcode: 0x%X", op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user