Como domesticamos la VPN corporativa con NixOS: guia completa, topologia y modulos


27 de febrero de 2026

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:

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:

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:

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:

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.

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.

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.

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.

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

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.

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (1)

passh — 27 Feb 2026 19:37
zas!

Deja un comentario