Una VPN no es un producto. Son dos tareas atornilladas juntas, y tu máquina ya tiene un programa para cada una.
La primera tarea es fabricar un enlace virtual — una interfaz que parece una tarjeta de red, coge paquetes IP y los entrega en otro sitio. La segunda es transportar los bytes de ese enlace de un extremo al otro. Compra un aparato VPN y compras las dos tareas con una licencia grapada encima; hazlo tú y cada una es un programa ya instalado. Hay dos formas de hacer la primera tarea en Linux, y este artículo construye las dos: pppd, que trae todo un protocolo de enlace — direcciones negociadas, una comprobación de vida, tramado — y un dispositivo tap, que no trae nada y es un agujero en el núcleo por el que empujas tramas. Compromisos distintos, no competidores, y la segunda mitad los pone uno al lado del otro.
Ese protocolo es PPP, y la razón de que esto funcione está escrita en su primer párrafo: PPP es un protocolo de enlace para enlaces punto a punto1, y no especifica de qué está hecho el enlace. Un módem y una línea telefónica fueron la respuesta original. Nunca fueron la única. PPTP metió PPP dentro de GRE2. L2TP lo metió dentro de UDP3. PPPoE lo metió dentro de tramas Ethernet. Cada uno de ellos es el mismo protocolo con un mensajero distinto, y ninguno necesitó cambiar PPP para hacerlo.
Así que la pregunta no es si puedes ejecutar PPP sobre un socket TCP o UDP, sino cuál, y qué cuesta. La respuesta corta, con las cuentas a la vista: usa UDP. Ejecutar un protocolo de flujo dentro de un flujo fiable es el único error aquí que se ve bien en tu mesa y se desmonta en un enlace real.
Una VPN Son Dos Tareas. Ya Tienes Las Dos.
Quítale el marketing a cualquier túnel y pasan tres cosas: algo presenta una interfaz virtual y convierte paquetes en un flujo de bytes, algo transporta ese flujo por una red que ya funciona, y algo lo cifra, o nada lo hace.
PPP hace la mitad del enlace como es debido — negociación de direcciones, una comprobación de vida, compresión de cabeceras, varios protocolos de capa de red sobre un enlace, un paso de autenticación si lo quieres — un montón de trabajo terminado en /usr/sbin sin hacer nada. Lo que no hace es preocuparse por el portador: dale un descriptor de fichero que mueva bytes en ambos sentidos y funciona encima de él. Netcat es un programa cuyo propósito entero es ser ese descriptor de fichero.
El cifrado es la parte que nadie te da hecha, y es la parte en la que se gasta la mayoría de este artículo, porque netcat no tiene respuesta para ella y fingir lo contrario es como la gente acaba construyendo cosas que no debería.
Las Fases, Y Por Qué Van En Este Orden
Todo lo de abajo se construye por fases, y cada una añade una cosa a la anterior. Eso no es un recurso didáctico. Es como deberías construirlo, porque cuando la fase seis se rompa necesitas saber si la fase dos sigue funcionando, y eso solo lo sabes si la fase dos fue alguna vez algo que ejecutaste por separado.
El enlace mismo se construye igual — el razonamiento detrás de la rampa de un segundo está más abajo: un túnel se levanta en crudo, demuestra que puede llevar una trama, y solo entonces se pone listo. Una construcción que lo enciende todo a la vez falla como un bloque, y te pasas la tarde adivinando qué capa lo hizo.
Lo Que pppd Quiere En Realidad Es Un Descriptor De Fichero
pppd se escribió para puertos serie, así que la lectura ingenua es que necesita uno. No lo necesita. Necesita un dispositivo de terminal, y se lo fabrica él mismo:
pty script — Specifies that the command
scriptis to be used to communicate rather than a specific terminal device. Pppd will allocate itself a pseudo-tty master/slave pair and use the slave as its terminal device. The script will be run in a child process with the pseudo-tty master as its standard input and output.4
Léelo otra vez — la construcción entera está ahí. pppd fabrica un pseudoterminal, se queda el esclavo y le entrega el maestro a un comando como stdin y stdout. Lo que ese comando haga con los bytes no es asunto de pppd. Ejecuta nc ahí y se van por un socket.
pty hereda el lado maestro como stdin y stdout. Netcat copia stdin al socket y el socket a stdout, así que las dos instancias de pppd se hablan a través del par de pty y de la red sin que ninguna sepa que existe un socket.Hay una segunda vía, notty, que usa el stdin y el stdout del propio pppd. Pero lanza un proceso de trasvase de caracteres por el que pasa cada byte, así que «increases the latency and CPU overhead»4. Usa pty salvo que tengas una razón para no hacerlo.
Tres hechos prácticos antes del primer comando, todos comprobados en la máquina en la que se escribió esto — Fedora 44, ppp-2.5.1-7.fc44:
$ pppd --version
pppd version 2.5.1
$ ls -l /usr/bin/pppd /dev/ppp
-rwxr-xr-x. 1 root root 393784 Jan 17 2026 /usr/bin/pppd
crw-------. 1 root root 108, 0 Sep 14 08:34 /dev/ppp
$ pppd noauth nodetach pty 'true'
pppd: using the noauth option requires root privilege
Necesita root. No hay forma de rodearlo. El binario no es setuid en Fedora, /dev/ppp está en modo 0600 y pertenece a root, y las opciones que necesitas son privilegiadas de todos modos. Esto es algo que ejecutas como root o bajo un fichero de unidad, no algo que un usuario hace a la ligera. Eso importa más adelante, cuando lleguemos a lo que significa para tu política de salida.
El lado del núcleo es modular. ppp_generic hace la interfaz, ppp_async el tramado con relleno de bytes que necesita un pty, y ppp_deflate/bsd_comp/ppp_mppe la compresión y el cifrado; se cargan bajo demanda. Si a una imagen de contenedor recortada le falta ppp_async, el enlace se levanta y no lleva nada — media hora miserable si no sabes dónde mirar.
Las líneas de control de módem no existen en un pty. Sin pin de detección de portadora, pppd espera una portadora que nunca llega. El arreglo es una palabra, local, que le dice que ignore las líneas de control de módem4. Déjala fuera y no pasa nada, sin un error que merezca la pena leer.
Netcat No Es Un Solo Programa
Antes de la construcción, la trampa que más tiempo come: «netcat» son al menos cuatro programas con banderas incompatibles, y cuál te toca depende de tu distribución. En esta máquina /usr/bin/nc es un enlace simbólico a /usr/bin/ncat, la reescritura de Nmap. Así que un comando copiado de una página wiki de hace quince años falla por razones que no tienen nada que ver con PPP.
| Implementación | Escuchar en un puerto | UDP | Seguir en pie tras irse un par |
|---|---|---|---|
| Ncat (Nmap) | ncat -l 443 | -u | -k / --keep-open |
| OpenBSD netcat | nc -l 443 | -u | -k |
| Traditional netcat | nc -l -p 443 | -u | no |
| GNU netcat | nc -l -p 443 | -u | no |
Así que comprueba cuál tienes antes de culpar a pppd:
readlink -f "$(command -v nc)"
nc --version 2>&1 | head -1 || nc -h 2>&1 | head -1
Abajo usaré ncat de forma explícita. Es el que trae TLS incorporado, cosa que importa más adelante, y ser explícito significa que los comandos no significan otra cosa en silencio en tu máquina.
Fase Uno: La Construcción TCP, Porque Es Lo Que Todo El Mundo Prueba Primero
Un extremo escucha, otro conecta. Las direcciones de aquí son del rango de documentación, así que pégalas tal cual en un laboratorio y no se daña nada enrutable. El puerto es 443 en todo el artículo, y es deliberado: el 443 saliente está abierto por defecto en casi cualquier red. Envuelve el portador en TLS un par de fases más abajo y el tráfico que lleva es indistinguible de cualquier sesión HTTPS. Atar un servicio de escucha ahí necesita root, y el extremo lejano — la máquina del propio atacante, o un relé — lo tiene. Ese es el argumento entero de la salida metido en un número de puerto, y volveré sobre él.
En el extremo que escucha:
pppd nodetach noauth local passive \
nodefaultroute noipdefault \
192.0.2.1:192.0.2.2 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat --listen --keep-open 443'
En el extremo que conecta:
pppd nodetach noauth local \
nodefaultroute noipdefault \
192.0.2.2:192.0.2.1 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat 198.51.100.10 443'
Cada palabra de ahí hace un trabajo, y una de ellas, noauth, hace un daño silencioso. El túnel no tiene control de acceso, y eso tiene su propia sección más adelante.
noauth: quita la autenticación del par, así que quien escucha acepta a quien llegue. nodefaultroute y noipdefault impiden que el enlace reescriba tu enrutado en silencio, local hace que llegue a levantarse sobre un pty, y el par local:remoto fijado impide que pppd acepte otra respuesta durante IPCP. El extremo que conecta es la misma línea sin passive.Levanta los dos extremos y tienes un ppp0 en cada uno, una ruta punto a punto a la dirección lejana, y una interfaz que puedes hacer ping, enrutar y pasar por tcpdump. Eso es una VPN — sin instalar un paquete, sin configurar un demonio, sin intercambiar una clave, que es el problema, y volveré sobre él.
Qué Negoció, Y Cómo Mirarlo
Añade debug y pppd registra el intercambio del protocolo de control, que merece la pena leer una vez aunque no lo leas nunca más. El enlace se levanta por etapas, y cada etapa puede fallar por su cuenta.
Las dos herramientas útiles de depuración ya están instaladas y nadie las usa:
# registra cada trama en ambos sentidos a un fichero
pppd ... debug record /tmp/ppp-trace
# después léelo en una forma legible
pppdump -h /tmp/ppp-trace | less
record escribe una captura con marcas de tiempo de cada byte, pppdump la convierte en algo legible4, y con tcpdump -ni ppp0 ves los dos lados — el tramado de debajo y los paquetes de encima.
La Trama En El Cable, Y Por Qué 0x7E Está En Todas Partes
PPP sobre una línea serie — y un pty lo es en lo que a pppd respecta — usa tramado HDLC asíncrono, y entenderlo es la diferencia entre ajustar esto y adivinar. Cada trama empieza y termina con el mismo byte: «Each frame begins and ends with a Flag Sequence, which is the binary sequence 01111110 (hexadecimal 0x7e)»5. Si 0x7e marca una frontera, no puede aparecer dentro de una trama, así que se escapa. También el byte de escape 0x7d, y cualquier otra cosa que pida uno de los dos extremos.
La regla de escape es exacta: «Each Flag Sequence, Control Escape octet, and any octet which is flagged in the sending Async-Control-Character-Map (ACCM), is replaced by a two octet sequence consisting of the Control Escape octet followed by the original octet exclusive-or’d with hexadecimal 0x20»5.
Esa última parte es el mando de ajuste. La ACCM son 32 bits, uno por carácter de control, y un 1 significa «escapa este». Pídelos todos y, en cargas cifradas donde los bytes son en la práctica aleatorios, más o menos uno de cada ocho bytes está por debajo de 0x20 — un 12 % de sobrecarga para nada.
El pppd moderno ya hace lo correcto aquí. Lee la página de manual en vez del folclore:
If no asyncmap option is given, the default is zero, so pppd will ask the peer not to escape any control characters.4
Así que asyncmap 0 es el valor por defecto, no un truco, y un socket es un camino limpio de 8 bits que no necesita escapes más allá de los dos bytes obligatorios. Déjalo en paz; pon bits solo cuando algo por el medio se coma de verdad los caracteres de control — un servidor de terminales, un concentrador serie, un proxy de consola malo. La secuencia de verificación de trama del final es un CRC de 16 bits, y es lo que hace que funcione la construcción UDP — que viene ahora.
TCP Sobre TCP Es El Portador Equivocado
La construcción de arriba funciona. En un banco de laboratorio, sobre loopback o una LAN tranquila, funciona de maravilla, que es justo por lo que la gente la pone en producción.
Después se encuentra con un camino real con pérdida real. Se cae de una forma que parece cualquier cosa menos lo que es.
El problema son dos temporizadores de retransmisión independientes apilados uno sobre otro, con el exterior escondiéndole la pérdida al interior. Olaf Titz escribió la explicación definitiva, y abre precisamente con esta construcción:
A frequently occurring idea for IP tunneling applications is to run a protocol like PPP, which encapsulates IP packets in a format suited for a stream transport (like a modem line), over a TCP-based connection. […] Unfortunately, it doesn’t work well. Long delays and frequent connection aborts are to be expected.6
Esta es la forma. Los dos TCP fijan un temporizador de retransmisión a partir de su tiempo de ida y vuelta. Cuando el portador pierde un segmento, retransmite, así que los datos de la conexión interior llegan tarde. Y el TCP interior, que no ve el portador, lee «tarde» como congestión y retransmite también. Ahora el portador tiene el original y un duplicado que entregar por un enlace que ya está perdiendo paquetes. El temporizador interior se dobla, la cola exterior crece, y el rendimiento se hunde mucho antes que el enlace. Nada en los registros dice por qué.
Esto no es un punto sutil de eficiencia. Es la diferencia entre un túnel que se degrada con elegancia y otro que deja de pasar tráfico con quizá un 2 % de pérdida mientras ping por ese mismo camino sigue viéndose bien — la razón para usar UDP, y para guardar TCP en la reserva solo para un camino que no vaya a pasar ninguna otra cosa.
Así que usa UDP — como valor por defecto, no como preferencia.
Fase Dos: La Construcción UDP, Que Es El Cimiento
El mismo pppd, otro portador. Lo único que cambia es el comando de pty.
Extremo que escucha:
pppd nodetach noauth local passive \
nodefaultroute noipdefault \
192.0.2.1:192.0.2.2 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat --udp --listen 198.51.100.10 443'
Extremo que conecta:
pppd nodetach noauth local \
nodefaultroute noipdefault \
192.0.2.2:192.0.2.1 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat --udp 198.51.100.10 443'
Dos cosas de un servicio de escucha UDP que te van a pillar, y ninguna es un problema de PPP.
Quien escucha no puede responder hasta que le hablan. Un socket UDP no tiene conexión que aceptar, así que netcat no puede saber a dónde mandar las respuestas hasta que llega un datagrama. Se engancha a la primera dirección y puerto de origen que oye y le habla a eso. Esto significa que el extremo que conecta tiene que hablar primero — cosa que pppd hace solo, porque el extremo no pasivo empieza a disparar peticiones de configuración LCP de inmediato. También significa que si el puerto de origen del cliente cambia, el portador está hablando en silencio con el sitio equivocado. Detrás de un NAT con un tiempo de espera UDP corto, eso es un túnel que se muere cada pocos minutos sin razón visible.
--keep-open no hace lo que quieres en UDP. El -k de Ncat mantiene un servicio de escucha TCP aceptando después de que un par se vaya. En UDP no hay nada que aceptar, así que recuperarse significa reiniciar el portador — para lo que están persist y holdoff, más abajo.
Las dos son argumentos a favor de socat, que trata a los pares UDP con más cuidado, y a favor de un supervisor en vez de confiar en que se quede en pie.
Por Qué PPP Sobrevive A Un Datagrama Perdido
La objeción evidente a un portador UDP es que PPP espera un flujo de bytes y UDP no lo es: los datagramas llegan enteros o no llegan, y pueden llegar desordenados. Funciona igualmente, por dos bytes que el tramado ha llevado desde siempre.
PPP sobre HDLC asíncrono se diseñó para una línea que corrompe bytes. Cada trama lleva una secuencia de verificación de trama de 16 bits; una trama que falla se descarta, y el siguiente byte de bandera resincroniza al receptor. El desorden es más raro que la pérdida y produce el mismo resultado: una FCS mala, una trama descartada, una resincronización.
Así que un datagrama perdido cuesta una trama PPP — un paquete IP, la condición normal de toda red que se ha construido jamás. El dueño del paquete retransmite, y el control de congestión ve una pérdida real y responde correctamente. Ese es el argumento entero a favor de UDP: deja que el tráfico de dentro del túnel se entere de la verdad sobre el camino.
Eso sí, no dejes que las tramas crezcan hasta necesitar que el portador las fragmente. Un fragmento perdido mata entonces el datagrama entero y tu tasa de pérdida efectiva se multiplica. Mantén la MTU de PPP bien por debajo de la MTU del camino.
Direcciones, Rutas, Y Hacer IPv6 Como Es Debido
ppp0 es una interfaz punto a punto — sin subred, sin ARP — así que local:remoto es todo el direccionamiento y enrutas por encima de forma explícita. Para un solo equipo que alcanza una red detrás del extremo lejano:
# en el cliente, una vez levantado el enlace
ip route add 203.0.113.0/24 via 192.0.2.1 dev ppp0
Para que el extremo lejano reenvíe por cuenta del cliente, los dos pasos de siempre:
sysctl -w net.ipv4.ip_forward=1
nft add rule inet nat postrouting oifname "eth0" ip saddr 192.0.2.2/32 masquerade
proxyarp hará que el servidor responda al ARP del cliente en su propio segmento4, lo cual está bien hasta que un segundo cliente quiere lo mismo. Haz entonces la parte que casi todo el mundo se salta. Ejecuta IPv6 por encima — un enlace punto a punto sin NAT, sin dominio de difusión y sin escasez de direcciones es el sitio más fácil de tu red para hacer IPv6 como es debido:
pppd nodetach noauth local \
+ipv6 ipv6 ::1,::2 \
nodefaultroute noipdefault \
192.0.2.2:192.0.2.1 \
pty 'ncat --udp 198.51.100.10 443'
+ipv6 habilita IPV6CP; ipv6 <local>,<remoto> fija los dos identificadores de interfaz de 64 bits4, el enlace se levanta con enlace-local, y le pones un /64 global encima7. IPV6CP es independiente de IPCP, así que noip no lleva más que IPv6 — algo razonable de construir en 2026, en una sola palabra.
Mantenerlo En Pie Cuando El Portador Muere En Silencio
Este es el fallo que se come una tarde, así que le toca su propia sección.
Un portador TCP que muere mal — el extremo lejano apagado, una entrada de tabla NAT caducada, una caja intermedia que dejó de reenviar — no se cierra. No hay FIN, ni RST, ni nada. Netcat se queda ahí sujetando un socket que no entregará otro byte jamás, pppd se queda ahí sujetando un pty que no verá otra trama jamás, e ip link informa tan contento de que ppp0 está UP. Un portador UDP no tiene estado de conexión ninguno, así que nunca se entera de nada.
PPP trae la respuesta incorporada, y viene desactivada:
lcp-echo-interval 10 lcp-echo-failure 3
Eso manda un eco LCP cada diez segundos y tira el enlace después de tres sin respuesta4 — treinta segundos para pillar un portador muerto, en exactamente el caso que nombra la página de manual, «no hardware modem control lines»4, que es todo pty que se haya fabricado. Después decide qué pasa a continuación:
persist maxfail 0 holdoff 5
lcp-echo-interval 10 lcp-echo-failure 3 es el detector: un eco cada diez segundos, enlace caído tras tres fallidos. persist maxfail 0 holdoff 5 es la reconstrucción: reiniciar, no rendirse nunca, esperar cinco segundos para que un camino inestable no sea una bomba de bifurcación — y pppd vuelve a ejecutar el comando de pty, así que vienen con él un netcat y un socket nuevos. Pon el eco en los dos extremos; un solo supervisor, una unidad de systemd con Restart=always, gana a dos procesos discutiendo. Pon ip route en /etc/ppp/ip-up.d/ para que el enrutado vuelva con el enlace.No Tiene Cifrado Ni Autenticación
Todo lo de arriba es un túnel que funciona. No es uno seguro, y el hueco no es un detalle.
No hay cifrado. No es cifrado débil. Es ninguno. Cada paquete que metas por aquí va en claro por el cable, envuelto en una trama HDLC que cualquier herramienta de captura descodifica al verla. tcpdump te enseñará el contenido del túnel de otro con la misma facilidad que el tuyo.
No hay autenticación del par. noauth lo dice. Un servicio de escucha netcat acepta lo que llegue al puerto: la primera conexión o el primer datagrama desde donde sea. Quien llegue primero se lleva un enlace enrutado hacia dentro de tu red. PPP sí tiene autenticación (PAP en claro, CHAP como desafío-respuesta8), y las dos demuestran quién es el par sin cifrar ni un byte de lo que viene después: CHAP aquí te da un túnel que sabe con quién habla y sigue publicando el contenido a cualquiera que esté en el camino.
Hay una opción de cifrado en la familia PPP — MPPE9, el módulo que está en ppp_mppe.ko. No eches mano de él: es RC4 con clave derivada del intercambio MS-CHAPv2, roto en público desde hace más de una década, y la razón de que PPTP esté muerto. Construir un túnel nuevo sobre él en 2026 elige un cifrador roto conocido por encima de uno que funciona y no cuesta nada.
Así que el resumen honesto hasta aquí: un enlace enrutado sin confidencialidad y sin control de acceso. Bien para un laboratorio, bien dentro de un enlace que ya está cifrado, mal en cualquier otro sitio. El arreglo es cifrar el portador — las dos secciones siguientes, y la razón para usar ncat en vez del netcat que traiga tu distribución.
Fase Tres: Envolver El Portador En TLS
La respuesta limpia deja pppd exactamente como está y sustituye el portador por uno que hace TLS. pppd no se entera nunca de que algo cambió.
Primero el certificado. Uno autofirmado basta mientras el cliente lo verifique. Una sesión TLS sin verificar es una conversación cifrada con alguien a quien no has identificado, y eso no detiene a nadie:
openssl req -x509 -newkey rsa:4096 -days 825 -nodes \
-keyout tunnel.key -out tunnel.crt \
-subj "/CN=tunnel.example.net" \
-addext "subjectAltName=DNS:tunnel.example.net"
Ncat
Ncat trae TLS incorporado. También encadena a través de un proxy, --proxy equipo:puerto --proxy-type http|socks4|socks510, así que el portador puede terminar en un relé en vez de en el extremo lejano del túnel. Ese es el punto sobre el que gira la sección de la salida. Extremo que escucha:
pppd nodetach noauth local passive \
nodefaultroute noipdefault 192.0.2.1:192.0.2.2 \
lcp-echo-interval 10 lcp-echo-failure 3 mtu 1400 mru 1400 \
pty 'ncat --listen --keep-open --ssl --ssl-cert tunnel.crt --ssl-key tunnel.key 443'
Extremo que conecta:
pppd nodetach noauth local \
nodefaultroute noipdefault 192.0.2.2:192.0.2.1 \
lcp-echo-interval 10 lcp-echo-failure 3 mtu 1400 mru 1400 \
pty 'ncat --ssl --ssl-verify --ssl-trustfile tunnel.crt tunnel.example.net 443'
--ssl-verify es la palabra que importa. Sin ella, --ssl te da cifrado frente a quien escucha de forma pasiva y nada frente a quien conteste el puerto primero; con ella, Ncat verifica la confianza y el nombre de dominio contra el fichero de confianza11. Eso sí, Ncat no comprueba certificados de cliente en el lado servidor, así que el servidor no puede identificar al cliente. Combínalo con CHAP, o usa uno de los dos siguientes.
Stunnel
El envoltorio tradicional, y el que hace la autenticación mutua como es debido:
[ppp]
accept = 443
connect = 127.0.0.1:6001
cert = /etc/stunnel/tunnel.crt
key = /etc/stunnel/tunnel.key
CAfile = /etc/stunnel/clients.crt
verify = 2
verify = 2 exige un certificado de cliente firmado por una CA que esté en CAfile — el control de acceso que la construcción con netcat nunca tuvo. El cliente ejecuta stunnel en modo cliente, y el comando de pty de pppd conecta al lado local en claro.
Socat
socat hace el asunto entero en un proceso por extremo, con la verificación activada por defecto:
# extremo que escucha
pppd ... pty 'socat - OPENSSL-LISTEN:443,reuseaddr,cert=tunnel.pem,cafile=clients.crt,verify=1'
# extremo que conecta
pppd ... pty 'socat - OPENSSL:tunnel.example.net:443,cafile=tunnel.crt,verify=1'
socat no está instalado en la máquina en la que se escribió esto, así que eso sale de su documentación — comprueba tus propias banderas. Es el que yo cogería, porque es el único de los cuatro que además habla DTLS.
Openssl, cuando no hay otra cosa
openssl está en toda máquina que tenga TLS, y s_server/s_client llevarán una tubería:
# extremo que escucha
pppd ... pty 'openssl s_server -quiet -accept 443 -cert tunnel.crt -key tunnel.key'
# extremo que conecta
pppd ... pty 'openssl s_client -quiet -verify_return_error -CAfile tunnel.crt -connect tunnel.example.net:443'
-quiet calla el cartel que si no acabaría dentro de tu flujo PPP, y -verify_return_error hace que un fallo de verificación cierre la conexión en vez de avisar y seguir. Son herramientas de depuración portándose como tales, pero en una máquina donde no puedes instalar nada te levantan un enlace.
Fase Cuatro: DTLS, Porque El Portador Debería Seguir Siendo UDP
Aquí viene la parte incómoda, y la razón de que esta sección vaya aparte. Todas las opciones de TLS de arriba van sobre TCP, así que envolver el portador en TLS deshace el argumento a favor de UDP y te devuelve el derretimiento. El cifrado y el transporte correcto no deberían ser un trueque. DTLS es TLS sobre datagramas, y es lo que quieres: conserva la protección de la capa de registro, tira las garantías de orden y retransmisión, y deja perdidos los paquetes perdidos, que es exactamente lo que la secuencia de verificación de trama de PPP está hecha para absorber.
Ncat no puede. socat y openssl sí:
# extremo que escucha
pppd ... pty 'socat - OPENSSL-DTLS-LISTEN:443,cert=tunnel.pem,cafile=clients.crt,verify=1'
# extremo que conecta
pppd ... pty 'socat - OPENSSL-DTLS-CLIENT:tunnel.example.net:443,cafile=tunnel.crt,verify=1'
Y con openssl a solas, donde -dtls elige cualquier versión de DTLS:
# extremo que escucha
pppd ... pty 'openssl s_server -quiet -dtls -accept 443 -cert tunnel.crt -key tunnel.key'
# extremo que conecta
pppd ... pty 'openssl s_client -quiet -dtls -verify_return_error -CAfile tunnel.crt -connect tunnel.example.net:443'
Vigila el tamaño de la trama. Un registro DTLS no se puede fragmentar como un registro TLS se reparte por un flujo TCP, así que todo tiene que caber de una vez en la MTU del camino.
Ajustar La Construcción PPP: MTU, Compresión, Y Qué Ayuda De Verdad
Cuatro mandos, todos opciones de pppd — la construcción con tap de la sección siguiente no tiene ninguno, porque no tiene nada de la maquinaria que configuran.
novj por encima de la velocidad de un módem. Ahorra un error de redondeo, cuesta CPU por paquete y se rompe bajo pérdida. Apaga deflate/bsdcomp: la carga ya está cifrada, y comprimir a través de una frontera de seguridad es un ataque, no una función.Nada de eso hace el túnel más rápido, y la razón importa, porque a PPP se le culpa de ello y no debería. PPP no es el cuello de botella. PPPoE lleva 2 Gbit con el mismo demonio, porque su camino de datos no sale nunca del núcleo — ppp_generic y pppoe hacen el tramado y el reenvío, y pppd solo se ocupa del plano de control. Lo que te cuesta aquí es el pty: cada byte cruza al espacio de usuario, atraviesa netcat, entra en un socket y vuelve, un viaje de ida y vuelta que PPPoE no hace nunca. Eso es del pseudoterminal, no de PPP.
Que es un buen momento para mirar la otra forma de hacer esto, donde no hay pty ninguno.
La Otra Forma: Un Dispositivo Tap, Y Nada De PPP
Todo hasta aquí ha usado PPP para la mitad del enlace. Hay una segunda forma de fabricar una interfaz virtual, y no necesita protocolo ninguno.
El controlador TUN/TAP del núcleo te entrega una interfaz y un descriptor de fichero atados entre sí: escribe un paquete en el descriptor y aparece en la interfaz como si viniera de un cable; lee y obtienes un paquete que el núcleo quería mandar. Esa es la interfaz entera12, y está en Linux desde 1999.
ip tuntap add dev tap0 mode tap user damien group damien
ip link set tap0 mtu 1400 up
ip addr add 192.0.2.1/30 dev tap0
Dos detalles de ese primer comando valen más de lo que parecen.
user damien hace el dispositivo persistente y sin privilegios. Creado así sobrevive al proceso que lo usa, y un usuario nombrado puede abrirlo sin ser root. Root crea el dispositivo una vez; la cosa que palea tramas no necesita root para nada. Guarda esa idea para la sección de la salida.
mode tun es la otra mitad del mismo controlador, y es la que casi todo el mundo quiere en realidad. Tun lleva paquetes IP. Tap lleva tramas Ethernet. La diferencia importa lo bastante como para tener su propia sección abajo.
Lo que cedes frente a PPP es todo lo que PPP negocia: sin LCP, así que sin tamaño de trama acordado ni comprobación de vida; sin IPCP/IPV6CP, así que los dos extremos se configuran a mano; sin autenticación, sin compresión de cabeceras. Un dispositivo tap es un agujero en el núcleo, y el protocolo que va por él es el que tú pongas. Lo que ganas es cero sobrecarga de tramado, cero escapes, cero canal de control, y una interfaz que se puede puentear.
Tap Sobre UDP, Donde Un Datagrama Es Una Trama
Esta es la correspondencia más limpia del artículo entero, y sale sola del diseño. Un dispositivo tap es un dispositivo de datagramas — un read() devuelve exactamente una trama — y un socket UDP es un socket de datagramas, un sendto() por datagrama. Así que una trama entra en un datagrama, llega como trama, y no hay nada que delimitar, almacenar ni resincronizar. Pierde un datagrama y has perdido una trama, que es como se ve un paquete descartado de todos modos.
Con socat, cada extremo es un comando:
# extremo que escucha
socat TUN:192.0.2.1/30,tun-type=tap,tun-name=tap0,iff-up UDP-LISTEN:443
# extremo que conecta
socat TUN:192.0.2.2/30,tun-type=tap,tun-name=tap0,iff-up UDP:198.51.100.10:443
socat no está instalado en la máquina en la que se escribió esto, así que esos dos salen de su documentación y no de una ejecución aquí — comprueba los nombres de dirección de tu propia versión antes de fiarte. Merece la pena tenerlo: es la única herramienta de este artículo que hace tun, tap, TLS y DTLS en un solo proceso.
Donde no puedas instalar nada, el trabajo entero son unas cincuenta líneas sin más dependencias que la biblioteca estándar. El corazón, con IFF_NO_PI apagando la cabecera de cuatro bytes que el controlador antepondría si no:
TUNSETIFF, IFF_TAP, IFF_NO_PI = 0x400454CA, 0x0002, 0x1000 # IFF_TUN is 0x0001
def open_tap(name):
fd = os.open("/dev/net/tun", os.O_RDWR)
fcntl.ioctl(fd, TUNSETIFF, struct.pack("16sH", name.encode(), IFF_TAP | IFF_NO_PI))
return fd
# ... learn the peer from the first datagram (UDP has no accept), then shovel:
while True:
ready, _, _ = select.select([tap, sock], [], [])
if tap in ready and peer:
sock.sendto(os.read(tap, MTU), peer) # one read = one frame = one datagram
if sock in ready:
frame, src = sock.recvfrom(MTU)
peer = src # last speaker wins — see the note below
if frame:
os.write(tap, frame)
El fichero completo — el análisis de argumentos, IPv6 con getaddrinfo, el datagrama de longitud cero que le dice a un servicio de escucha UDP dónde responder, y la compresión que añade la sección siguiente — está en el paquete:
peer = src en cada datagrama es la parte que hay que leer dos veces. Quien mandó la última trama se convierte en el par. Cómodo detrás de un NAT cuyo puerto de origen no para de moverse, y una puerta abierta en una red no confiable, donde cualquiera que pueda mandar un datagrama al puerto se queda con el túnel. Bien dentro de una sesión DTLS, que es a donde va esto; mal por sí solo. La mitad del socket del script se probó aquí sobre loopback IPv6: llega quien abre, quien escucha aprende el par, una trama cruza y vuelve la respuesta; la mitad del tap necesita root, la única parte que no pude ejecutar.
Sobre TCP Tienes Que Inventarte El Tramado
Cambia ahora el portador por TCP y mira cómo aparece un problema entero que PPP resolvió en silencio en 1994.
TCP es un flujo de bytes sin fronteras de registro y sin promesa alguna sobre cómo se agrupan los bytes al llegar: dos tramas escritas seguidas pueden llegar en una lectura, o una trama en tres. El receptor tiene un montón de bytes sin idea de dónde acaba una trama, y un dispositivo tap solo acepta tramas enteras.
Así que sobre TCP escribes tu propio tramado. Dos bytes de longitud en big-endian delante de cada trama es la respuesta habitual:
# sending
sock.sendall(struct.pack("!H", len(frame)) + frame)
# receiving
def recv_exactly(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("carrier closed")
buf += chunk
return buf
length = struct.unpack("!H", recv_exactly(sock, 2))[0]
frame = recv_exactly(sock, length)
Eso funciona, y es estrictamente peor que la versión UDP. Es el problema de TCP sobre TCP otra vez; añade dos bytes y un bucle de reensamblado por trama; y no tiene vuelta atrás desde un error, porque un flujo con prefijos de longitud que pierde el sitio lee cada longitud siguiente de en medio de una trama. HDLC resincroniza en el siguiente 0x7E; esto no puede, salvo que reinventes HDLC peor. Tercer argumento a favor de UDP, y el más fuerte: sobre un portador de datagramas no hay problema de tramado, porque el portador ya trae la única función que necesitabas.
Tun O Tap: Capa 3 Salvo Que Necesites De Verdad Capa 2
El mismo controlador te da dos dispositivos, y la gente elige el equivocado sin parar porque un tutorial dijo tap.
| tun | tap | |
|---|---|---|
| Qué cruza | paquetes IP | tramas Ethernet |
| Sobrecarga por paquete | ninguna | cabecera Ethernet de 14 bytes |
| ARP, DHCP, difusión | no | sí, todo, por el túnel |
| Protocolos que no son IP | no | sí |
| Puede unirse a un puente | no | sí |
| Equivale a | un enlace punto a punto, como ppp0 | un cable de red |
Tun es un enlace enrutado — como la interfaz PPP de la primera mitad: dos direcciones, una ruta, paquetes que entran y salen. Tap es un cable Ethernet virtual, así que cada difusión, cada consulta ARP y cada trozo de ruido multicast del segmento cruza ahora tu túnel y quema ancho de banda.
Cambia una línea del script para cambiar de uno a otro:
IFF_TUN = 0x0001 # instead of IFF_TAP
Usa tap cuando necesites de verdad capa 2. Hay razones reales: un protocolo que no es IP, un latido de clúster que espera ver difusiones, un servidor DHCP que tiene que alcanzar clientes al otro lado del túnel, o puentear dos segmentos en uno.
Esto último es el caso habitual y con el que hay que tener cuidado:
ip link add br0 type bridge
ip link set tap0 master br0
ip link set eth1 master br0
ip link set br0 up
Ahora el segmento remoto es parte del tuyo local — sus difusiones, su árbol de expansión, su barullo de MAC y, si alguien fue descuidado, su servidor DHCP. Puentear dos sedes que las dos usan 192.168.1.0/24 es una mala tarde; una que da la vuelta al mismo segmento es una mala semana. Usa tun por defecto, echa mano de tap cuando puedas nombrar la cosa de capa 2 que necesitas, y puentea solo después de mirar qué está difundiendo en los dos lados.
Los Mismos Envoltorios, Un Proceso Por Extremo
La construcción con tap tiene el mismo agujero que la de PPP: el portador va en claro y quien escucha acepta a quien llegue primero. El arreglo es el mismo, y con socat se reduce a un comando por extremo, porque fabrica el dispositivo y termina la sesión DTLS en un solo proceso:
# extremo que escucha
socat TUN:192.0.2.1/30,tun-type=tap,tun-name=tap0,iff-up \
OPENSSL-DTLS-LISTEN:443,cert=tunnel.pem,cafile=clients.crt,verify=1
# extremo que conecta
socat TUN:192.0.2.2/30,tun-type=tap,tun-name=tap0,iff-up \
OPENSSL-DTLS-CLIENT:tunnel.example.net:443,cafile=tunnel.crt,verify=1
Esa es la construcción correcta más corta de este artículo. Una interfaz virtual, un portador de datagramas, autenticación mutua por certificado y cifrado. Dos comandos. Sin demonio, sin negociación de protocolo.
verify=1 no es opcional — el mismo punto que con --ssl-verify, y con el comportamiento de peer = src de arriba, una parte sin identificar está a un datagrama de adueñarse de tu túnel. Si te has quedado atado al script de Python, no le atornilles TLS: apúntalo a un puerto de loopback y pon el envoltorio delante, o usa socat. Un envoltorio TLS hecho a mano alrededor de un túnel hecho a mano son dos oportunidades de equivocarse en la parte interesante.
Fase Cinco: Comprimir El Flujo Con zstd
Hay una cosa más que merece la pena poner en la trastienda, y a diferencia de las opciones de compresión de pppd esta sí puede pagar: comprimir las tramas con zstd antes de que entren en el portador.
La palabra importante ahí es tramas, en plural. Comprime el flujo, no cada paquete por su cuenta. Es el mayor efecto medido de este artículo.
El tráfico de red es repetitivo de una forma que solo se ve entre paquetes — las mismas cabeceras, los mismos nombres de equipo, las mismas claves JSON, una y otra vez. Un compresor que empieza de cero en cada trama de 1400 bytes no ve nada de eso; uno que mantiene su ventana entre tramas lo ve todo.
En una Fedora actual esto no necesita instalar nada — Python 3.14 trajo zstd a la biblioteca estándar13; esta máquina tiene la 3.14.7 contra zstd 1.5.7:
from compression.zstd import ZstdCompressor, ZstdDecompressor # Python 3.14+
_c, _d = ZstdCompressor(level=1), ZstdDecompressor()
# FLUSH_BLOCK: emit everything so far, keep the window for the next frame.
out = _c.compress(frame, mode=ZstdCompressor.FLUSH_BLOCK)
frame = _d.decompress(out)
Un compresor por sentido, vivo durante toda la vida del enlace. Un FLUSH_BLOCK por trama, así que una trama sale en el momento en que llega sin nada almacenado, y el extremo lejano devuelve una trama por bloque.
Lo Que Vale La Ventana, Medido
Cifras de esta máquina. Las mismas tramas de 1400 bytes, el mismo nivel 1, y la única diferencia es si el compresor conserva su historial:
| Tráfico en el túnel | flujo, FLUSH_BLOCK | por paquete, FLUSH_FRAME | por paquete con diccionario entrenado |
|---|---|---|---|
| Llamadas de API repetitivas y telemetría | 5,0 % | 14,8 % | 5,3 % |
| Líneas de registro | 7,5 % | 24,2 % | 15,1 % |
| Texto plano y ficheros de configuración | 37,8 % | 51,3 % | 45,3 % |
| Bytes aleatorios, haciendo de TLS | 100 %+ | 100,7 % | — |
El rendimiento al nivel 1 dio 205 MB/s con texto y más de 1 GB/s con el tráfico repetitivo — cuanto más comprimible, más rápido, porque hay menos que codificar.
El tráfico de registro baja al 7,5 % de su tamaño original, frente al 24,2 % por paquete — el triple, los mismos datos, el mismo nivel, por una sola bandera. Y el nivel 1 es el nivel: el nivel 3 compró alrededor de un uno por ciento, el nivel 9 otro más mientras bajaba el rendimiento de 211 MB/s a 61.
La Pega, Y Es La Misma Pega Que Todo Lo Demás Aquí
Una ventana compartida significa que cada trama depende de las anteriores: pierde una y el historial del descompresor deja de coincidir, y nada posterior se descodifica. Así que la compresión de flujo necesita un portador que lo entregue todo en orden — TCP, o TLS sobre TCP, no UDP ni DTLS. Ese es el único argumento honesto a favor del portador TCP en todo el artículo. Si lo que cruza tu túnel es de verdad comprimible — syslog, replicación de base de datos en claro, telemetría, una API charlatana — una sesión TLS con compresión de flujo mueve un tercio de los bytes que movería un portador de datagramas, cosa que en un camino decente puede ganarle a la penalización por retransmisión. Mídelo con tu propio tráfico.
Sobre UDP, Donde Un Diccionario Hace El Trabajo De La Ventana
Donde el portador es UDP o DTLS, y debería serlo por defecto, no puedes mantener una ventana: cada datagrama se sostiene solo, lo que significa FLUSH_FRAME y la columna más floja de arriba. Un diccionario entrenado le da al compresor el contexto entre paquetes que habría tenido una ventana, sin dependencia ninguna entre datagramas:
# capture a few thousand real frames off the link first, one per file
zstd --train frames/* -o tunnel.dict --maxdict=110000
```[^zstddict]
```python
from compression.zstd import ZstdCompressor, ZstdDecompressor, ZstdDict
d = ZstdDict(open("tunnel.dict", "rb").read())
_c = ZstdCompressor(level=1, zstd_dict=d) # load it ONCE, not per frame
out = _c.compress(frame, mode=ZstdCompressor.FLUSH_FRAME)
Con el tráfico repetitivo eso llevó la compresión por paquete del 14,8 % al 5,3 %, recuperando el 96 % de la distancia hasta la compresión de flujo completa sin dejar de tolerar la pérdida; con líneas de registro el 55 % de la distancia, con texto general el 44 %.
Vienen tres reglas con ello. Los dos extremos tienen que cargar el mismo diccionario o no se descodifica nada — comprobado aquí, una trama comprimida con diccionario lanza ZstdError sin él. Entrena con una captura del tráfico real, porque un diccionario es una apuesta previa y una equivocada te cuesta: el diccionario de texto empeoró ligeramente otro texto. Y cárgalo una vez en un compresor de vida larga; pasarlo en cada llamada midió 5 MB/s, y no es una errata.
El Byte De Cabecera, Y La Rampa De Un Segundo
Dos cosillas que impiden que esto sea frágil.
Cada datagrama lleva una cabecera de un byte, y nombra el modo en vez de decir solo «comprimido»: 0x00 la trama tal cual, 0x01 una trama zstd autónoma, 0x02 un bloque de un flujo continuo. Un receptor puede entonces descodificar lo que haya elegido el extremo lejano sin estar configurado para coincidir, cosa que ya vale el byte por sí sola.
En modo trama, quien envía solo manda la forma comprimida cuando de verdad es más pequeña, porque comprimir no siempre gana: los bytes aleatorios salieron al 100,7 %, y un ACK de TCP de 64 bytes se comprime a 73 — los diez bytes de cabecera de zstd sobre un paquete sin nada que apretar. El recuento de paquetes en un enlace real lo dominan los paquetes pequeños, así que sin esa comprobación inflarías la mayoría de tu tráfico para encoger la minoría. En modo flujo manda siempre la forma comprimida, porque saltarse una dejaría las dos ventanas fuera de paso.
Y el enlace empieza en crudo: durante el primer segundo cada trama sale sin comprimir, digan lo que digan los ajustes, y cada sentido arranca por su cuenta:
RAMP = 1.0 # seconds of raw frames before compression starts
def pack(self, frame):
if self.c is None or time.monotonic() - self.started < RAMP:
return RAW + frame
out = self.c.compress(frame, mode=ZstdCompressor.FLUSH_BLOCK)
return ZSTD + out if len(out) < len(frame) else RAW + frame
Esa es la idea de las fases del principio del artículo aplicada a un solo enlace. El túnel se levanta por el camino más simple que tiene, demuestra que puede llevar una trama, y solo entonces empieza a hacer algo listo. Cuando se rompa, sabes en qué segundo se rompió.
Fase Seis: Comprimido Y Cifrado, Y Lo Que Cuesta El Orden
El orden importa, y solo uno funciona: comprimir, después cifrar. El texto cifrado no se comprime, como enseña la fila del 100,7 % — que es también por lo que TLS 1.3 quitó su propia compresión, dejando tu capa como el único sitio donde hacerlo. Ese orden tiene un problema conocido, el mismo que le puse a deflate: comprimir antes de cifrar filtra el texto plano a través de la longitud del texto cifrado, y donde un atacante puede inyectar datos elegidos junto a un secreto y mirar los tamaños, esa filtración ha sido un ataque que funciona más de una vez — CRIME y BREACH contra TLS14, y VORACLE contra exactamente esta forma15.
Eso no es una razón para no comprimir nunca. Es una razón para saber en qué caso estás:
- Un enlace que lleva tu propio tráfico entre dos máquinas tuyas — replicación, copias de seguridad, registros, telemetría — no lleva texto plano elegido por un atacante viajando junto a secretos. Comprímelo, y comprime el flujo.
- Un enlace que lleva navegación arbitraria de usuarios, donde el contenido web de otro y tus credenciales van juntos, es el caso sobre el que se escribió VORACLE. Déjalo apagado.
La razón de que me quede tranquilo poniendo esto en la construcción con tap y no en la de PPP no es un principio: aquí eliges un algoritmo moderno a propósito, para un tráfico que has mirado, mientras que el deflate de pppd comprime todo por defecto con uno de 1996 venga o no venga a cuento.
Qué Gana La Construcción Con Tap, Y Qué Cede
Pon las dos mitades una al lado de la otra, porque no compiten. Son compromisos distintos.
| pppd sobre un socket | tap o tun sobre un socket | |
|---|---|---|
| Tramado | incorporado (HDLC, resincroniza tras el daño) | ninguno sobre UDP porque no hace falta; inventarlo sobre TCP |
| Configuración de direcciones | negociada por IPCP e IPV6CP | a mano en los dos extremos |
| Comprobación de vida | eco LCP, incorporada | ninguna; la añades tú o el enlace muere en silencio |
| Autenticación | PAP o CHAP disponibles, las dos débiles | ninguna en absoluto |
| Capa | solo 3 | 3 con tun, 2 con tap |
| Puenteado | no | sí, con tap |
| Sobrecarga | bandera, cabecera y FCS por trama, más escapes | nada, o 14 bytes con tap |
| Compresión | deflate y BSD, activadas por defecto, de 1996 | ninguna, o zstd por trama que añades y controlas |
| Root necesario | sí, en todo momento | para crear el dispositivo; no para usarlo |
| Piezas en movimiento | un demonio de treinta años | un descriptor de fichero |
PPP te da un enlace negociado que se vigila solo y te cobra un protocolo por ello. Un dispositivo tap te da un agujero en crudo por nada, y tú pones las piezas que faltan o te las ahorras. Para un túnel que se queda en marcha, la comprobación de vida ausente es la que muerde: pppd se entera de un portador muerto en treinta segundos y lo reconstruye, la construcción con tap no se entera de nada porque no hay nada dentro que esté mirando. Añade un keepalive, ejecútalo bajo algo que lo reinicie, o usa la construcción que ya tiene uno.
Cuándo Es Esta La Herramienta Correcta, Y Cuándo No
Nunca es de verdad la herramienta correcta, y no voy a fingir lo contrario. Todo lo de arriba funciona, y nada de ello es lo que deberías ejecutar en producción. La versión honesta de un cómo-se-hace incluye la parte donde sueltas la herramienta, y esta es esa parte.
Para lo que sirve de verdad es para enseñarte cómo funciona una cosa. Una VPN desmontada en sus piezas y, en la sección siguiente, cómo se comporta de verdad la salida en cuanto alguien está dentro de tu red con root. Esas son las razones para haber leído esto. Los casos estrechos de abajo son reales, pero no son por lo que existe este artículo.
Echa mano de la construcción PPP cuando:
- El portador no es IP en absoluto — una consola serie, un cacharro USB, un enlace de radio, una tubería con nombre, un canal SSH. A
pppdle da igual sobre qué viajen los bytes, y un dispositivo tap no te ayuda aquí. - Estás rescatando algo: una máquina con consola serie, sin red, y un trabajo que hay que terminar esta noche. PPP sobre esa consola es un enlace enrutado, instalado en los dos extremos sin nada que copiar.
- Quieres que el enlace se cuide solo. El eco LCP, la negociación de direcciones y el reinicio salen gratis; escribirlos tú es como la construcción con tap se convierte en un producto pequeño y poco fiable.
Echa mano de la construcción con tap cuando:
- Necesitas capa 2 — un protocolo que no es IP, un latido de clúster que quiere difusiones, DHCP a través del túnel, o dos segmentos que tienen que ser uno.
- Quieres las mínimas piezas en movimiento: sobre UDP con DTLS son dos comandos, sin demonio, sin negociación, sin nada que escapar.
- Root escasea. Crea el dispositivo una vez con
user, y el proceso que mueve tramas no vuelve a necesitar privilegios.
Las dos son la herramienta correcta cuando estás aprendiendo. Cada capa es visible y se puede cambiar por separado, y no hay mejor forma de entender qué hace un producto VPN que construir uno con las piezas y ver cómo se levanta cada una.
Ninguna es la herramienta correcta cuando quieres una VPN. Para eso, usa WireGuard. Está en el núcleo, es una fracción del código, hace la criptografía como es debido sin nada que negociar y nada que equivocar, y es un protocolo de datagramas por diseño. ssh -w te da un dispositivo tun sobre una sesión SSH existente en un solo comando, y OpenVPN es la versión madura y auditada de la forma DTLS-sobre-tap de arriba. Los tres son mejores en esto que cualquier cosa construida aquí.
Construye esto porque quieres saber qué hay dentro de la cosa que compras. No porque fuera ingenioso.
Lo Que Esto Enseña De Verdad: La Salida Desde El Lado Del Atacante
Esta es la razón para leer un artículo de construcción que te acaban de decir que no uses. Dale la vuelta y mira desde dentro de tu propia red, como quien acaba de aterrizar ahí con root. Toda brecha real acaba ahí, por una clave robada, una fuga de contenedor, un servicio sin parchear, alguien de dentro. La pregunta que decide entonces lo mala que se pone la jornada no es «qué pueden ejecutar», porque pueden ejecutar cualquier cosa. Es «qué puede salir, y si lo habías decidido antes de que llegaran».
Si la salida está abierta por defecto, la respuesta es todo, y ya poco puedes hacer. Nada de esto era exótico. pppd, ncat, socat e ip son paquetes firmados de la distribución que ya están en la máquina; el apaño de tap son cincuenta líneas de biblioteca estándar. Un puerto saliente permitido — y es el 443, el que abre toda red por defecto — y hay un enlace enrutado desde tu red a la de otro, cifrado, autenticado, que sobrevive a los reinicios, que lleva IPv4 e IPv6, e indistinguible en tu frontera de cualquier sesión HTTPS que tus usuarios hacen diez mil veces al día. Hazlo tap y puentéalo y lo que salió del edificio no es una ruta. Es el segmento.
No hay nada que pueda pillar un escáner: ni firma de malware porque no hay malware, ni protocolo raro porque es un saludo TLS normal al 443, ni binario inusual porque los instaló todos tu propio gestor de paquetes. El proxy registra una conexión y un recuento de bytes, y las dos cosas parecen trabajo.
Y el destino ni siquiera es fijo. Un socket no tiene por qué terminar donde acaban los paquetes, porque el portador se puede apuntar a través de un proxy — un relé que no es más que dos conexiones y una tubería, que la sección siguiente construye en una línea de shell. ncat acepta --proxy con --proxy-type http, socks4 o socks5, así que la sesión TLS que ve tu frontera termina en aquello a lo que el atacante le dijo que conectara a través — un salto interno, un servicio SaaS permitido que resulta reenviar CONNECT, un relé en la nube — y el túnel sigue desde ahí hasta algún sitio que tú no ves nunca. HTTP CONNECT y SOCKS hacen esto los dos por diseño, porque para eso está un proxy. Así que una entrada en la lista de permitidos para un destino en el que confías es siempre confianza en ese destino y en todo aquello a lo que vaya a reenviar, que ni controlas ni puedes enumerar. El punto final de tu registro de cortafuegos es el proxy. Nunca fue el extremo lejano.
Así que la verdad incómoda: en cuanto alguien está dentro con root y la salida es permisiva, el túnel no es lo que te toca impedir. Las piezas están instaladas, la salida está abierta, y ni siquiera lleva a donde parece. Tu única oportunidad de poner esto difícil fue antes de que llegara el atacante, en la frontera, decidiendo qué puede salir.
Eso es la salida con default-deny, y es la lección entera. Salida bloqueada por defecto; una lista de permitidos corta y nombrada donde una persona justificó cada destino y cada puerto; todo lo demás rechazado, registrado y con alerta. No porque frene en seco a un atacante decidido — un destino permitido es un túnel permitido — sino porque la alternativa es no tener decisión ninguna que hacer cumplir. Una política de salida escrita como una lista de puertos permitidos es una política sobre números de puerto. Nunca fue una política sobre qué sale, y en cuanto alguien tiene root, los números de puerto son todo lo que protege.
Este es el mismo hallazgo que el artículo sobre ping, que construye el túnel con eco ICMP, y el artículo sobre los ayudantes de protocolo, donde tu cortafuegos abre los agujeros él solo. Tres formas de entrar, una conclusión: el control que creías tener era sobre protocolos, y ninguno de estos protocolos es lo que dice ser. La frontera, decidida por adelantado y con default-deny, es el único control que fue real alguna vez.
Un Proxy Son Dos Conexiones Y Una Tubería
Merece la pena ver lo poco que es un relé, porque explica por qué no puedes razonar sobre el extremo lejano desde el cercano. Un proxy no es software especial. Es una conexión unida a otra por una tubería. La forma más vieja usa una tubería con nombre, un FIFO, para llevar el sentido de vuelta: un ncat escucha, otro conecta hacia delante, y el FIFO cablea el camino de respuesta entre ellos.
mkfifo backpipe
ncat -l 7000 0<backpipe | ncat farend.example.net 7100 1>backpipe
Léelo como fontanería: la salida de quien escucha entra en el segundo ncat y sigue hacia el extremo lejano, y las respuestas vuelven por el FIFO hasta el cliente. Dos sockets, una tubería, los dos sentidos, y la conexión del cliente termina aquí, en el relé, mientras los paquetes siguen hasta farend y vuelven. Ejecuté exactamente esto en loopback con un tercer ncat haciendo de eco en el extremo lejano, y una línea que metí volvió tras haber hecho el viaje entero.
Ncat hace lo mismo en un solo proceso, ejecutando la conexión hacia delante por cada cliente que llega:
ncat -l 7000 --keep-open --sh-exec 'ncat farend.example.net 7100'
La misma forma, menos piezas: el socket de quien escucha se une al del ncat ejecutado por la tubería que el shell pone entre ellos. Encadena tres y el túnel cruza tres redes, terminando y volviendo a originarse en cada una, y el cortafuegos de cada salto registra una conexión local y ordenada hacia el siguiente y nada más allá.
Ese es el truco entero, y por eso el registro de la frontera no es prueba de un destino. Cada relé es el extremo lejano hasta donde puede ver la máquina anterior, y el otro extremo de verdad está a tantas tuberías como nadie estuviera mirando.
Pon ahora esos relés en máquinas que no son del atacante.
Cada dueño de esa cadena ve solo una conexión del salto anterior al salto siguiente: sin origen, sin destino, solo medio. Esto no es una idea novedosa que yo le esté entregando a nadie. Es como han funcionado las cadenas de pivote y las redes de mando y control desde hace veinte años, y por qué el tráfico saliente de un equipo comprometido lleva tantas veces a otra víctima en vez de al atacante. Tus registros te enseñan que hablaste con una máquina en un centro de datos de algún sitio. De quién era, y a dónde reenvía, no estuvo nunca en ellos.
El peso defensivo es una línea: no puedes atribuir ni confiar en un destino que no restringiste por adelantado. Para cuando el tráfico está saliendo, la dirección a la que sale no te dice casi nada, porque es un relé en la máquina de otro y el punto final real está lavado detrás.
El Enlace Nunca Fue El Producto
Lo que me llama la atención, después de desmontar esto, es lo poco que tiene de nuevo y lo mucho que se vende.
El RFC 1661 es de 1994. pppd lleva treinta años en todas las distribuciones de Linux, los módulos del núcleo son ocho ficheros en un directorio, y todo lo que hace que una VPN sea una VPN — una interfaz virtual, un enlace negociado, un portador cifrado, una ruta — son cuatro programas y un certificado. Nada de eso es difícil ni secreto. Documentaron cada byte y lo regalaron, y creció una industria entre tú y eso vendiendo las mismas cuatro piezas en una caja con una licencia por puesto y un contrato de soporte que caduca. Las piezas no mejoraron. Las envolvieron.
Eso no es un argumento para ejecutar esto en producción. Acabo de decirte que no lo hagas. Es un argumento para saber qué hay en la caja que compras, porque el día que el proveedor cambie las licencias, lo compren o cierre tu modelo, la diferencia entre un mal trimestre y un mal año es si alguien de tu casa sabe de qué estaba hecha la cosa.
Cógete una tarde y construye el túnel con las piezas. Mira negociar a LCP, rompe el enlace y míralo volver, quítale el certificado y mira qué se para. Después vuelve a leer la hoja de datos de tu proveedor de VPN, y mira cuánto reconoces.
Esa misma tarde compra la otra mitad. Si un enlace enrutado hacia fuera de tu red son cuatro programas instalados y un certificado, a quien acaba de conseguir root en una de tus máquinas no lo frena lo difícil que sea construir el túnel. No es difícil. Lo único que lo frena es lo que tú decidiste, antes de que llegara, que podía salir. Constrúyelo una vez y dejas de pensar en la salida como algo que hace cumplir un producto, y empiezas a pensarla como una decisión que tomaste o no tomaste.
No hay mucha magia en ello. La mayor parte es 1994 con una mano de pintura, y no hay nowt de malo en 1994. Funcionó, estaba documentado, y todavía se ejecuta.
RFC 1661 — The Point-to-Point Protocol (PPP), 1994. Define el enlace, LCP y la familia de protocolos de control de red que se apoyan encima. ↩︎
RFC 2637 — Point-to-Point Tunneling Protocol (PPTP), que lleva PPP dentro de GRE. ↩︎
RFC 3931 — Layer Two Tunneling Protocol version 3, el descendiente en vía de estándar del protocolo que lleva PPP dentro de UDP. ↩︎
pppd(8) — la página de manual del demonio PPP. Fuente de
pty,notty,local,passive,noipdefault,proxyarp,record,receive-all, las opciones de eco LCP y el valor por defecto de asyncmap: «If no asyncmap option is given, the default is zero, so pppd will ask the peer not to escape any control characters.» Las citas de aquí se leyeron deman pppden ppp 2.5.1. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎RFC 1662 — PPP in HDLC-like Framing. La secuencia de bandera, la regla de relleno de octetos y el Async-Control-Character-Map: «Each frame begins and ends with a Flag Sequence, which is the binary sequence 01111110 (hexadecimal 0x7e).» ↩︎ ↩︎
Olaf Titz, Why TCP Over TCP Is A Bad Idea — la explicación estándar del apilado de retransmisiones, que abre con esta construcción exacta. La URL original ya no sirve el artículo; esta es una captura del Internet Archive. ↩︎
RFC 5072 — IP Version 6 over PPP. IPV6CP y los identificadores de interfaz de 64 bits, independientes de IPCP. ↩︎
RFC 1994 — PPP Challenge Handshake Authentication Protocol (CHAP). Demuestra la identidad del par; no cifra nada. ↩︎
RFC 3079 — Deriving Keys for use with Microsoft Point-to-Point Encryption (MPPE). La construcción RC4 con clave del intercambio MS-CHAP, y la razón de que PPTP no sea una opción viva. ↩︎
Ncat Users’ Guide — connecting through a proxy —
--proxyy--proxy-typepara HTTP CONNECT y SOCKS 4/5, de modo que el portador TLS termina en el proxy, no en el extremo lejano del túnel. ↩︎Ncat Users’ Guide — las opciones
--ssl,--ssl-verifyy--ssl-trustfile, y los modos de escucha y UDP. Versión probada aquí: Ncat 7.92. ↩︎Universal TUN/TAP device driver — la documentación del propio núcleo para
/dev/net/tun,TUNSETIFF, y la diferencia entre tun y tap. ↩︎PEP 784 — Adding Zstandard to the standard library, por lo que
compression.zstdno necesita paquete en Python 3.14. Medido aquí contra Python 3.14.7 y zstd 1.5.7. ↩︎RFC 7457 — Summarizing Known Attacks on TLS and DTLS, que cubre CRIME y la forma general de una fuga por longitud al comprimir antes de cifrar. ↩︎
OpenVPN — the VORACLE attack — la misma fuga contra una VPN que comprime antes de cifrar, y la razón de que OpenVPN desaconseje ahora la compresión. ↩︎