Librería P2P mínima en Go, construida sobre go-libp2p pero envuelta en una superficie diminuta: la App solo maneja seis primitivas.
La librería solo mueve bytes y encuentra peers; archivos, chunks, integridad y
reintentos son responsabilidad de la App. Identidad criptográfica persistente, DHT
Kademlia auto-organizada, transporte QUIC autenticado y redes aisladas por NetworkID.
El objetivo: correr un nodo sin configuración y que la red se mantenga sola.
El diseño y el porqué de cada decisión están en
docs/diseno-red-p2p.md, que es el documento canónico.
node, _ := p2plite.New(ctx, p2plite.Config{}) // construye (defaults horneados)
// El handler va ANTES de Start: después, un stream podría llegar antes que él.
node.SetStreamHandler(func(from p2plite.PeerID, s p2plite.Stream) { /* … */ })
node.Start(ctx) // escucha y entra a la red
node.Announce(ctx, "mi-recurso") // publico que tengo algo (y se mantiene)
node.StopAnnounce("mi-recurso") // dejo de publicarlo
ch, err := node.FindProviders(ctx, "mi-recurso") // descubro quién lo tiene
node.Connect(ctx, peerID) // conecto (opcional: OpenStream ya lo hace)
s, err := node.OpenStream(ctx, peerID) // le abro un stream (bytes opacos)Announce se mantiene solo: republica en segundo plano hasta StopAnnounce o Close,
porque los provider records de la DHT caducan.
FindProviders no tiene límite de resultados — cancela el ctx cuando tengas bastantes.
Su error es un fallo de nodo (DHT caída, nodo parado); "cero proveedores" se señala
cerrando el canal sin emitir nada.
La librería no escribe nada salvo que le pases un Logger. Fiel a que no se apropia
de la salida de la App: tú decides si quieres diagnósticos y a dónde van.
p2plite.New(ctx, p2plite.Config{Logger: log.Default()}) // stdlib log → stderrLogger es una interfaz de un solo método (Printf(string, ...any)) que *log.Logger
de la stdlib satisface tal cual; slog o zap se adaptan con un envoltorio de una línea.
| Clase | De dónde sale | Qué hacer |
|---|---|---|
| Fallo de nodo | error devuelto por un método de Node |
Raro y fatal: reiniciar |
| Fallo de stream | error del Stream al leer/escribir |
Normal: cerrar y seguir |
Los de nodo son ErrNotStarted, ErrClosed, ErrDHTNotReady, ErrNoAddrs.
node.ConnKind(peerID) // direct | hole-punched | relayed | none — el escalón
node.ConnIP(peerID) // ipv6 | ipv4 | "" — la familiaLa escalera de conectividad es automática y no se configura, pero sí se puede mirar.
relayed estable en un peer significa que la perforación falló y esa conexión está a
punto de morir por los límites de Circuit Relay v2. ConnIP distingue el escalón 1
(IPv6, sin NAT) del 2 (IPv4): direct ipv6 es el caso ideal, hole-punched ipv4 es
NAT perforado.
Para verlo con detalle, libp2p lo loguea:
GOLOG_LOG_LEVEL="holepunch=debug,autonat=info" ./node -find holaquic-go v0.60 (que arrastra go-libp2p) todavía no soporta Go 1.26: compilado con
1.26 panica en el handshake (crypto/tls bug: where's my session ticket?). Hay que
compilar con Go 1.25.7. El toolchain go1.25.7 del go.mod no basta si tienes 1.26
instalado; fuérzalo con la variable de entorno:
GOTOOLCHAIN=go1.25.7 go build ./...El script build.sh ya lo hace y cross-compila todo:
./build.sh # genera dist/seed-linux-amd64, dist/node-windows-amd64.exe, dist/node-linux-amd64(Cuando quic-go publique soporte de Go 1.26, se podrá subir la versión y quitar este pin.)
GOTOOLCHAIN=go1.25.7 go test -short ./... # unidad + ciclo de vida (rápido, sin red)
GOTOOLCHAIN=go1.25.7 go test ./... # + integración announce→find→stream localEl suite rápido no toca la red y corre en menos de un segundo. La integración levanta
una semilla y dos nodos en localhost; se salta con -short. El detector de carreras
(-race) necesita cgo/gcc, así que va en CI (Linux), no en el Windows de desarrollo.
- Compila todo:
./build.sh. - Abre el puerto UDP 4001 de entrada en tu servidor. Es UDP, no TCP: QUIC va sobre UDP y que el TCP del mismo número responda no sirve de nada. Hay que abrirlo tanto en el firewall del proveedor (IONOS, AWS…) como en el del sistema (ufw/firewalld).
- Arranca la semilla. Para probar rápido, a mano:
En su primer arranque imprime su PeerID y sus multiaddrs. Para dejarla en producción, instálala como servicio endurecido (usuario sin privilegios + sandbox de systemd): ver
./seed-linux-amd64 -datadir /var/lib/p2plite -port 4001
deploy/. - Hornea la multiaddr con la IP pública del servidor en
DefaultBootstrap(enbootstrap.go) y recompila:./build.sh. - A partir de aquí, los nodos entran a la red sin flags, y los servidores nuevos
se añaden solos vía
EntryKeysin recompilar a nadie.
Para usar
/dns4/<nombre>en vez de la IP hace falta un registro A en modo DNS-only (nube gris): un proxy tipo Cloudflare no pasa UDP/QUIC.
Empujar un tag vX.Y.Z dispara el workflow de release,
que compila los binarios y publica un GitHub Release con ellos y un SHA256SUMS:
git tag v0.1.0 && git push origin v0.1.0Quien descargue verifica con sha256sum -c SHA256SUMS --ignore-missing.
¿Quieres sostener la red con un seed propio? Ver deploy/RUN-A-SEED.md: cualquiera con un servidor público puede correr un seed y se une solo.
./node-windows-amd64.exe # solo unirse a la red y mantenerse
./node-windows-amd64.exe -announce hola # publicar "hola" y servir a quien lo pida
./node-windows-amd64.exe -find hola -msg ping # buscar "hola", conectar al proveedor y enviarLas flags son overrides opcionales para pruebas (-bootstrap, -datadir, -port,
-network); nunca obligatorias.
-version imprime qué build corre (útil para saber qué máquina tiene el fix):
./node-linux-amd64 -version # p2plite-node <git describe> (go1.25.7)La versión sale de git describe --tags --always --dirty al compilar con build.sh:
sin tags es el SHA corto; márcalos (git tag v0.1.0) para versiones legibles.
El flujo objetivo funciona de punta a punta:
casa: node -announce hola → anuncia el recurso en la DHT (vía la semilla)
trabajo: node -find hola -msg "..." → descubre a casa, conecta y envía el mensaje
← "recibido por <casa>: ..." (respuesta de casa)
✓ descubrimiento + conexión + mensaje ida y vuelta
| Ruta | Qué es |
|---|---|
docs/diseno-red-p2p.md |
El diseño canónico: las decisiones y su porqué |
p2plite.go |
La librería: las seis primitivas + identidad + host libp2p + DHT |
bootstrap.go |
Valores por defecto horneados (semilla, red, puerto, datadir, EntryKey) |
cmd/seed |
Nodo semilla siempre-activo (DHT server + relay de handshake) |
cmd/node |
Nodo de la App (casa/trabajo), zero-config |
build.sh |
Cross-compila forzando Go 1.25.7 |
- Sin relay de datos. El relay solo coordina el hole punching (DCUtR) y se sale; lo garantizan los límites por defecto de Circuit Relay v2 (128 KB / 2 min). Dos peers que ambos fallen la perforación (NAT simétrico, CGNAT) no se conectan, y es un trade-off aceptado a propósito.
- El descubrimiento y almacenamiento DHT dependen de que haya al menos una entrada viva.
- IPv6: el transporte ya escucha en IPv6; en una red IPv6 nativa la conexión directa es el caso normal y no hace falta perforar casi nunca.