Como domesticamos la VPN corporativa con NixOS: guia completa, topologia y modulos
Por que este post
Este post es nuestra documentacion definitiva. No solo cuenta la historia, es la referencia tecnica para cuando algo falle, cuando llegue una maquina nueva, o cuando dentro de 6 meses no nos acordemos de por que pusimos checkReversePath = false.
Si trabajas con VPN corporativa en Linux, o si tu empresa usa Pulse Secure / Ivanti Connect Secure y te obliga a usar Windows, esto te interesa.
La historia: un año de intentos
Vocento usa Ivanti Connect Secure (antes Pulse Secure) como VPN corporativa. Es un cliente propietario que oficialmente solo soporta Windows y macOS. En Linux, el camino ha sido largo:
Intento 1: Empaquetar el .deb en NixOS (enero 2026)
Encontramos pulse-secure-nixos en nixpkgs, un intento de hacer un paquete Nix nativo a partir del .deb del cliente. El problema: el .deb no se actualizaba, las dependencias de GLIBC no cuadraban, y los certificados del servidor rechazaban la version vieja del cliente. Abandonado.
Intento 2: Emulacion y contenedores
Probamos meter el cliente en un contenedor con las librerias correctas, usar FHS environments de Nix, incluso steam-run como wrapper generico de binarios Linux. Nada funcionaba porque Ivanti necesita acceso real al stack de red del kernel (tun/tap, iptables, resolv.conf) y un contenedor no se lo da limpiamente.
Intento 3: VM Ubuntu con Ivanti (la que funciono)
La solucion obvia que deberiamos haber intentado primero: una VM Ubuntu con el cliente instalado nativamente. Funciona porque Ubuntu es un SO "normal" que Ivanti soporta oficialmente.
Pero durante meses fue un desastre operativo: la VM dependia de memory snapshots de libvirt para funcionar. Si la reiniciabas sin restaurar el snapshot, moria. Si tocabas la red, moria. 150 lineas de configuracion manual en NixOS que nadie se atrevia a tocar. La "ZONA SAGRADA".
Intento 4: Modulos declarativos (febrero 2026 - HOY)
Todo automatizado. Disco limpio. Reproducible. 20 lineas de config. Es lo que documentamos aqui.
Topologia de red completa
INTERNET
|
[Router casero]
192.168.2.1
|
┌──────┴──────┐
│ enp7s0 │ IP: 192.168.2.147
│ (host) │ NixOS (aurin/vespino/macbook)
│ │
│ iptables │ MASQUERADE: br0 -> enp7s0
│ nat │ (NixOS networking.nat lo genera)
│ │
│ br0 │ IP: 192.168.53.10/24
│ (bridge) │ 9 rutas estaticas via .53.12
└──────┬──────┘
│
┌──────┴──────┐
│ ens3 │ IP: 192.168.53.12/24
│ (VM) │ Ubuntu 22.04 + Ivanti
│ │
│ iptables │ MASQUERADE: host (.53.10) -> tun0
│ ip_forward │ FORWARD: ens3 <-> tun0
│ │
│ tun0 │ IP: 192.168.196.49/32
│ (tunel) │ Ivanti Connect Secure
└──────┬──────┘
│
┌───────────┼───────────┐
│ │ │
10.180.0.0/16 10.182.0.0/16 192.168.201.0/24
(servidores) (servidores) (DNS Vocento)
│ │ │
Bitbucket Jenkins Active Directory
Confluence Nexus LDAP
Evolok Grafana ...
Flujo de un paquete (ej: git push a Bitbucket)
1. Host: git push -> destino 10.180.x.x
2. Host: ruta estatica dice "10.180.0.0/16 via 192.168.53.12 dev br0"
3. Host -> br0 -> VM ens3: paquete llega a la VM
4. VM: ip_forward + iptables FORWARD acepta (ens3 -> tun0)
5. VM: MASQUERADE cambia source de 192.168.53.10 a 192.168.196.49
6. VM: tun0 encapsula en ESP (UDP 4500) hacia servidor VPN
7. Host: recibe ESP de VM via br0, NAT (networking.nat) lo saca por enp7s0
8. Internet: paquete ESP llega al servidor VPN de Vocento
9. Servidor VPN: desencapsula, entrega a 10.180.x.x
10. Respuesta: camino inverso, conntrack mapea todo correctamente
Los dos modulos NixOS
ivanti-vpn-vm.nix - La VM
Define la VM en libvirt de forma declarativa. El XML se genera desde Nix. Al hacer nixos-rebuild switch, la VM se define automaticamente.
Options:
services.ivanti-vpn-vm = {
enable = true; # Habilitar el modulo
vmName = "ivanti-vpn"; # Nombre en libvirt (default)
diskPath = "/var/lib/libvirt/images/ivanti-vpn-clone.qcow2"; # Disco
networkMode = "bridge"; # "bridge" (prod) o "nat" (test)
macAddress = "52:54:00:04:89:fa"; # CRITICO: Ivanti usa MAC como Device ID
vmAddress = "192.168.53.12"; # IP de la VM
hostAddress = "192.168.53.10"; # IP del host en br0
};
Que genera:
- Servicio systemd
define-ivanti-vpn-vm: define la VM en libvirt al boot - XML de libvirt con KVM, 4GB RAM, 2 vCPUs, SPICE graphics, virtio disk+net
- Script
vpn-vmcon subcomandos: start, stop, status, ssh, viewer, ip, setup-network - Paquete
guestfs-toolspara virt-customize
vocento-vpn-bridge.nix - La red del host
Configura todo el networking del host para que la VM funcione como gateway VPN.
Options:
services.vocento-vpn-bridge = {
enable = true;
externalInterface = "enp7s0"; # Interfaz con internet (OBLIGATORIO)
externalAddress = "192.168.2.147"; # IP fija (null = DHCP)
defaultGateway = "192.168.2.1"; # Gateway (null = no configurar)
hostAddress = "192.168.53.10"; # IP del host en br0
vmAddress = "192.168.53.12"; # IP de la VM
dnsMode = "vm"; # "vm" (proxy DNS) o "direct"
hostsFile = /path/to/hosts.txt; # /etc/hosts extra (Vocento)
vpnRoutes = [ # Subredes corporativas
{ address = "10.180.0.0"; prefixLength = 16; }
# ... 9 subredes por defecto
];
extraUnmanagedInterfaces = [ "enp7s0" ]; # NM no toca estas
};
Que genera:
- Bridge
br0con IP estatica y rutas a subredes corporativas - NAT via
networking.nat(MASQUERADE automatico, sin reglas manuales) /etc/resolv.confcon VM como DNS primario + 8.8.8.8 fallback/etc/hostscon entradas Vocento (si hostsFile definido)- Firewall:
checkReversePath = false, puertos DNS abiertos - NetworkManager: interfaces de bridge excluidas
- Script
vpn-bridge-statuspara diagnostico rapido
Como usar la VPN (dia a dia)
# Arrancar VM + abrir viewer grafico
vpn-vm start
# Dentro del viewer: abrir Ivanti Connect Secure, conectar
# (usuario y password de Vocento, MFA si aplica)
# Verificar que funciona (desde el host)
vpn-bridge-status
ping 192.168.201.38 # DNS Vocento
git push # a Bitbucket corporativo
# Apagar cuando termines
vpn-vm stop
# Otros comandos utiles
vpn-vm status # Estado + ping
vpn-vm ssh # Shell en la VM
vpn-vm viewer # Reabrir viewer si lo cerraste
vpn-vm ip # Mostrar IP de la VM
Como reproducirlo desde cero (sin VM existente)
Si no tienes el disco qcow2 (maquina nueva, disco corrupto, o primera vez):
Paso 1: Crear la VM base
# Descargar ISO Ubuntu 22.04 LTS
wget https://releases.ubuntu.com/22.04.5/ubuntu-22.04.5-desktop-amd64.iso
# Crear disco vacio
qemu-img create -f qcow2 /var/lib/libvirt/images/ivanti-vpn-clone.qcow2 30G
# Instalar Ubuntu con virt-install
virt-install \
--name ivanti-vpn-temp \
--ram 4096 --vcpus 2 \
--disk /var/lib/libvirt/images/ivanti-vpn-clone.qcow2 \
--cdrom ubuntu-22.04.5-desktop-amd64.iso \
--os-variant ubuntu22.04 \
--graphics spice \
--network bridge=br0
En la instalacion:
- Usuario:
passh - Hostname: lo que quieras
- Instalacion minima (no necesitas office ni juegos)
Paso 2: Instalar Ivanti
# Dentro de la VM, abrir Firefox e ir a la URL de la VPN corporativa
# (la URL que te da tu empresa, tipo https://vpn.empresa.com)
# El portal web descarga el .deb automaticamente
# Instalarlo:
sudo dpkg -i pulse-*.deb
sudo apt-get install -f # dependencias
Paso 3: Inyectar configuracion de red
# Apagar la VM temporal
virsh shutdown ivanti-vpn-temp
# Borrar la VM temporal de libvirt (el disco se queda)
virsh undefine ivanti-vpn-temp
# Inyectar toda la config de red automaticamente
vpn-vm setup-network
vpn-vm setup-network usa virt-customize para inyectar en el disco sin arrancar la VM:
- Netplan con
renderer: networkd, IP estatica, metric 200 - systemd-networkd habilitado, NetworkManager deshabilitado
- cloud-init deshabilitado
- resolv.conf con 8.8.8.8 (la VPN lo sobreescribe al conectar)
- Servicio
reset-dns.service: resetea DNS en cada boot (rompe el chicken-and-egg) - ip_forward habilitado
- iptables: MASQUERADE solo para hostAddress en tun0 + FORWARD ens3<->tun0
- sudoers NOPASSWD para passh
Todo en 90 segundos, sin SSH, sin consola grafica.
Paso 4: Configurar NixOS
# En hosts/<maquina>/default.nix
# Imports
imports = [
../../modules/services/ivanti-vpn-vm.nix
../../modules/services/vocento-vpn-bridge.nix
];
# Config (ejemplo para maxos con WiFi)
services.ivanti-vpn-vm = {
enable = true;
networkMode = "bridge";
};
services.vocento-vpn-bridge = {
enable = true;
externalInterface = "wlan0";
hostsFile = /home/passh/src/vocento/autoenv/hosts_all.txt;
};
# Rebuild
sudo nixos-rebuild switch --flake ~/dotfiles#hostname --impure
# Arrancar y conectar
vpn-vm start
# En viewer: abrir Ivanti, conectar, a currar
El flatten: de 73GB a 16GB
La VM original tenia una cadena de 4 snapshots qcow2:
base.qcow2 (52.8GB)
-> snapshot.1749804634 (10MB)
-> snapshot.1750227688 (251MB)
-> snapshot.1772005365 (7.5GB, ACTIVO)
+ 3 memory snapshots (~12GB)
= 73GB total, castillo de naipes digital
Si cualquier archivo de la cadena se corrompe, toda la VM muere. Sin recovery posible.
# Flatten: recorre toda la cadena y genera un disco unico
sudo qemu-img convert -p -f qcow2 -O qcow2 \
/var/lib/libvirt/images/snapshot-activo.qcow2 \
/var/lib/libvirt/images/ivanti-vpn-clone.qcow2
# Verificar
sudo qemu-img check /var/lib/libvirt/images/ivanti-vpn-clone.qcow2
# No errors were found on the image.
# Image end offset: 17616076800 (16.4 GiB)
El debugging: 5 horas, 5 bugs
Despues del flatten y la migracion a modulos, la VPN conectaba pero los datos no pasaban. Aqui empezo lo epico.
Bug 1: reset-dns.service fallaba al boot
El servicio que resetea resolv.conf a 8.8.8.8 arrancaba antes de que el filesystem estuviera en modo escritura.
- Causa:
DefaultDependencies=nosin dependencia explicita - Fix:
After=local-fs.target
Bug 2: DNS chicken-and-egg
La VM tenia DNS de Vocento (192.168.201.38) en resolv.conf. Pero ese DNS solo es accesible con la VPN conectada. Y la VPN necesita DNS para conectar.
- Causa: Ivanti sobreescribe resolv.conf al conectar, y al reiniciar queda con DNS corporativo inalcanzable
- Fix:
reset-dns.servicepone 8.8.8.8 en cada boot, Ivanti lo sobreescribe al conectar
Bug 3: DOBLE MASQUERADE en el host (ROOT CAUSE)
Este nos tuvo 3 horas. La VPN conectaba, el ESP se negociaba, los keepalives funcionaban... pero los datos no pasaban. El servidor VPN simplemente no respondia.
tcpdump capa por capa:
VM tun0: ICMP sale con IP correcta (192.168.196.49) OK
VM ens3: ESP encapsulado sale (UDP 4500, SPI correcto) OK
Host br0: Paquete llega al bridge OK
Host enp7s0: Sale NATed (IP publica) OK
Servidor: ...silencio absoluto... FALLO
Chema (nuestro agente de seguridad de red) lo encontro: habia DOS reglas MASQUERADE en el host para el mismo trafico.
Regla 1 (manual): -s 192.168.53.0/24 -j MASQUERADE (TODAS las interfaces)
Regla 2 (NixOS): -o enp7s0 mark 0x1 -j MASQUERADE (solo interfaz externa)
La regla 1 estaba en extraCommands del modulo bridge (puesta "por si acaso"). La regla 2 la genera NixOS con networking.nat. Ambas aplicaban al mismo paquete ESP.
Resultado: conntrack veia dos traducciones NAT para el mismo flujo. No sabia a cual mapear las respuestas. Las descartaba silenciosamente. Sin log. Sin error. Sin rastro.
Fix: Eliminar la regla manual. networking.nat ya genera MASQUERADE correctamente.
Bug 4: MASQUERADE blanket en tun0 (VM)
Dentro de la VM, habia -o tun0 -j MASQUERADE para toda la subnet. Esto enmascaraba el propio trafico ESP de Pulse Secure antes de entrar al tunel.
- Fix:
-o tun0 -s 192.168.53.10 -j MASQUERADE(solo trafico del host)
Bug 5: DNS order invertido
resolv.conf tenia 8.8.8.8 primero, VM segundo. Los dominios corporativos (*.grupo.vocento) se intentaban resolver con Google DNS, que obviamente no los conoce.
- Fix: VM (192.168.53.12) como nameserver primario, 8.8.8.8 como fallback
Diagnostico rapido si algo falla
# Estado general
vpn-bridge-status # br0, rutas, DNS, ping VM
vpn-vm status # VM running? ping?
# Si la VPN conecta pero no pasan datos:
# 1. Verificar que NO hay doble MASQUERADE
sudo iptables -t nat -L POSTROUTING -n -v
# Solo debe haber: Docker rules + nixos-nat-post
# Si ves una regla -s 192.168.53.0/24 sin -o -> PROBLEMA
# 2. Verificar cadena nixos-nat-post
sudo iptables -t nat -L nixos-nat-post -n -v
# Debe haber UNA sola regla MASQUERADE con -o enp7s0 mark 0x1
# 3. Verificar MASQUERADE en la VM
vpn-vm ssh -- "sudo iptables -t nat -L POSTROUTING -n -v"
# Debe ser: -o tun0 -s 192.168.53.10 -j MASQUERADE
# NO: -o tun0 -j MASQUERADE (sin source = blanket = BUG)
# 4. tcpdump capa por capa (en terminales separadas)
# En la VM:
sudo tcpdump -i tun0 -n icmp
sudo tcpdump -i ens3 -n "udp port 4500"
# En el host:
sudo tcpdump -i br0 -n "udp port 4500"
sudo tcpdump -i enp7s0 -n "udp port 4500"
# Donde desaparezcan los paquetes, ahi esta el bug
# 5. Conntrack (si sospechas doble NAT)
sudo conntrack -L -p udp --dport 4500 | head
# Entradas duplicadas o con estados raros = conntrack corrupto
Lo que aprendimos
- virt-customize es magia: modifica discos qcow2 sin SSH, sin arrancar la VM, sin consola grafica. 90 segundos para inyectar netplan + systemd + iptables + sudoers.
- El doble MASQUERADE es un asesino silencioso: conntrack se corrompe y los paquetes desaparecen sin dejar rastro. Si tu VPN "conecta pero no pasa datos", busca reglas NAT duplicadas.
- tcpdump capa por capa es la unica forma: captura en cada salto (VM tun0 -> VM ens3 -> host br0 -> host eth0). El gap entre saltos te dice donde esta el bug.
- NixOS networking.nat ya genera MASQUERADE correctamente: NUNCA añadir reglas manuales en extraCommands. Si las pones, creas doble NAT invisible.
- Ivanti usa la MAC como Device ID: si cambias la MAC de la VM, pierdes la autenticacion. El modulo la tiene como opcion configurable.
- Los memory snapshots de libvirt son una trampa: parecen backups, pero crean cadenas de dependencia. Un disco corrupto mata toda la cadena. Flatten + virt-customize es el camino reproducible.
- reset-dns.service rompe el chicken-and-egg: Ivanti sobreescribe resolv.conf con DNS corporativo. Al reiniciar sin VPN, no hay DNS. El servicio lo resetea en cada boot.
Archivos del repo
dotfiles/
modules/services/
ivanti-vpn-vm.nix # VM declarativa (474 lineas, documentada para noobs)
vocento-vpn-bridge.nix # Bridge + routing + DNS (354 lineas, documentada)
hosts/
aurin/default.nix # imports + ~20 lineas de config VPN
vespino/default.nix # imports + ~15 lineas de config VPN
macbook/default.nix # imports + ~15 lineas de config VPN
El resultado
De 150 lineas manuales a 20 declarativas. De 73GB en cadena de snapshots a 16GB limpios. De "ZONA SAGRADA: no tocar" a "import, enable, a currar". De un año de intentos fallidos (empaquetar .deb, emular, contenedores) a una VM declarativa que se monta en 5 pasos.
Cuando vimos que el IDE hacia push a Bitbucket a traves de la VPN migrada: "ha pusheado! ha pusheado! dios ha pusheado!"
Eso vale mas que cualquier deploy exitoso.
Comentarios (1)
Deja un comentario