Foro en Tiempo Real por CLI en Go: TCP, TLS y Cero Dependencias
Go

Foro en Tiempo Real por CLI en Go: TCP, TLS y Cero Dependencias

Construye paso a paso un foro de chat en tiempo real 100% CLI en Go: TCP, TLS, broadcast sin duplicados por UUID y reconexión resiliente, cero dependencias.

Por Omar Flores
#go #golang #tcp #networking #concurrency #goroutines #channels #tls #cli #real-time #resilience #project-tutorial

Un Foro, No un Chat Privado

Vamos a construir algo distinto a la típica app de notas: un foro comunal en tiempo real, por terminal. Cualquiera que se conecte ve todos los mensajes de todos. No hay salas privadas, no hay mensajes directos, no hay base de datos — cuando apagas el servidor, la conversación desaparece con él. Es, a propósito, tan simple como una plaza pública.

Pero “simple” en el resultado no significa “simple” en la construcción. Un foro en tiempo real bien hecho tiene que resolver problemas reales de sistemas concurrentes: ¿qué pasa si dos personas se conectan con el mismo nombre? ¿Qué pasa si un cliente es tan lento que bloquea a todos los demás? ¿Qué pasa si la red se cae a medias de un mensaje? ¿Cómo evitas que ese mismo mensaje se duplique si el cliente reintenta?

Esta guía responde cada una de esas preguntas con código, no con teoría. Al final tendrás un servidor (forumd) y un cliente de terminal (forum), comunicándose por TCP con TLS, sin un solo paquete externo — cero dependencias, toda la librería estándar de Go.


Qué Vamos a Construir

  • Un foro general: todo mensaje se transmite (broadcast) a todos los conectados
  • Cada mensaje lleva un UUID generado por el cliente — el servidor descarta duplicados
  • Nombres de usuario únicos, resueltos automáticamente si hay colisión
  • Transporte cifrado con TLS 1.3, verificación de certificado real (nada de InsecureSkipVerify)
  • Cliente resiliente: reconexión automática con backoff exponencial y jitter
  • Servidor resiliente: límite de tasa por cliente, desconexión de clientes lentos, heartbeats
  • Cero persistencia — todo vive en memoria, todo se pierde al apagar el servidor
  • Cero dependencias externas — sin go.sum, solo librería estándar
  • 100% CLI: ni HTTP, ni navegador, ni WebSocket — sockets TCP puros
go version
# go version go1.27.1 linux/amd64 (o superior)

Arquitectura: Un Hub, Sin Mutex

La pieza central es el Hub: una única goroutine que es la única dueña del estado — la lista de clientes conectados, los nombres en uso, los IDs de mensajes ya vistos. Nadie más toca ese estado directamente. Todo el mundo le habla al Hub a través de canales.

Esto no es una elección estética. Es la aplicación literal del lema de Go: no comuniques compartiendo memoria, comparte memoria comunicando. Como solo una goroutine lee y escribe los mapas internos, no hace falta ni un sync.Mutex en todo el proyecto.

graph LR
    subgraph "Clientes"
        C1["forum CLI<br/>alice"]
        C2["forum CLI<br/>bob"]
        C3["forum CLI<br/>carol"]
    end

    subgraph "forumd"
        L["Listener TLS"]
        H["Hub<br/>una sola goroutine<br/>dueña de todo el estado"]
        D1["conexión alice<br/>readPump / writePump"]
        D2["conexión bob<br/>readPump / writePump"]
        D3["conexión carol<br/>readPump / writePump"]
    end

    C1 <-->|"TCP + TLS"| L
    C2 <-->|"TCP + TLS"| L
    C3 <-->|"TCP + TLS"| L
    L --> D1
    L --> D2
    L --> D3
    D1 <-->|"canales"| H
    D2 <-->|"canales"| H
    D3 <-->|"canales"| H

Cada conexión entrante corre en su propia goroutine, dividida en dos mitades clásicas: readPump (lee del socket, valida, envía al Hub) y writePump (recibe del Hub, escribe al socket). El Hub es el único punto de encuentro entre todas ellas.


Preparando el Proyecto

mkdir forum && cd forum
go mod init forum

No vas a instalar nada más. En serio — nada de go get. Todo lo que necesitas ya está en la librería estándar: net, crypto/tls, bufio, encoding/json, context, log/slog.

mkdir -p cmd/forumd cmd/forum
mkdir -p internal/chat internal/server internal/client
mkdir -p certs

Árbol final:

forum/
├── cmd/
│   ├── forumd/
│   │   └── main.go
│   └── forum/
│       └── main.go
├── internal/
│   ├── chat/
│   │   ├── message.go
│   │   ├── id.go
│   │   └── message_test.go
│   ├── server/
│   │   ├── hub.go
│   │   ├── client.go
│   │   ├── limiter.go
│   │   └── hub_test.go
│   └── client/
│       └── session.go
├── certs/
│   ├── server.crt
│   └── server.key
├── go.mod
├── Dockerfile
├── docker-compose.yml
└── Makefile

Fíjate: no hay go.sum en este árbol. Sin dependencias externas, no hay nada que sumar.


Paso 1: El Protocolo — Mensajes por Línea

Antes de escribir un socket, define cómo hablan cliente y servidor entre sí. Vamos a usar JSON delimitado por saltos de línea (NDJSON): cada mensaje es un objeto JSON en una sola línea, terminado en \n. Es depurable a simple vista, no requiere un framing binario propio, y bufio.Scanner lo parte solo.

internal/chat/message.go

package chat

import (
	"encoding/json"
	"time"
)

type Kind string

const (
	KindHello   Kind = "hello"
	KindWelcome Kind = "welcome"
	KindMessage Kind = "message"
	KindSystem  Kind = "system"
	KindPing    Kind = "ping"
	KindPong    Kind = "pong"
)

const MaxBodyBytes = 2000

type Envelope struct {
	Kind   Kind      `json:"kind"`
	ID     string    `json:"id,omitempty"`
	From   string    `json:"from,omitempty"`
	Body   string    `json:"body,omitempty"`
	SentAt time.Time `json:"sent_at"`
}

func Encode(e Envelope) ([]byte, error) {
	b, err := json.Marshal(e)
	if err != nil {
		return nil, err
	}
	return append(b, '\n'), nil
}

func Decode(line []byte) (Envelope, error) {
	var e Envelope
	err := json.Unmarshal(line, &e)
	return e, err
}

Seis tipos de mensaje (Kind) cubren todo el protocolo: hello (el cliente se presenta), welcome (el servidor confirma el nombre asignado), message (chat real), system (avisos de entrada/salida), y ping/pong (el latido que mantiene viva la conexión).

internal/chat/id.go

package chat

import (
	"crypto/rand"
	"fmt"
)

func NewID() string {
	var b [16]byte
	_, _ = rand.Read(b[:])

	b[6] = (b[6] & 0x0f) | 0x40
	b[8] = (b[8] & 0x3f) | 0x80

	return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}

Un UUID v4 real, generado con crypto/rand, en 12 líneas — sin traer github.com/google/uuid. Un UUID v4 son 16 bytes aleatorios con seis bits fijos (versión y variante); eso es literalmente todo lo que hace esta función.

internal/chat/message_test.go

package chat

import "testing"

func TestEncodeDecodeRoundTrip(t *testing.T) {
	original := Envelope{Kind: KindMessage, ID: NewID(), From: "omar", Body: "hola foro"}

	raw, err := Encode(original)
	if err != nil {
		t.Fatalf("Encode() error = %v", err)
	}

	decoded, err := Decode(raw)
	if err != nil {
		t.Fatalf("Decode() error = %v", err)
	}

	if decoded.ID != original.ID || decoded.Body != original.Body {
		t.Fatalf("round trip mismatch: got %+v, want %+v", decoded, original)
	}
}

func TestNewIDIsUnique(t *testing.T) {
	seen := make(map[string]bool)

	for range 1000 {
		id := NewID()
		if seen[id] {
			t.Fatalf("duplicate ID generated: %s", id)
		}
		seen[id] = true
	}
}

for range 1000 — Go moderno de verdad: iterar sobre un entero sin declarar variable de índice, disponible desde Go 1.22.


Paso 2: El Hub — El Corazón del Servidor

El Hub no sabe nada de TCP. No sabe nada de TLS. Solo conoce clientes abstractos con un nombre y un canal de salida. Esa separación es la que permite razonar sobre él sin pensar en sockets.

internal/server/hub.go

package server

import (
	"fmt"
	"log/slog"
	"time"

	"forum/internal/chat"
)

type joinRequest struct {
	desired string
	client  *client
	done    chan struct{}
}

type Hub struct {
	joins     chan joinRequest
	leaves    chan *client
	broadcast chan chat.Envelope
	clients   map[*client]struct{}
	names     map[string]struct{}
	seen      map[string]time.Time
}

func NewHub() *Hub {
	return &Hub{
		joins:     make(chan joinRequest),
		leaves:    make(chan *client),
		broadcast: make(chan chat.Envelope, 256),
		clients:   make(map[*client]struct{}),
		names:     make(map[string]struct{}),
		seen:      make(map[string]time.Time),
	}
}

func (h *Hub) Join(desired string) *client {
	c := &client{id: chat.NewID(), send: make(chan chat.Envelope, 16)}
	req := joinRequest{desired: desired, client: c, done: make(chan struct{})}
	h.joins <- req
	<-req.done
	return c
}

func (h *Hub) Leave(c *client) {
	h.leaves <- c
}

func (h *Hub) Broadcast(env chat.Envelope) {
	h.broadcast <- env
}

func (h *Hub) Run() {
	cleanup := time.NewTicker(time.Minute)
	defer cleanup.Stop()

	for {
		select {
		case req := <-h.joins:
			username := h.uniqueName(req.desired)
			req.client.username = username
			h.clients[req.client] = struct{}{}
			h.names[username] = struct{}{}
			close(req.done)

			slog.Info("client joined", "username", username, "id", req.client.id)
			h.deliver(chat.Envelope{
				Kind:   chat.KindSystem,
				Body:   username + " joined the forum",
				SentAt: time.Now().UTC(),
			})

		case c := <-h.leaves:
			if _, ok := h.clients[c]; ok {
				delete(h.clients, c)
				delete(h.names, c.username)
				close(c.send)

				slog.Info("client left", "username", c.username, "id", c.id)
				h.deliver(chat.Envelope{
					Kind:   chat.KindSystem,
					Body:   c.username + " left the forum",
					SentAt: time.Now().UTC(),
				})
			}

		case env := <-h.broadcast:
			if env.ID != "" {
				if _, dup := h.seen[env.ID]; dup {
					continue
				}
				h.seen[env.ID] = time.Now()
			}
			h.deliver(env)

		case now := <-cleanup.C:
			for id, at := range h.seen {
				if now.Sub(at) > 5*time.Minute {
					delete(h.seen, id)
				}
			}
		}
	}
}

func (h *Hub) deliver(env chat.Envelope) {
	for c := range h.clients {
		select {
		case c.send <- env:
		default:
			slog.Warn("dropping slow client", "username", c.username)
			delete(h.clients, c)
			delete(h.names, c.username)
			close(c.send)
		}
	}
}

func (h *Hub) uniqueName(base string) string {
	name := base
	for n := 2; ; n++ {
		if _, taken := h.names[name]; !taken {
			return name
		}
		name = fmt.Sprintf("%s-%d", base, n)
	}
}

Cuatro decisiones que sostienen todo lo demás:

  • Join es una petición, no una orden. Envía un joinRequest con un canal done y espera. Eso obliga a que la asignación del nombre único ocurra dentro del Run() de la Hub, serializada, sin ninguna carrera posible aunque dos personas se conecten con el mismo nombre en el mismo microsegundo.
  • La deduplicación vive en el mismo select. Cada Envelope que llega a h.broadcast se compara contra h.seen antes de repartirse. Si el ID ya se vio, se descarta silenciosamente — así un mensaje reenviado por error (o por un reintento del cliente) nunca llega dos veces.
  • deliver nunca bloquea. El select con rama default significa: si el canal de un cliente está lleno (16 mensajes en cola sin drenar), ese cliente se considera lento y se desconecta ahí mismo. Un solo cliente colgado no puede congelar la sala entera.
  • La limpieza de IDs vistos usa un time.Ticker en el mismo select. No hace falta una goroutine aparte para el housekeeping — es solo otro caso más en el mismo bucle.

close(req.done) y <-req.done forman una barrera de sincronización real: todo lo que el Hub escribió antes de cerrar ese canal (incluyendo req.client.username) es visible para quien hizo Join() en cuanto retorna. No hace falta ningún mutex para leer c.username después.


Paso 3: Por Conexión — readPump y writePump

Cada conexión aceptada obtiene su propio par de goroutines: una que lee, una que escribe. Nunca se cruzan directamente — se comunican con el Hub por canales, y entre ellas coordinan el cierre a través del canal send.

internal/server/limiter.go

package server

import "time"

type limiter struct {
	tokens float64
	max    float64
	rate   float64
	last   time.Time
}

func newLimiter(maxTokens, perSecond float64) *limiter {
	return &limiter{tokens: maxTokens, max: maxTokens, rate: perSecond, last: time.Now()}
}

func (l *limiter) allow() bool {
	now := time.Now()
	l.tokens = min(l.max, l.tokens+now.Sub(l.last).Seconds()*l.rate)
	l.last = now

	if l.tokens < 1 {
		return false
	}
	l.tokens--
	return true
}

Un token bucket de 15 líneas, sin sync.Mutex, porque cada instancia vive dentro de la goroutine de un único cliente — nadie más la toca.

internal/server/client.go

package server

import (
	"bufio"
	"net"
	"strings"
	"time"

	"forum/internal/chat"
)

const (
	readTimeout  = 90 * time.Second
	writeTimeout = 10 * time.Second
	pingInterval = 30 * time.Second
)

type client struct {
	id       string
	username string
	conn     net.Conn
	send     chan chat.Envelope
}

func Serve(conn net.Conn, hub *Hub) {
	defer conn.Close()

	scanner := bufio.NewScanner(conn)
	scanner.Buffer(make([]byte, 4096), chat.MaxBodyBytes*4)

	conn.SetReadDeadline(time.Now().Add(readTimeout))
	if !scanner.Scan() {
		return
	}

	hello, err := chat.Decode(scanner.Bytes())
	if err != nil || hello.Kind != chat.KindHello {
		return
	}

	c := hub.Join(sanitizeUsername(hello.From))
	c.conn = conn
	c.send <- chat.Envelope{Kind: chat.KindWelcome, From: c.username}

	writeDone := make(chan struct{})
	go func() {
		writePump(c)
		close(writeDone)
	}()

	readPump(c, scanner, hub)
	hub.Leave(c)
	<-writeDone
}

func readPump(c *client, scanner *bufio.Scanner, hub *Hub) {
	limit := newLimiter(5, 1)

	for {
		c.conn.SetReadDeadline(time.Now().Add(readTimeout))

		if !scanner.Scan() {
			return
		}

		env, err := chat.Decode(scanner.Bytes())
		if err != nil || env.Kind != chat.KindMessage {
			continue
		}
		if !limit.allow() {
			continue
		}

		body := strings.TrimSpace(env.Body)
		if body == "" || len(body) > chat.MaxBodyBytes {
			continue
		}

		id := env.ID
		if id == "" {
			id = chat.NewID()
		}

		hub.Broadcast(chat.Envelope{
			Kind:   chat.KindMessage,
			ID:     id,
			From:   c.username,
			Body:   body,
			SentAt: time.Now().UTC(),
		})
	}
}

func writePump(c *client) {
	ticker := time.NewTicker(pingInterval)
	defer ticker.Stop()
	defer c.conn.Close()

	for {
		select {
		case env, ok := <-c.send:
			if !ok {
				return
			}
			if err := writeEnvelope(c.conn, env); err != nil {
				return
			}

		case <-ticker.C:
			if err := writeEnvelope(c.conn, chat.Envelope{Kind: chat.KindPing}); err != nil {
				return
			}
		}
	}
}

func writeEnvelope(conn net.Conn, env chat.Envelope) error {
	b, err := chat.Encode(env)
	if err != nil {
		return err
	}

	conn.SetWriteDeadline(time.Now().Add(writeTimeout))
	_, err = conn.Write(b)
	return err
}

func sanitizeUsername(raw string) string {
	name := strings.TrimSpace(raw)
	name = strings.Map(func(r rune) rune {
		if r < 0x20 || r == 0x7f {
			return -1
		}
		return r
	}, name)

	if len(name) > 32 {
		name = name[:32]
	}
	if name == "" {
		name = "anon-" + chat.NewID()[:8]
	}
	return name
}

Con calma, porque aquí está la parte más delicada del proyecto:

  • Un solo bufio.Scanner para toda la conexión. Se crea una vez en Serve, lee la línea de saludo, y ese mismo scanner — no uno nuevo — se pasa a readPump. Si crearas un segundo lector envolviendo el mismo net.Conn, perderías cualquier byte que el primero ya hubiera almacenado en su búfer interno.
  • scanner.Buffer(..., chat.MaxBodyBytes*4) pone un techo duro. Sin esto, un cliente malicioso podría mandar una “línea” de gigabytes sin salto de línea y reventar la memoria del servidor antes de que tu validación de longitud alcance a rechazarla. Con el límite puesto, Scan() simplemente falla y la conexión se cierra.
  • writePump siempre cierra c.conn, sin importar por qué salió — error de escritura, o el canal send cerrado por el Hub al expulsar a un cliente lento. Cerrar la conexión desde ahí desbloquea inmediatamente el Scan() que readPump tiene pendiente en el otro lado.
  • El if _, ok := h.clients[c]; ok en Hub.Leave (visto en el paso anterior) no es paranoia — es necesario. Si el Hub ya expulsó a un cliente lento desde deliver, y luego Serve llama hub.Leave(c) al terminar, esa verificación evita un close doble sobre un canal ya cerrado, que sí provocaría un pánico.
  • El heartbeat es puramente por actividad. El servidor no necesita procesar el contenido de un pong — el simple hecho de que Scan() retorne con éxito (sea lo que sea que haya llegado) reinicia el SetReadDeadline en la siguiente vuelta del bucle. Un cliente que nunca escribe nada, ni siquiera un pong, se desconecta a los 90 segundos.

Nota también algo que el servidor no hace: nunca confía en el Kind que manda un cliente salvo message. Un cliente no puede falsificar un mensaje de system ni inyectar un ping falso — cualquier otro Kind que no sea message simplemente se ignora en readPump.

internal/server/hub_test.go

package server

import (
	"testing"
	"time"

	"forum/internal/chat"
)

func TestHubDeduplicatesMessages(t *testing.T) {
	hub := NewHub()
	go hub.Run()

	alice := hub.Join("alice")
	defer hub.Leave(alice)

	drain(t, alice.send)

	msg := chat.Envelope{Kind: chat.KindMessage, ID: chat.NewID(), From: "bob", Body: "hola"}
	hub.Broadcast(msg)
	hub.Broadcast(msg)

	first := recvMessage(t, alice.send)
	if first.ID != msg.ID {
		t.Fatalf("expected message %s, got %s", msg.ID, first.ID)
	}

	select {
	case env := <-alice.send:
		t.Fatalf("expected no duplicate, got %+v", env)
	case <-time.After(200 * time.Millisecond):
	}
}

func drain(t *testing.T, ch <-chan chat.Envelope) {
	t.Helper()

	select {
	case <-ch:
	case <-time.After(time.Second):
		t.Fatal("timed out waiting for join system message")
	}
}

func recvMessage(t *testing.T, ch <-chan chat.Envelope) chat.Envelope {
	t.Helper()

	select {
	case env := <-ch:
		return env
	case <-time.After(time.Second):
		t.Fatal("timed out waiting for message")
		return chat.Envelope{}
	}
}

Este test no es un adorno — es la prueba concreta de que “no se repiten” es verdad. hub.Broadcast(msg) se llama dos veces con exactamente el mismo ID, y el test falla si alice recibe el mensaje más de una vez.


Paso 4: Certificados TLS — Sin InsecureSkipVerify

“Seguro” significa cifrado en tránsito y verificación real del certificado — no saltarte la verificación porque el certificado es autofirmado. Genera un certificado autofirmado y confía explícitamente en él, en vez de desactivar la verificación por completo.

mkdir -p certs

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -days 365 -nodes \
  -keyout certs/server.key -out certs/server.crt \
  -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

El cliente va a cargar ese mismo server.crt como su única CA de confianza (RootCAs), y a exigir que el ServerName coincida — exactamente la misma verificación que harías contra un certificado emitido por una autoridad pública. InsecureSkipVerify: true existe como bandera para desarrollo, pero úsala y perdiste toda protección contra un atacante interceptando la conexión (machine-in-the-middle). Este proyecto la deja disponible detrás de un flag explícito — nunca como comportamiento por defecto.


Paso 5: forumd — El Servidor

cmd/forumd/main.go

package main

import (
	"context"
	"crypto/tls"
	"flag"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	"forum/internal/server"
)

func main() {
	addr := flag.String("addr", ":4000", "listen address")
	certFile := flag.String("cert", "certs/server.crt", "TLS certificate path")
	keyFile := flag.String("key", "certs/server.key", "TLS key path")
	flag.Parse()

	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))

	cert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
	if err != nil {
		slog.Error("load TLS certificate", "error", err)
		os.Exit(1)
	}

	tlsConfig := &tls.Config{
		Certificates: []tls.Certificate{cert},
		MinVersion:   tls.VersionTLS13,
	}

	listener, err := tls.Listen("tcp", *addr, tlsConfig)
	if err != nil {
		slog.Error("listen", "error", err)
		os.Exit(1)
	}
	defer listener.Close()

	hub := server.NewHub()
	go hub.Run()

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	go func() {
		<-ctx.Done()
		listener.Close()
	}()

	slog.Info("forum listening", "addr", *addr)

	for {
		conn, err := listener.Accept()
		if err != nil {
			if ctx.Err() != nil {
				return
			}
			slog.Warn("accept", "error", err)
			continue
		}

		go server.Serve(conn, hub)
	}
}

tls.Listen reemplaza a net.Listen sin cambiar nada del resto del flujo — cada conexión que sale de Accept() ya llega cifrada. Al recibir SIGTERM o Ctrl+C, cerrar el listener hace que Accept() retorne un error inmediatamente; como ctx.Err() ya no es nil en ese punto, el bucle sale limpio en vez de loguear un error falso.

Arranca el servidor:

go run ./cmd/forumd

Paso 6: forum — El Cliente CLI

El cliente tiene tres responsabilidades corriendo en paralelo: leer del socket e imprimir, leer de stdin y enviar, y reconectar solo cuando la red falla. La pieza clave para que esto no se vuelva un caos de goroutines es que stdin se lee una sola vez, para toda la vida del proceso — no una vez por intento de conexión. Así, lo que escribes durante un corte de red simplemente espera en un canal hasta que la reconexión se complete.

internal/client/session.go

package client

import (
	"bufio"
	"context"
	"crypto/tls"
	"fmt"
	"math/rand/v2"
	"net"
	"os"
	"strings"
	"time"

	"forum/internal/chat"
)

type Config struct {
	Addr      string
	Username  string
	TLSConfig *tls.Config
}

func Run(ctx context.Context, cfg Config) error {
	send := make(chan chat.Envelope, 64)

	go func() {
		_ = scanStdin(ctx, send)
	}()

	backoff := time.Second

	for {
		if ctx.Err() != nil {
			return ctx.Err()
		}

		start := time.Now()
		err := connectAndChat(ctx, cfg, send)
		if ctx.Err() != nil {
			return ctx.Err()
		}

		if time.Since(start) > 5*time.Second {
			backoff = time.Second
		}

		fmt.Fprintf(os.Stderr, "\nconnection lost (%v), retrying in %s...\n", err, backoff)

		select {
		case <-time.After(backoff + jitter()):
		case <-ctx.Done():
			return ctx.Err()
		}

		backoff = min(backoff*2, 30*time.Second)
	}
}

func jitter() time.Duration {
	return time.Duration(rand.Int64N(int64(500 * time.Millisecond)))
}

func connectAndChat(ctx context.Context, cfg Config, send chan chat.Envelope) error {
	dialer := &net.Dialer{Timeout: 10 * time.Second}
	rawConn, err := dialer.DialContext(ctx, "tcp", cfg.Addr)
	if err != nil {
		return fmt.Errorf("dial: %w", err)
	}

	conn := tls.Client(rawConn, cfg.TLSConfig)
	if err := conn.HandshakeContext(ctx); err != nil {
		conn.Close()
		return fmt.Errorf("tls handshake: %w", err)
	}
	defer conn.Close()

	hello, err := chat.Encode(chat.Envelope{Kind: chat.KindHello, From: cfg.Username})
	if err != nil {
		return err
	}
	if _, err := conn.Write(hello); err != nil {
		return fmt.Errorf("handshake write: %w", err)
	}

	connCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	errs := make(chan error, 2)
	go func() { errs <- readLoop(conn, send) }()
	go func() { errs <- writeLoop(connCtx, conn, send) }()

	err = <-errs
	cancel()
	return err
}

func readLoop(conn net.Conn, send chan<- chat.Envelope) error {
	scanner := bufio.NewScanner(conn)
	scanner.Buffer(make([]byte, 4096), chat.MaxBodyBytes*4)

	for {
		conn.SetReadDeadline(time.Now().Add(2 * time.Minute))

		if !scanner.Scan() {
			if err := scanner.Err(); err != nil {
				return fmt.Errorf("read: %w", err)
			}
			return fmt.Errorf("read: connection closed")
		}

		env, err := chat.Decode(scanner.Bytes())
		if err != nil {
			continue
		}

		switch env.Kind {
		case chat.KindWelcome:
			fmt.Printf("\rconnected to the forum as %s\n> ", env.From)
		case chat.KindPing:
			select {
			case send <- chat.Envelope{Kind: chat.KindPong}:
			default:
			}
		case chat.KindMessage:
			fmt.Printf("\r[%s] %s: %s\n> ", env.SentAt.Local().Format("15:04:05"), env.From, env.Body)
		case chat.KindSystem:
			fmt.Printf("\r*** %s\n> ", env.Body)
		}
	}
}

func writeLoop(ctx context.Context, conn net.Conn, send <-chan chat.Envelope) error {
	for {
		select {
		case env := <-send:
			b, err := chat.Encode(env)
			if err != nil {
				continue
			}

			conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
			if _, err := conn.Write(b); err != nil {
				return fmt.Errorf("write: %w", err)
			}

		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

func scanStdin(ctx context.Context, send chan<- chat.Envelope) error {
	fmt.Print("> ")
	scanner := bufio.NewScanner(os.Stdin)

	for scanner.Scan() {
		if ctx.Err() != nil {
			return ctx.Err()
		}

		text := strings.TrimSpace(scanner.Text())
		if text == "" {
			fmt.Print("> ")
			continue
		}
		if len(text) > chat.MaxBodyBytes {
			fmt.Printf("message too long (max %d bytes), not sent\n> ", chat.MaxBodyBytes)
			continue
		}

		send <- chat.Envelope{Kind: chat.KindMessage, ID: chat.NewID(), Body: text}
		fmt.Print("> ")
	}

	return fmt.Errorf("stdin closed")
}

Lo que hace que la reconexión sea segura y no un desastre de goroutines colgadas:

  • send se crea una sola vez, en Run, y vive para siempre. scanStdin lo escribe; cada intento de conexión, sin importar cuántas veces se reconecte, lo lee a través de un writeLoop nuevo. Nunca hay dos goroutines leyendo stdin al mismo tiempo, porque scanStdin solo se lanza una vez.
  • connCtx cancela al hermano cuando uno falla. Si readLoop muere (la red se cayó), connectAndChat recibe ese error, llama cancel(), y writeLoop sale por su rama <-ctx.Done() en vez de quedarse esperando para siempre en un canal que ya nadie va a llenar con la urgencia debida.
  • El backoff se resetea solo si la conexión anterior duró más de cinco segundos. Así, una caída real de red no te deja reintentando cada 30 segundos para siempre una vez que la red vuelve — pero un servidor que rechaza la conexión instantáneamente sí activa el backoff completo, para no martillarlo.
  • El jitter evita el efecto manada. Si el servidor se cae con veinte clientes conectados, sin jitter los veinte reintentarían exactamente en el mismo instante, una y otra vez. Con 500ms de aleatoriedad sumados al backoff, se dispersan.

Hay una ventana de carrera minúscula y honesta: si escribes un mensaje justo en el instante en que la conexión muere, ese mensaje específico puede perderse en vez de reintentarse — el select dentro de writeLoop podría, en teoría, drenarlo del canal justo antes de que ctx.Done() se dispare. Arreglarlo de raíz requeriría un protocolo de confirmación (ACK) explícito por mensaje, que queda fuera del alcance de esta guía — y es, de hecho, el ejercicio que te propongo al final.

cmd/forum/main.go

package main

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"flag"
	"fmt"
	"net"
	"os"
	"os/signal"
	"os/user"
	"syscall"

	"forum/internal/client"
)

func main() {
	addr := flag.String("addr", "localhost:4000", "server address")
	username := flag.String("user", defaultUsername(), "your username in the forum")
	caFile := flag.String("ca", "certs/server.crt", "trusted certificate (self-signed CA)")
	insecure := flag.Bool("insecure-skip-verify", false, "skip TLS verification (development only)")
	flag.Parse()

	tlsConfig := &tls.Config{ServerName: hostOnly(*addr)}

	if *insecure {
		tlsConfig.InsecureSkipVerify = true
	} else {
		pem, err := os.ReadFile(*caFile)
		if err != nil {
			fmt.Fprintln(os.Stderr, "read CA file:", err)
			os.Exit(1)
		}

		pool := x509.NewCertPool()
		if !pool.AppendCertsFromPEM(pem) {
			fmt.Fprintln(os.Stderr, "invalid CA file")
			os.Exit(1)
		}
		tlsConfig.RootCAs = pool
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	err := client.Run(ctx, client.Config{
		Addr:      *addr,
		Username:  *username,
		TLSConfig: tlsConfig,
	})
	if err != nil && ctx.Err() == nil {
		fmt.Fprintln(os.Stderr, "fatal:", err)
		os.Exit(1)
	}
}

func hostOnly(addr string) string {
	host, _, err := net.SplitHostPort(addr)
	if err != nil {
		return addr
	}
	return host
}

func defaultUsername() string {
	if u, err := user.Current(); err == nil && u.Username != "" {
		return u.Username
	}
	return "anon"
}

defaultUsername usa os/user para adivinar tu nombre de usuario del sistema operativo — un detalle pequeño que evita que tengas que escribir -user=omar cada vez que pruebas.


Probándolo

Terminal 1 — el servidor:

go run ./cmd/forumd

Terminal 2 — primer cliente:

go run ./cmd/forum -user=alice
> connected to the forum as alice
> hola a todos

Terminal 3 — segundo cliente:

go run ./cmd/forum -user=bob
> connected to the forum as bob
> *** alice joined the forum
> [14:32:07] alice: hola a todos
> qué tal alice

De vuelta en la terminal de alice, verás llegar el mensaje de bob en tiempo real. Ahora prueba la resiliencia de verdad: mata el servidor con Ctrl+C en la Terminal 1. Ambos clientes van a imprimir connection lost, retrying in 1s..., y en cuanto vuelvas a levantar go run ./cmd/forumd, se reconectan solos — sin que tú toques nada en las terminales de los clientes.


Paso 7: Docker

Como no hay dependencias externas, ni siquiera hay go.sum que copiar.

Dockerfile

FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/forumd ./cmd/forumd

FROM gcr.io/distroless/static-debian12
COPY --from=build /out/forumd /forumd
COPY certs/server.crt certs/server.key /certs/
EXPOSE 4000
ENTRYPOINT ["/forumd", "-addr=:4000", "-cert=/certs/server.crt", "-key=/certs/server.key"]

docker-compose.yml

services:
  forumd:
    build: .
    ports:
      - "4000:4000"
    restart: unless-stopped

Sin volúmenes. A propósito — no hay nada que persistir. Si el contenedor se reinicia, el foro empieza de cero, y eso es exactamente el comportamiento que pediste.

Makefile

certs:
	mkdir -p certs
	openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -days 365 -nodes \
	  -keyout certs/server.key -out certs/server.crt \
	  -subj "/CN=localhost" \
	  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

server:
	go run ./cmd/forumd

client:
	go run ./cmd/forum

test:
	go test ./...

up:
	docker compose up --build

down:
	docker compose down

El cliente forum es una herramienta interactiva de terminal — normalmente la corres nativa en tu máquina, no dentro de Docker. El servidor sí es un buen candidato para contenerizar; el cliente vive donde vive el humano que escribe.

go test ./...
docker compose up --build

Límites Conocidos (Y Ejercicios Para Ti)

Esta guía es honesta sobre dónde se detiene a propósito:

  • Sin entrega garantizada exacta. Hay una ventana de carrera minúscula donde un mensaje escrito justo cuando la conexión muere puede perderse. Ejercicio: agrega un Kind: "ack" que el servidor devuelva por cada mensaje aceptado, y haz que el cliente reintente si no recibe el ACK en cierto tiempo — gracias al ID que ya generas, el reintento es seguro contra duplicados sin cambiar nada más.
  • El límite de tasa es silencioso. Si superas 5 mensajes por segundo, el exceso se descarta sin avisarte. Ejercicio: haz que el servidor responda con un Envelope de tipo system explicando por qué se descartó.
  • Es un solo foro, sin salas. Ejercicio natural: agrega un campo Room al Envelope, y haz que Hub.deliver solo entregue a los clientes suscritos a esa sala — sigue sin hacer falta ningún mutex nuevo.
  • No hay persistencia, por diseño. Si algún día quisieras historial, la forma correcta de agregarlo sin romper la arquitectura es un suscriptor más del Hub — un cliente especial de solo lectura que escribe cada Envelope a SQLite, igual que en la guía de la API de notas — sin tocar el broadcast en vivo.

Ese último punto es, quizás, la lección más importante de todo el proyecto: el Hub no sabe ni le importa quién está del otro lado de c.send. Podría ser un socket TCP, un test, o un escritor de base de datos. Esa es la ventaja real de mantener el estado en un solo lugar, hablado por canales.