GitHub Actions no puede llegar a tu VPN: runner propio o túnel
Un runner alojado es una VM nueva fuera de tu red. O pones un runner dentro de esa red, o abres un túnel desde el job y esperas una ruta real.
Tu paso de despliegue se cuelga en un nombre de host interno mientras el mismo comando responde al instante desde tu portátil, y la compilación del cliente en ambas máquinas es idéntica. El job se ejecuta en un runner alojado por GitHub: una máquina virtual creada para ese único job dentro de la nube de GitHub, sin ruta hacia tu espacio de direcciones privado, sin entrada de resolver para la zona interna y sin certificado que el endpoint privado vaya a aceptar. Solo dos diseños encajan con esa restricción, y ninguno se puede activar después sin reconstruir el workflow. Elige entre ellos antes de la primera línea de YAML, porque migrar a posteriori cuesta más que el primer intento.
La elección está entre un runner colocado donde la red ya termina y un túnel que existe durante la duración de un job. Ambos han soportado despliegues en producción durante años, y fallan de formas distintas.
Dónde está realmente el límite
Todo lo que instalas en tu portátil se queda en tu portátil, y todo lo que instalas durante un job desaparece con el runner. No hay un espacio de nombres de red persistente al que un cliente pueda seguir conectado entre ejecuciones, así que cada ejecución empieza en el mismo estado inalcanzable. Azure documenta este límite para los agentes alojados por Microsoft, donde los enlaces VPN y ExpressRoute hacia redes privadas no son compatibles, y los runners alojados por GitHub arrastran esa misma forma de restricción en lugar de un mecanismo aparte. Léelo como una propiedad del producto, no como un defecto que haya que rodear.
Ningún ajuste del repositorio lo relaja. La etiqueta del job decide qué pool recoge el trabajo, y una etiqueta alojada significa una máquina fuera de tu perímetro durante toda la ejecución.
La accesibilidad privada tiene que venir, por tanto, de algo que poseas. Un runner colocado donde la red ya termina, o un túnel que vive un solo job. El resto del artículo trata de pagar por una de esas dos cosas a propósito en vez de descubrir la factura en producción.
Decide por frecuencia de ejecución, no por familiaridad
Recurre a un runner propio dentro de la VPC o de la red de la oficina cuando los despliegues llegan varias veces al día, cuando el job toca más de un puerto privado o cuando un equipo de plataforma ya se encarga del parcheo y de la rotación de credenciales. Un job en esa máquina resuelve y enruta como cualquier otro host detrás del cortafuegos. El trabajo de red ocurre una vez, en la instalación, en lugar de en cada ejecución.
Construye el túnel dentro del job cuando el acceso es ocasional y no quieres un agente de larga vida sosteniendo una credencial hacia tu red. El job se convierte en su propio cliente de red durante unos minutos y no deja nada atrás, que suele ser justo el objetivo.
Calcula el coste del túnel antes de elegirlo. Cada ejecución paga la materialización del perfil, el arranque del cliente, la espera de disponibilidad y el desmontaje. Un túnel que sube pero nunca se verifica produce un despliegue que falla a medio camino y deja el estado privado en una forma que nadie planificó. El reintento posterior rara vez es limpio, y el segundo fallo suele achacarse a la versión del cliente.
Escribir el perfil para que el paso no pueda abortar
Mantén los perfiles y las credenciales fuera del repositorio y materialízalos desde secretos en el paso que los necesita. El orden decide si el job sobrevive: un archivo creado bajo sudo pertenece a root, y el shell sin privilegios del paso ya no puede escribir en él.
- name: Bring up OpenVPN
run: |
umask 077
profile="$RUNNER_TEMP/client.ovpn"
printf '%s\n' "$VPN_PROFILE" > "$profile"
: > "$RUNNER_TEMP/openvpn.log"
printf '%s\n' "$VPN_CREDENTIALS" > "$RUNNER_TEMP/credentials.txt"
sudo openvpn --daemon openvpn-job \
--config "$profile" \
--auth-user-pass "$RUNNER_TEMP/credentials.txt" \
--writepid "$RUNNER_TEMP/openvpn.pid" \
--log "$RUNNER_TEMP/openvpn.log"Los dos detalles de ese bloque existen para pasos posteriores. Con umask 077 el perfil queda en modo 0600 y es propiedad del usuario del runner, sudo todavía puede leerlo, y el cliente escribe su log en un archivo que el runner ya posee, así que el bucle de disponibilidad puede leer ese log sin un segundo sudo. --writepid registra el PID al que apuntará el desmontaje.
Un perfil que se autentica con un certificado de cliente necesita su clave escrita de la misma forma, y un perfil de desafío estático necesita el modo de autenticación correspondiente. Los perfiles que empujan opciones DNS necesitan openvpn-systemd-resolved en Ubuntu y Debian, porque el empuje por sí solo no cambia nada en una imagen base.
La disponibilidad es una sonda, no una pausa
Un sleep fijo aguanta justo hasta que el concentrador va lento, y entonces el paso de despliegue se ejecuta antes de que exista la ruta. El fallo aparece luego dentro de la herramienta de despliegue como un timeout contra el host privado, lo que no dice nada sobre el túnel.
- name: Await private endpoint
run: |
for attempt in $(seq 1 30); do
if getent hosts private.example.internal > /dev/null && \
timeout 3 bash -c 'exec 3<>/dev/tcp/private.example.internal/8443' 2>/dev/null; then
echo "endpoint answered after $attempt attempts"
exit 0
fi
sleep 2
done
grep -Ei 'error|verify|auth' "$RUNNER_TEMP/openvpn.log" | tail -40
exit 1El bucle observa dos cosas porque cualquiera de las dos puede ser la mitad rota. getent hosts demuestra que el resolver empujado responde para ese nombre, y la apertura del socket demuestra que algo escucha en el puerto que usará el despliegue; un perfil solo de rutas necesita la dirección literal y que se borre la línea getent.
Cuando el bucle expira, el log es lo único que separa tres fallos distintos: un certificado rechazado, un modo de autenticación equivocado y una ruta que nunca llegó. Filtrar antes del tail mantiene esa salida lo bastante corta para leerla mientras el job sigue fallando. Lee el primer error en lugar de la última línea, porque un cliente que no puede autenticarse se repite hasta que el bucle se rinde. ip -4 route show dev tun0 responde a la pregunta más simple de si se empujó alguna ruta, y cuesta una línea dentro del mismo bucle.
Desmontaje y la cuestión del artefacto
Cierra el job con un paso protegido por if: always() que ejecute sudo kill "$(sudo cat "$RUNNER_TEMP/openvpn.pid")" y nada más. En un runner propio compartido, nunca mates por nombre de proceso: otro job puede tener un cliente con el mismo binario y argumentos parecidos. El fallo cae entonces sobre el job que corrió segundo, y su log apunta a un workflow que no hizo nada mal.
La protección importa porque un despliegue fallido deja igualmente un túnel vivo. Sin always() el paso de limpieza se salta justo cuando hace falta, y la siguiente ejecución arranca contra un cliente ya conectado.
Queda una pregunta antes de adoptar cualquiera de los dos diseños: ¿la etapa de compilación necesita una ruta privada? Un agente de despliegue que ya está dentro de la red puede descargar un artefacto aprobado, lo que mantiene las compilaciones normales lejos del camino privado. Menos jobs sosteniendo un túnel significa menos lugares donde esconder una ruta obsoleta.