Tutorial
31 min read
12 views

Túneles de alto rendimiento en el borde: Zero-Copy, NAT CGNAT y arquitectura QUIC 0-RTT

Domina la optimización de túneles a bajo nivel. Aprende cómo Linux splice/vmsplice, hole punching UDP, PCP y QUIC 0-RTT reducen la latencia y eliminan la sobrecarga de CPU.

IT
InstaTunnel Team
Published by the InstaTunnel team | Editorial policy
Túneles de alto rendimiento en el borde: Zero-Copy, NAT CGNAT y arquitectura QUIC 0-RTT

Quick answer

Túneles locales de alto rendimiento: Zero-Copy, CGNAT y QUIC: localhost tunnel answer

A localhost tunnel gives your local app a public HTTPS URL without opening router ports, which is useful for demos, QA, mobile testing, and provider callbacks.

How do I expose localhost without opening ports?

Use a reverse HTTPS tunnel. Your machine connects outbound to the tunnel service, and the public URL forwards requests back to your local app.

When should I use a localhost tunnel?

Use one for webhook testing, OAuth callbacks, client demos, QA previews, mobile device checks, and short-lived development reviews.

Construir un cliente de túnel rápido y resistente implica trabajar en tres capas distintas:

  1. Sobrecarga kernel-a-usuarios: cada byte que un proxy reenvía se copia dos veces a través de la frontera kernel/usuario, y cada fragmento cuesta dos llamadas al sistema.
  2. Topología de red: NATs duros (dependientes del endpoint) y Carrier-Grade NAT (CGNAT) dificultan las rutas directas peer-to-peer.
  3. Latencia de transporte: una conexión TCP + TLS nueva requiere rondas de ida y vuelta en los handshakes antes de poder enviar el primer byte de solicitud, lo que afecta a solicitudes de corta duración.

Esta guía cubre las primitivas del kernel, protocolos y compromisos de diseño para cada capa. También indica dónde cada técnica deja de ser útil, porque las tres capas interactúan de formas fáciles de malinterpretar. Cada ejemplo de código a continuación fue compilado y ejecutado, ya sea contra una prueba de eco en loopback o contra un servidor simulado.


Parte 1: Reduciendo la copia en espacio de usuario con splice()

Un encaminador convencional en espacio de usuario lee de un socket en un buffer de la aplicación y luego escribe ese buffer en otro socket:

[ Encaminamiento tradicional ]
Kernel : [ Buffer de recepción del socket A ] --read()--e (copia) --+
                                                       v
Usuario  :                              [ Buffer de la aplicación ]
                                                       |
Kernel : [ Buffer de envío del socket B ] c--write()-- (copia) -+

Para transferencias en bloque, esto cuesta dos copias por fragmento, dos llamadas al sistema por fragmento, y un buffer en espacio de usuario que compite por la caché de CPU con el resto del programa.

Qué hace splice()

Según la página del manual de Linux, splice() mueve datos entre dos descriptores de archivo sin copiar entre el espacio de direcciones del kernel y el del usuario. Al menos uno de ellos debe ser un pipe. La llamada existe en Linux desde la versión 2.6.17, y desde 2.6.31 ambos descriptores pueden ser pipes. Junto con vmsplice(2) y tee(2), permite a un programa controlar un búfer del kernel que se implementa con el mismo tipo de buffer usado para pipes.

[ Pipeline de splice ]
Kernel : [ Socket A ] --splice--e [ Buffer del pipe ] --splice--e [ Socket B ]
Usuario :   (nunca se ven bytes de carga útil aquí)

La firma:

#define _GNU_SOURCE
#include <fcntl.h>

ssize_t splice(int fd_in, loff_t *off_in,
               int fd_out, loff_t *off_out,
               size_t len, unsigned int flags);

Tres detalles que comúnmente se malinterpretan:

  • SPLICE_F_MOVE no hace nada en kernels modernos. La página del manual indica que la implementación original tenía errores, por lo que desde Linux 2.6.21 la bandera es aceptada pero no realiza ninguna acción. No confíes en ella para mover páginas.
  • SPLICE_F_NONBLOCK hace que el lado pipe de la operación sea no bloqueante. La llamada aún puede bloquearse si el otro descriptor es un socket bloqueante, así que también configura O_NONBLOCK en los sockets.
  • SPLICE_F_MORE indica que más datos siguen en un splice() posterior, útil en la ruta pipe-a-socket.

“Sin copia al espacio de usuario” no es lo mismo que “cero copias en cualquier lugar”. La página del manual promete solo la primera.

Dónde encaja vmsplice() (y dónde no)

vmsplice(2) conecta la memoria del usuario con un pipe. Cuando el pipe está abierto para escritura, mapea tus rangos de memoria en el pipe. Cuando está abierto para lectura, copia los datos en tus buffers. Esto es relevante cuando tu propio código produce datos (por ejemplo, un frame preconstruido), no para reenvío socket a socket, donde nada necesita tocar espacio de usuario.

El matiz es que, dado que el pipe mapea tus páginas en lugar de copiarlas, no debes modificarlas hasta que el kernel las haya consumido. SPLICE_F_GIFT va más allá y declara las páginas entregadas al kernel como de buena fe, por lo que la aplicación debe tratarlas como solo lectura. También requiere direcciones y longitudes alineadas a páginas.

Cuándo splice ayuda y cuándo no

Splice solo aplica cuando el túnel reenvía bytes que no necesita leer o transformar:

  • Reenvío TCP a TCP simple (un túnel TCP que transporta un flujo opaco, o un camino de passthrough TLS): splice funciona.
  • TLS terminado en espacio de usuario, o QUIC: la aplicación debe ver texto plano para cifrar o descifrar, por lo que splice no ayuda. La descarga TLS del kernel (kTLS) puede reducir esta brecha para TCP: su documentación describe soporte para sendfile en la ruta de transmisión, permitiendo que texto plano llegue a un socket TLS sin copia en espacio de usuario.
  • QUIC y otros protocolos UDP no pueden usar splice, así que las palancas disponibles son el batching. La librería QUICHE de Google, por ejemplo, usa UDP GSO (UDP_SEGMENT) para que un sendmsg transporte múltiples segmentos, y sendmmsg para enviar varios paquetes en una llamada (fuente QUICHE).
  • io_uring también expone splice mediante io_uring_prep_splice(3), útil si ya construyes sobre un bucle basado en finalización.

Go: primero usa la librería estándar, solo crea a mano para ajustar

El paquete net de Go ya realiza splice en Linux. Cuando un *net.TCPConn es la fuente de un io.Copy a otra conexión TCP, el runtime usa internamente un camino splice y recurre a una copia normal si no aplica (net/splice_linux.go). Para la mayoría de proxies, io.Copy(dst, src) entre dos valores *net.TCPConn es la respuesta correcta.

Una versión hecha a mano solo vale si necesitas controlar aspectos que la stdlib no ofrece, como tamaño del pipe, tamaño de fragmento o contabilidad por conexión. La versión a continuación integra con el netpoller de Go mediante syscall.RawConn, para que un bloqueo en espera no consuma CPU:

//go:build linux

package main

import (
	"errors"
	"fmt"
	"net"

	"golang.org/x/sys/unix"
)

// SplicePipe es un pipe del kernel usado como buffer entre dos sockets.
type SplicePipe struct{ r, w int }

// NewSplicePipe crea un pipe no bloqueante y ajusta su tamaño. Procesos sin privilegios
// están limitados por /proc/sys/fs/pipe-max-size (1 MiB por defecto).
func NewSplicePipe(size int) (*SplicePipe, error) {
	var fds [2]int
	if err := unix.Pipe2(fds[:], unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
		return nil, fmt.Errorf("pipe2: %w", err)
	}
	if _, err := unix.FcntlInt(uintptr(fds[0]), unix.F_SETPIPE_SZ, size); err != nil {
		unix.Close(fds[0])
		unix.Close(fds[1])
		return nil, fmt.Errorf("F_SETPIPE_SZ(%d): %w", size, err)
	}
	return &SplicePipe{r: fds[0], w: fds[1]}, nil
}

func (p *SplicePipe) Close() { unix.Close(p.r); unix.Close(p.w) }

// Copy mueve bytes desde src hasta dst hasta que src llega a EOF.
func (p *SplicePipe) Copy(dst, src *net.TCPConn, chunk int) (int64, error) {
	srcRC, err := src.SyscallConn()
	if err != nil {
		return 0, err
	}
	dstRC, err := dst.SyscallConn()
	if err != nil {
		return 0, err
	}

	var total int64
	for {
		// Etapa 1: socket -e pipe.
		var n int64
		var serr error
		rerr := srcRC.Read(func(fd uintptr) bool {
			for {
				n, serr = unix.Splice(int(fd), nil, p.w, nil, chunk, unix.SPLICE_F_NONBLOCK)
				if serr != unix.EINTR {
					break
				}
			}
			// false =e runtime espera hasta que el socket sea legible, luego reintenta
			return !errors.Is(serr, unix.EAGAIN)
		})
		if rerr != nil {
			return total, rerr
		}
		if serr != nil {
			return total, fmt.Errorf("splice socket-e pipe: %w", serr)
		}
		if n == 0 {
			return total, nil // EOF
		}

		// Etapa 2: pipe -e socket. Drena todo lo que se acaba de poner.
		for left := n; left > 0; {
			var w int64
			werr := dstRC.Write(func(fd uintptr) bool {
				for {
					w, serr = unix.Splice(p.r, nil, int(fd), nil, int(left),
						unix.SPLICE_F_NONBLOCK|unix.SPLICE_F_MORE)
					if serr != unix.EINTR {
						break
					}
				}
				return !errors.Is(serr, unix.EAGAIN)
			})
			if werr != nil {
				return total, werr
			}
			if serr != nil {
				return total, fmt.Errorf("splice pipe-e socket: %w", serr)
			}
			left -= w
			total += w
		}
	}
}

Usa un pipe por dirección por conexión. Cuando Copy termina, llama a dst.CloseWrite() para que el EOF se propague como un cierre parcial. Una carga útil aleatoria de 64 MiB enviada a través de un eco en loopback de dos saltos usando este código regresó byte por byte idéntica.

Rust: splice dentro de un bucle de eventos Tokio

En Rust, la crate nix envuelve splice, pero su splice y SpliceFFlags están protegidos por la característica Cargo zerocopy (el código abajo fue compilado con nix 0.30). TcpStream::async_io de Tokio es la forma limpia de ejecutar una llamada al sistema sin bloquear, bajo su seguimiento de disponibilidad: si la clausura devuelve WouldBlock, Tokio limpia la disponibilidad y espera otra vez.

[dependencies]
nix   = { version = "0.30", features = ["fs", "zerocopy"] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "io-util"] }
use std::io;
use std::os::fd::OwnedFd;

use nix::fcntl::{fcntl, splice, FcntlArg, OFlag, SpliceFFlags};
use nix::unistd::pipe2;
use tokio::io::Interest;
use tokio::net::TcpStream;

/// Un pipe del kernel usado como buffer entre dos sockets.
pub struct SplicePipe {
    r: OwnedFd,
    w: OwnedFd,
}

impl SplicePipe {
    pub fn new(size: i32) -> io::Result<Self> {
        let (r, w) = pipe2(OFlag::O_CLOEXEC | OFlag::O_NONBLOCK)?;
        fcntl(&w, FcntlArg::F_SETPIPE_SZ(size))?;
        Ok(Self { r, w })
    }

    /// Mueve bytes desde `src` a `dst` hasta que `src` llega a EOF.
    pub async fn forward(&self, src: &TcpStream, dst: &TcpStream, chunk: usize) -> io::Result<u64> {
        let mut total = 0u64;
        loop {
            // socket -e pipe (EAGAIN se convierte en WouldBlock, para que Tokio espere)
            let n = src
                .async_io(Interest::READABLE, || {
                    splice(src, None, &self.w, None, chunk, SpliceFFlags::SPLICE_F_NONBLOCK)
                        .map_err(io::Error::from)
                })
                .await?;
            if n == 0 {
                return Ok(total); // EOF
            }

            // pipe -e socket: drena todo lo puesto
            let mut left = n;
            while left > 0 {
                let w = dst
                    .async_io(Interest::WRITABLE, || {
                        splice(
                            &self.r,
                            None,
                            dst,
                            None,
                            left,
                            SpliceFFlags::SPLICE_F_NONBLOCK | SpliceFFlags::SPLICE_F_MORE,
                        )
                        .map_err(io::Error::from)
                    })
                    .await?;
                left -= w;
                total += w as u64;
            }
        }
    }
}

Envuelve cada TcpStream en un Arc para que una tarea pueda reenviar en cada dirección, y apaga la mitad de escritura del destino cuando una dirección termina. La misma prueba de eco en loopback de 64 MiB pasó con este código.

Guía práctica

  • Mide en tu carga de trabajo. Splice elimina la copia en espacio de usuario y la pareja de llamadas al sistema por fragmento, pero añade gestión de pipes y (en este diseño) una segunda llamada por fragmento, por lo que mensajes pequeños o streams muy transformados pueden no beneficiarse.
  • La capacidad predeterminada del pipe es 64 KiB. Aumentarla con F_SETPIPE_SZ es un control de rendimiento, limitado por /proc/sys/fs/pipe-max-size para procesos sin privilegios.
  • Solo en Linux: splice es específico de Linux, así que mantén un fallback portátil (io.Copy, tokio::io::copy) para otras plataformas.

Parte 2: Traversando NAT y CGNAT

Por qué el NAT de grado carrier es diferente

La mayoría de los routers domésticos realizan una capa de NAT. Bajo CGNAT, el ISP añade una segunda capa. RFC 6598 (BCP 153) reserva 100.64.0.0/10 como Espacio Compartido para proveedores de servicios para numerar los enlaces entre sus dispositivos CGN y el equipo del cliente. Si la dirección WAN de tu router cae en ese rango, casi seguro estás detrás de CGNAT.

[ CGNAT: dos NAT en serie ]
Laptop (192.168.1.50)
  └─e NAT del router doméstico   dirección WAN: 100.64.10.15   (espacio compartido RFC 6598)
        └─e CGNAT del ISP   dirección pública: 198.51.100.1:45210 (dirección de documentación)
              └─e Internet

(Las direcciones 198.51.100.x y 203.0.113.x en este artículo son direcciones reservadas para documentación, no hosts reales.)

Comportamiento del NAT: dos propiedades independientes

El vocabulario antiguo de “cono completo / cono restringido / cono restringido por puerto / simétrico” proviene del trabajo temprano con STUN. Explicaciones modernas, como Tailscale, describen el comportamiento del NAT con dos propiedades que importan para la traversabilidad, y consideran las categorías nombradas como un mapa aproximado:

  • Mapeo: ¿depende el puerto externo del destino? Si no, el NAT es “fácil”; si sí, es “difícil”.
  • Filtrado entrante: ¿qué direcciones remotas pueden enviar paquetes de vuelta a través de un mapeo?
Nombre legado Mapeo Filtrado entrante Travesía UDP directa
Cono completo Mismo puerto externo para cada destino Cualquier host remoto Fácil
Cono restringido por dirección Mismo puerto externo para cada destino Solo IPs a las que el host ya envió Fácil una vez que ambos lados enviaron primero
Cono restringido por puerto Mismo puerto externo para cada destino Solo pares IP:puerto que el host ya envió Fácil una vez que ambos lados enviaron primero
Simétrico Nuevo puerto externo por destino Solo la IP:puerto contactado Difícil

Para rutas directas, necesitas al menos un NAT de mapeo fácil en un lado. Los routers domésticos suelen ser NATs fáciles, mientras que los NATs duros suelen ser routers de oficina y gateways en la nube.

STUN, rendezvous y hole punching

RFC 8489 (que reemplaza RFC 5389, febrero 2020) define STUN como una herramienta auxiliar: un endpoint puede usarlo para aprender la dirección y puerto públicos asignados por su NAT, verificar conectividad y mantener vivos los mapeos NAT. El RFC especifica que STUN es una herramienta dentro de una solución completa de traversal como ICE, no una solución por sí misma.

Eso corrige una simplificación común: el servidor STUN no intercambia direcciones entre pares. Un canal de rendezvous (señalización), que en un producto de túnel suele ser tu plano de control, lleva la vista de cada lado sobre su endpoint público al otro.

  1. Ambos endpoints envían solicitudes de enlace STUN y aprenden su ip:port público (por ejemplo, 198.51.100.1:10050 y 203.0.113.8:20080).
  2. Cada uno informa su endpoint al servicio de rendezvous, que entrega el endpoint del otro a cada par.
  3. Ambos pares envían paquetes UDP a su endpoint público al mismo tiempo. Cada paquete saliente crea un estado NAT que permite que los paquetes del otro lado entren. El primer paquete en una dirección puede ser descartado, lo cual es esperado.
  4. Una vez que fluye el tráfico, siguen enviando keepalives para que los mapeos no expiren. [ Hole punching UDP ] Cliente A servidor STUN servicio de rendezvous Cliente B |--- solicitud de enlace ----e| | | |c-- A_pub=198.51.100.1:10050 |c--- solicitud de enlace ----| | | | B_pub=203.0.113.8:20080 |--- registro A_pub -------------------e|c--- registro B_pub ----| |c-- B_pub ---------------------------|--- A_pub -----------e| | | |=== punch A -e B (puede ser descartado por NAT de B) ===============e| |c== punch B -e A (el NAT de A ahora tiene estado) ========| |c================== ruta UDP directa ========================e|

NATs duros: el truco del paradoja del cumpleaños, con números reales

Cuando un lado tiene un NAT duro y el otro uno fácil, el lado fácil no sabe a qué puerto enviar. La explicación de Tailscale muestra cómo ayuda la paradoja del cumpleaños: el lado duro abre muchos sockets (digamos 256), cada uno con su propio puerto externo aleatorio, mientras que el lado fácil prueba puertos de destino aleatorios. Los números en ese post: con 256 puertos abiertos en el lado duro, unas 174 pruebas aleatorias dan aproximadamente un 50% de probabilidad de coincidencia, 256 pruebas dan 64%, 1024 dan 98%, y 2048 dan 99.9%.

Cuando ambos lados son duros, el espacio de búsqueda se vuelve pares de puertos, y las probabilidades colapsan. En ese análisis, el éxito tras 20 segundos a esas tasas es alrededor del 0.01%, y alcanzar el 99.9% requiere aproximadamente 170,000 pruebas por lado (unos 28 minutos a 100 paquetes por segundo). El autor también advierte que sondeo agresivo puede agotar la tabla de sesiones de dispositivos NAT pequeños.

La conclusión práctica es que los caminos NATs duros a duros generalmente necesitan un relay.

Protocolos de mapeo de puertos: útiles, pero solo en el NAT más cercano

UPnP IGD, NAT-PMP y PCP permiten a un host solicitar a su gateway que reenvíe un puerto. Comparten un patrón de solicitud/respuesta: pedir al gateway que reenvíe un puerto WAN a una dirección LAN, y recibir la dirección y puerto públicos asignados. La limitación importante, según el mismo material de Tailscale, es que solo actúan en el NAT más cercano al cliente. En una configuración doble NAT, no pueden llegar al NAT externo, por lo que suelen fallar. Detrás de CGNAT, un mapeo UPnP o NAT-PMP en tu router doméstico solo abre un puerto en el lado WAN 100.64.x.x, que no te hace alcanzable desde internet.

PCP (RFC 6887) es el sucesor de NAT-PMP y soporta IPv4 e IPv6. Sus funciones útiles para clientes de túneles:

  • Los servidores escuchan en UDP en el puerto 5351; los clientes en UDP 5350 para mensajes multicast de ANUNCIO.
  • La operación MAP solicita un mapeo entrante. El puerto y dirección externos sugeridos son pistas; el servidor puede asignar otros.
  • La opción PREFER_FAILURE indica que el servidor no debe crear un mapeo alternativo si el sugerido no está disponible. Cuando no puede cumplir, devuelve CANNOT_PROVIDE_EXTERNAL (código de resultado 11).
  • UNSUPP_VERSION (código 1) indica que el servidor no soporta la versión de PCP usada.

Si PCP puede llegar a un CGN depende de si el ISP ejecuta un servidor PCP para los suscriptores, lo cual un cliente no puede saber de antemano. Trata PCP como un primer intento oportunista, no como una opción garantizada.

Layout de solicitud MAP de PCP (RFC 6887 secciones 7.1 y 11.1)

Una solicitud MAP tiene 60 bytes: un encabezado común de 24 bytes más un cuerpo MAP de 36 bytes.

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Versión = 2  |R|   Opcode=1  |           Reservado            |
+---------------+---------------+-------------------------------+
|                 Tiempo de vida solicitado (32 bits)             |
+---------------------------------------------------------------+
|                                                               |
|             IP del cliente PCP (128 bits)                     |
|                                                               |
|                                                               |
+---------------------------------------------------------------+
|                                                               |
|                  Nonce de mapeo (96 bits)                     |
|                                                               |
+---------------+-----------------------------------------------+
|   Protocolo   |                Reservado (24 bits)             |
+-------------------------------+-------------------------------+
|        Puerto interno        |    Puerto externo sugerido     |
+-------------------------------+-------------------------------+
|                                                               |
|          IP externo sugerido (128 bits)                        |
|                                                               |
|                                                               |
+---------------------------------------------------------------+

Direcciones IPv4 se llevan como IPv4-mapped IPv6 (::ffff:a.b.c.d). En respuesta, el bit “R” se activa, el encabezado obtiene un código de resultado y un tiempo de época, y el cuerpo MAP lleva el puerto y dirección externos asignados en lugar de las sugerencias.

Go: cliente PCP MAP

package main

import (
	"bytes"
	"crypto/rand"
	"encoding/binary"
	"fmt"
	"net"
	"time"
)

const (
	pcpServerPort = 5351
	pcpVersion    = 2
	opMap         = 1
	protoUDP      = 17
	pdpReqLen     = 60 // encabezado común de 24 bytes + cuerpo MAP de 36 bytes
)

// PCPMapping es lo que el servidor PCP concedió.
type PCPMapping struct {
	ExternalIP   net.IP
	ExternalPort uint16
	Lifetime     time.Duration
}

// RequestMapping solicita al servidor PCP en la puerta de enlace que reenvíe el tráfico UDP externo a localIP:internalPort (RFC 6887, operación MAP).
func RequestMapping(gateway, localIP net.IP, internalPort uint16, lifetime uint32) (*PCPMapping, error) {
	// Vincular a localIP: la IP del cliente PCP debe coincidir con la fuente del paquete,
	// de lo contrario, el servidor rechaza la solicitud.
	conn, err := net.DialUDP("udp",
		&net.UDPAddr{IP: localIP},
		&net.UDPAddr{IP: gateway, Port: pcpServerPort})
	if err != nil {
		return nil, err
	}
	defer conn.Close()

	var nonce [12]byte
	if _, err := rand.Read(nonce[:]); err != nil {
		return nil, err
	}

	req := make([]byte, pcpReqLen)
	req[0] = pcpVersion // versión
	req[1] = opMap      // bit R = 0 (solicitud), opcode = 1
	binary.BigEndian.PutUint32(req[4:8], lifetime)
	copy(req[8:24], localIP.To16()) // IP del cliente (IPv4-mapped para IPv4)
	copy(req[24:36], nonce[:])      // nonce de mapeo
	req[36] = protoUDP              // bytes 37-39 reservados
	binary.BigEndian.PutUint16(req[40:42], internalPort)
	binary.BigEndian.PutUint16(req[42:44], internalPort) // puerto externo sugerido (una pista)
// bytes 44-59: IP externo sugerido; ceros = sin preferencia

	_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
	if _, err := conn.Write(req); err != nil {
		return nil, err
	}
	resp := make([]byte, 1100) // mensajes PCP hasta 1100 bytes
	n, err := conn.Read(resp)
	if err != nil {
		return nil, fmt.Errorf("sin respuesta PCP: %w", err)
	}
	return parseMapResponse(resp[:n], nonce)
}

func parseMapResponse(b []byte, nonce [12]byte) (*PCPMapping, error) {
	if len(b) < pcpReqLen {
		return nil, fmt.Errorf("respuesta PCP corta: %d bytes", len(b))
	}
	if b[0] != pcpVersion {
		return nil, fmt.Errorf("versión PCP inesperada %d", b[0])
	}
	if b[1] != 0x80|opMap { // bit R activado (respuesta) + opcode MAP
		return nil, fmt.Errorf("no es respuesta MAP (byte 1 = %#x)", b[1])
	}
	if code := b[3]; code != 0 {
		return nil, fmt.Errorf("código de resultado PCP %d", code)
	}
	if !bytes.Equal(b[24:36], nonce[:]) {
		return nil, fmt.Errorf("desajuste de nonce de mapeo")
	}
	return &PCPMapping{
		Lifetime:     time.Duration(binary.BigEndian.Uint32(b[4:8])) * time.Second,
		ExternalPort: binary.BigEndian.Uint16(b[42:44]),
		ExternalIP:   net.IP(append([]byte(nil), b[44:60]...)).To16(),
	}, nil
}

El cliente verifica la versión de respuesta, el bit de respuesta, el código de resultado y el nonce antes de confiar en la dirección asignada. Se probó contra un servidor simulado que responde según el esquema anterior; no se ha probado con un CGN real. Actualiza los mapeos antes de que expire el tiempo de vida concedido, y trata un cambio en el tiempo de época en una respuesta posterior como señal de que el servidor perdió estado.

Estrategia de conexión en capas

Un cliente de túnel realista prueba rutas en orden de costo, y siempre tiene un relay como última opción:

  1. IPv6 directo, cuando ambos lados lo tienen.
  2. Un mapeo de puerto en el gateway más cercano (PCP, NAT-PMP, UPnP).
  3. Hole punching asistido por STUN coordinado a través del plano de control.
  4. Sondeo estilo cumpleaños cuando solo uno de los lados es NAT duro.
  5. Un relay, cuando ambos lados son NAT duros o falla cualquier paso anterior.

Para un servicio de túnel alojado, el camino de relay es la opción por defecto y todo lo demás es una optimización para reducir coste y latencia.


Parte 3: QUIC 0-RTT para reconexiones de túnel

Cuánto cuesta el handshake

Una conexión TCP nueva necesita una ronda de ida y vuelta antes de que TLS pueda comenzar, y TLS 1.3 necesita otra más antes de que el cliente pueda enviar datos de aplicación. QUIC combina los handshakes de transporte y criptográficos, por lo que una conexión nueva requiere una ronda. Con reanudación de sesión, QUIC permite al cliente enviar datos en su primer vuelo: 0-RTT.

[ Tiempo hasta el primer byte de solicitud ]
TCP + TLS 1.3           QUIC (nuevo)              QUIC 0-RTT (reanudado)
SYN ------------e       Inicial -------e        Inicial + datos 0-RTT -e
c-- SYN-ACK         c-- Handshake         c-- Handshake terminado
ClientHello ----e       Datos --------e
c-- ServerHello..Fin
Datos -----------e
(2 rondas)             (1 ronda)               (0 rondas)

RFC 8470 describe el beneficio de datos tempranos en HTTP para evitar los retrasos de ida y vuelta en el handshake TLS, pero también señala el principal riesgo: un atacante puede capturar y reproducir las solicitudes que transporta.

Cómo funciona la reanudación en QUIC

Según RFC 9001 sección 4.6:

  • El cliente debe recordar el estado de TLS, los parámetros de transporte de QUIC, el protocolo de aplicación elegido y cualquier información necesaria para construir paquetes 0-RTT, todo de una conexión anterior. TLS 1.3 limita el tiempo entre esas conexiones a siete días.
  • La ticket de sesión del servidor llega como un mensaje NewSessionTicket en el handshake de TLS en marcos CRYPTO de QUIC (no es un tipo de marco QUIC separado).
  • El campo max_early_data_size se usa solo como un centinela: los servidores QUIC deben enviar 0xffffffff allí, y un cliente trata cualquier otro valor como una violación del protocolo. La cantidad de datos 0-RTT está gobernada por el parámetro de transporte initial_max_data del servidor. (No es un valor negociado en el ticket de 16-64 KB).
  • Un servidor acepta incluyendo early_data en EncryptedExtensions; lo rechaza omiténdolo. Tras un rechazo, el cliente debe resetear el estado de todos los flujos que usaron 0-RTT, incluyendo el estado de la aplicación.
  • Las aplicaciones que usan QUIC deben definir un perfil para uso de 0-RTT. Sin uno, 0-RTT solo puede transportar marcos QUIC sin datos de aplicación, y los clientes no deben enviar datos en 0-RTT a menos que la aplicación lo solicite.

Qué no da 0-RTT

RFC 8446 sección 2.3 detalla las garantías más débiles de los datos 0-RTT:

  • No es forward secret, porque solo está protegido por claves derivadas de la clave precompartida.
  • No tiene protección contra reproducción en diferentes conexiones, porque los datos 0-RTT no dependen del valor aleatorio del servidor. Dentro de una misma conexión, no puede duplicarse.

Defensa contra reproducción, capa por capa

Capa de transporte y TLS. TLS 1.3 describe tres técnicas en la sección 8: tickets de un solo uso, grabar mensajes ClientHello y verificaciones de frescura (comparando la edad del ticket obfuscado con el tiempo transcurrido por el servidor). Cada una intercambia estado o tolerancia de reloj contra protección, y ninguna elimina completamente la reproducción: la reducen y limitan.

Capa de QUIC. La sección 9.2 del RFC 9001 dice que el estado de transporte de QUIC no se ve afectado por la reproducción, ya que el procesamiento de marcos es idempotente, pero la reproducción sí afecta lo que hace la aplicación con los datos. Señala que los tickets de sesión y tokens de validación de dirección no deben llevar semántica de aplicación, y que aceptar 0-RTT implica un costo adicional. La opción más efectiva de defensa es deshabilitar 0-RTT.

Capa HTTP. RFC 8470 proporciona herramientas a nivel HTTP:

  • Un intermediario que recibe una solicitud en datos tempranos la marca con un encabezado Early-Data: 1 al reenviarla.
  • Un origen que juzga demasiado alto el riesgo de reproducción responde con 425 Too Early.
  • Los clientes que usan datos tempranos deben reintentar una solicitud que recibe un 425, y los intermediarios deben reenviar un 425 para solicitudes que portaron el encabezado Early-Data.
  • Si el procesamiento de una solicitud no tiene efectos secundarios que cambien estado, las consecuencias de la reproducción generalmente no son significativas.

El enfoque de Cloudflare es un punto de referencia útil: su soporte de 0-RTT cubre solicitudes GET, HEAD y OPTIONS, no POST, añade un encabezado Early-Data: 1 para que los orígenes reconozcan solicitudes tempranas, y señala que las solicitudes 0-RTT pueden ser reproducidas, por lo que debes evaluar si ese riesgo encaja en tus requisitos.

Flujo de decisión en el servidor

Solicitud de ClientHello con PSK + datos 0-RTT
        |
        v
¿Ticket válido y no expirado? ----- no ---e Rechazar 0-RTT, realizar handshake normal 1-RTT
        | sí
        v
Verificación de reproducción (almacén de tickets de un solo uso, registro de ClientHello o edad)
        | pasa                       \ falla
        v                               ---e Rechazar 0-RTT, realizar handshake normal 1-RTT
Aceptar early_data en EncryptedExtensions
        |
        v
Política por solicitud en la capa de aplicación / HTTP
   método seguro (GET, HEAD, OPTIONS)  -----e procesar inmediatamente
   cualquier cambio de estado           -----e 425 Too Early, reintentar después del handshake

Middleware en Go para 425 Too Early

Si tu relay reenvía solicitudes con Early-Data: 1, la aplicación puede rechazar métodos que cambian estado:

package main

import "net/http"

// rejectUnsafeEarlyData responde con 425 Too Early (RFC 8470) cuando una solicitud
// recibida como datos tempranos tiene un método que cambia estado.
func rejectUnsafeEarlyData(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Early-Data") == "1" && !isSafeMethod(r.Method) {
			w.Header().Set("Retry-After", "0")
			http.Error(w, "reintentar después de que termine el handshake", http.StatusTooEarly)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func isSafeMethod(m string) bool {
	switch m {
	case http.MethodGet, http.MethodHead, http.MethodOptions:
		return true
	}
	return false
}

Una prueba basada en tablas que cubre GET, HEAD, POST y PUT con y sin el encabezado pasa. La lista permitida está deliberadamente limitada a métodos que son seguros, no solo idempotentes: un PUT puede ser idempotente y aún así ser inseguro para reenvío (por ejemplo, reenvío de una escritura obsoleta después de una más reciente).

Dónde realmente ayuda 0-RTT en un túnel

0-RTT se aplica a la conexión que se reanuda, lo que aclara dónde vale la pena:

  • Reconexiones agente-a-relay. Un agente de túnel que reanuda tras dormir o un cambio de red puede reanudar su sesión sin esperar un handshake completo. Guarda lo que vaya en 0-RTT en marcos de control idempotentes (“reanudar sesión X”). Espera a que termine el handshake para cambios de estado, como registrar una nueva ruta o rotar credenciales.
  • Clientes públicos en el borde vía HTTP/3. Aplica las reglas de capa HTTP arriba. Las entregas de webhook suelen ser POST, que Cloudflare excluye de su soporte 0-RTT.
  • No es una solución gratuita para direcciones. Cloudflare señala que, con QUIC, las solicitudes 0-RTT pueden procesarse antes de validar la dirección fuente del cliente. El RFC 9001 sección 9.3 cubre el riesgo de amplificación: los servidores limitan lo que envían a tres veces los bytes recibidos hasta verificar la dirección.

La migración de conexión es una característica complementaria. QUIC identifica conexiones por IDs de conexión en lugar del cuádruple dirección 4-tuple, así que un cliente cuya asignación NAT o IP cambia puede continuar la misma conexión tras validación de ruta, sin un nuevo handshake (RFC 9000, sección 9).

Bloques estándar para UDP y IP sobre HTTP/3

Si tu túnel transporta paquetes UDP o IP, no necesitas inventar una capa de enmarcado. RFC 9298 define el proxy de UDP en HTTP, RFC 9484 define el proxy de IP en HTTP (octubre 2023), y ambos usan la extensión de datagramas no confiables de QUIC (RFC 9221). Usar formas estándar permite que pilas HTTP/3 y middleboxes entiendan tu tráfico.

Lista de verificación de despliegue

  • Por defecto, 0-RTT desactivado, habilítalo solo para operaciones de control en plano de control que sean idempotentes y nombradas.
  • Usa tickets de un solo uso o un registro ClientHello donde puedas compartir estado; aplica verificaciones de frescura donde no puedas.
  • Marca las solicitudes de datos tempranos reenviadas con Early-Data: 1, y respeta 425 de extremo a extremo.
  • Nunca pongas credenciales, pagos o cambios de estado en 0-RTT.
  • Rota las claves de cifrado de tickets regularmente, y limita la duración del ticket por debajo del techo de siete días de TLS 1.3.
  • Planifica un fallback TCP/TLS para redes que filtren UDP.

Referencias

Continue from this article into the most relevant product guides and workflows.

Related Topics

#zero-copy local tunnels#linux splice system call#vmsplice performance#socket buffer optimization#high-throughput file transfers#rust tunnel client#go packet forwarding#user-space bottlenecks#carrier-grade nat traversal#cgnat udp hole punching#port control protocol pcp#dynamic port mapping#residential router traversal#quic local tunnels#zero-rtt connection resumption#tls 1.3 handshake optimization#ephemeral developer tunnels#low-level networking protocols#edge architecture#kernel bypass networking#system call overhead reduction#tcp socket multiplexing#network socket programming#asynchronous io rust#io_uring vs splice#high-performance proxy architecture#reverse proxy tunneling#zero trust edge networks#udp hole punching techniques#nat traversal strategies#session resumption early data#quic transport protocol#packet forwarding latency#network performance tuning#linux kernel networking#network stack optimization#system programming go#distributed systems architecture#proxy server performance#bandwidth optimization#low latency networking#secure tunnel protocols#NAT mapping lifecycle#network address translation bypass#stateful packet inspection bypass#tunnel client engineering#network engineering blog#advanced linux kernel features#tcp ip stack tuning#concurrent proxy design

Keep building with InstaTunnel

Read the docs for implementation details or compare plans before you ship.

Share this article

More InstaTunnel Insights

Discover more tutorials, tips, and updates to help you build better with localhost tunneling.

Browse All Articles