hjs.es ← inicio

Infraestructura como código: Ansible para Linux y Windows

Toda la configuración de mi homelab Proxmox convertida en código versionado: un solo comando deja cualquier máquina instalada, configurada y securizada. El mismo repositorio administra contenedores y VMs Linux por SSH y Windows Server por WinRM, con hardening aplicado en dos fases y cuentas nombradas por persona (secretos cifrados con Ansible Vault) separadas de la cuenta de servicio que usa la propia automatización.

Ansible Ansible Vault Proxmox VE Windows Server WinRM fail2ban Hardening SSH Terraform GitHub

Contexto

Cada vez que creaba un LXC o una VM en mi Proxmox repetía el mismo ritual a mano: actualizar, instalar los mismos paquetes, configurar locales y zona horaria, montar fail2ban, crear usuarios, endurecer SSH... Quince minutos de comandos idénticos con resultados que nunca eran del todo idénticos — cada máquina acababa siendo ligeramente distinta según el día que la instalé.

La solución tiene nombre desde hace años: infraestructura como código. La configuración no se ejecuta, se declara en archivos versionados, y una herramienta se encarga de que la realidad coincida con lo declarado. Monté el proyecto con Ansible sobre un LXC de gestión mínimo (1 vCPU, 1 GB de RAM) que actúa como único punto de administración — y cuando el modelo funcionó en Linux, lo extendí a Windows Server, porque en el mundo real las infraestructuras son mixtas.

Diseño

01

Nodo de gestión dedicado

Un LXC ligero contiene Ansible y la única clave privada con acceso al resto de máquinas. Toda la administración del homelab sale de ese punto, auditable y aislado. Las máquinas gestionadas no necesitan ningún agente instalado: Linux se administra por SSH y Windows por WinRM, ambos nativos.

02

Roles con estructura estándar

Cada pieza de configuración es un rol creado con ansible-galaxy init, que genera el esqueleto estándar (tasks, handlers, defaults, vars, meta...). De ese esqueleto solo se rellena lo que el rol necesita — como mínimo tasks/main.yml (las tareas), y opcionalmente defaults/main.yml (variables con valores por defecto, lo que hace el rol reutilizable) y handlers/main.yml (acciones que solo se disparan si algo cambió, como reiniciar un servicio). El resto de carpetas quedan vacías y Ansible las ignora.

03

Inventario por grupos + un punto de entrada

El inventario clasifica las máquinas en tres grupos: linux (LXC y VMs Debian/Ubuntu/Kali...), windows (Server, W11) y homelab (el nodo Proxmox, con tratamiento especial). site.yml mapea grupos a roles: Linux recibe base + usuario_ansible + hardening_ssh; Windows recibe base_windows; el nodo, base y usuario pero sin hardening (decisión consciente: es la máquina cuyo plan B es la consola física). Incorporar una máquina nueva es una línea en el inventario y un comando.

04

Un modelo, dos mundos

Windows no se gestiona con SSH sino con WinRM (puerto 5985, habilitado con Enable-PSRemoting), y sus módulos son propios: win_timezone, win_updates, win_reboot... Pero el modelo mental es idéntico: declarar estado, no ejecutar comandos. El rol de Windows incluso decide solo si debe reiniciar tras instalar actualizaciones (register + when). Misma idempotencia, mismo repo, mismo recap.

05

Tareas condicionales

No todo aplica a todas las máquinas: chrony no puede ajustar el reloj dentro de un LXC (no tienen reloj propio, usan el del host) y el qemu-guest-agent solo tiene sentido en VMs. Las tareas usan when sobre los "facts" que Ansible descubre de cada máquina (ansible_virtualization_type): un solo rol, comportamiento correcto en cada tipo de máquina.

06

Cuentas nombradas junto a la de servicio

usuario_ansible es una cuenta de servicio: solo la usa el nodo de gestión para ejecutar playbooks, con sudo sin contraseña porque no hay ningún humano tecleando en ese momento. Para el acceso humano, un rol separado (usuario_hugo) crea una cuenta nombrada por persona: en Linux, con su propia clave SSH y sudo que sí pide contraseña; en Windows, mismo principio pero mecanismo distinto — win_user crea una cuenta local en el grupo de administradores con contraseña propia (WinRM no usa claves). La razón de fondo es trazabilidad: si todo el mundo entra como ansible o como Administrador, los logs no distinguen quién hizo qué. Con Ansible Vault cifrando contraseñas y hashes, y la lista de claves creciendo por persona (no por máquina), añadir alguien nuevo es una línea, no un redeploy.

07

Publicado en GitHub

El proyecto completo está versionado con Git y publicado para su consulta. El inventario del repo lleva IPs y credenciales de ejemplo; los valores reales solo existen en el nodo de gestión.

Ejecución selectiva

El día a día no es "aprovisionar todo": es tocar un grupo o una sola máquina. Para eso está --limit, que intersecta con los grupos de site.yml — cada máquina recibe solo los roles que le corresponden:

# Todo el homelab (Linux + Windows + nodo):
ansible-playbook site.yml

# Solo las máquinas Linux:
ansible-playbook site.yml --limit linux

# Solo las Windows:
ansible-playbook site.yml --limit windows

# Una máquina concreta:
ansible-playbook site.yml --limit test-debian

# Simulacro sin tocar nada (qué cambiaría):
ansible-playbook site.yml --limit windows --check

# Cualquier ejecución que toque variables cifradas necesita la contraseña del vault:
ansible-playbook site.yml --limit linux --ask-vault-pass

Problemas por el camino

✗

Problema 1 — fail2ban moría al arrancar en los LXC

El playbook instalaba y arrancaba fail2ban sin errores, pero en cada ejecución la tarea volvía a salir changed — señal de que Ansible lo encontraba parado una y otra vez. journalctl -u fail2ban reveló la causa: Have not found any log file for sshd jail. Los LXC mínimos de Debian 12 no llevan rsyslog, así que /var/log/auth.log no existe y fail2ban se suicidaba al no encontrarlo.

Solución: declarar en el rol un jail.local con backend = systemd (más python3-systemd): fail2ban lee directamente el journal en vez de buscar archivos de log. El arreglo quedó codificado en el rol — ninguna máquina futura volverá a tener este problema.
✗

Problema 2 — El huevo y la gallina del hardening

El objetivo era administrar todo con un usuario dedicado y prohibir el login de root por SSH. Pero el usuario dedicado lo crea el propio playbook... conectándose como root. Si cierras root antes de tiempo, te quedas fuera de tu propia máquina — como comprobé cuando Ansible me devolvió un Permission denied contra mi propia VM de pruebas.

Solución — hardening en dos fases: primera pasada como root que crea la puerta nueva (usuario + clave + sudo validado con visudo -cf); verificación manual de que la puerta abre; y solo entonces, segunda fase que cierra la vieja y cambia el inventario al usuario nuevo. Regla aprendida: nunca cierres una puerta sin haber probado la otra.
✗

Problema 3 — El instalador de Windows no veía el disco

La VM de Windows Server se creó con hardware paravirtualizado (VirtIO SCSI, red VirtIO), que es lo eficiente en Proxmox — pero Windows no trae esos drivers de serie, así que el instalador llegaba a la selección de disco y mostraba una lista vacía: para Windows, el disco no existía.

Solución: la VM necesita dos ISOs montadas a la vez — el instalador de Windows Server en una unidad y virtio-win.iso en otra. Desde el propio instalador, "Cargar controlador" → unidad de VirtIO → driver SCSI, y el disco aparece. Tras la instalación, el paquete completo de drivers + qemu-guest-agent desde esa misma ISO, que además permite a Proxmox ver la IP de la VM y hacer apagados limpios.
✗

Problema 4 — Ansible no arrancaba: locales

A mitad de proyecto, Ansible dejó de arrancar con unsupported locale setting. El SSH desde mi equipo reenviaba es_ES.UTF-8 al LXC de gestión, que no tenía ese locale generado, y Python abortaba antes de ejecutar nada.

Solución: arreglo inmediato en el nodo de gestión y, aplicando la mentalidad del proyecto, una tarea locale_gen en el rol base — el problema quedó declarado y resuelto para todas las máquinas de una vez.
✗

Problema 5 — group_vars: un archivo y una carpeta con el mismo nombre no coexisten

Al separar variables normales y secretas de Windows (group_vars/windows.yml + group_vars/windows_vault.yml), ansible_password desaparecía sin ningún error — WinRM fallaba con "auth method basic requires a password" como si el vault no existiera. Se repitió después con group_vars/all.yml: una variable se resolvía bien y la otra no, sin patrón aparente.

Causa: la regla de group_vars es estricta — o un único archivo <grupo>.yml, o una carpeta <grupo>/ con varios archivos dentro, nunca ambos para el mismo grupo. windows_vault.yml suelto se interpretaba como variables para un grupo llamado literalmente windows_vault, que no existe en ningún inventario — se ignoraba en silencio, sin ningún aviso de Ansible.
Solución: todo dentro de una carpeta por grupo — group_vars/windows/vars.yml + group_vars/windows/vault.yml, group_vars/all/vars.yml + group_vars/all/vault.yml. Ansible carga automáticamente todo lo que encuentra dentro de la carpeta.
✗

Problema 6 — win_user fallaba: el grupo "Administrators" no existe en español

Al crear la cuenta de hugo en Windows con win_user, la tarea fallaba con group 'Administrators' not found — a pesar de que el usuario sí llegaba a crearse. La causa: el grupo local de administradores tiene un nombre distinto según el idioma de instalación de Windows, y esta VM está en español.

Solución: usar el nombre tal como existe en el sistema — Administradores, no Administrators. La tarea es idempotente, así que reintentar no duplicó al usuario, solo completó lo que faltaba (añadirlo al grupo).
✗

Problema 7 — Aprovisionar una máquina de Terraform por primera vez sacó dos límites nuevos de Ansible

Con Terraform ya creando LXC/VMs directamente, aprovisionar la primera máquina nueva (ttyd) de extremo a extremo chocó con dos comportamientos de Ansible que nunca se habían visto en máquinas creadas a mano.

1 — El usuario de gestión todavía no existe. ansible_user: ansible en group_vars apunta a una cuenta que crea el propio rol usuario_ansible — que aún no se ha ejecutado en una máquina recién creada. Intentar forzar root con -u root en la línea de comandos no sirvió de nada: -u tiene precedencia baja, cualquier ansible_user definido en group_vars lo pisa igualmente.
Solución: -e ansible_user=root (extra-vars) para el primer contacto — -e sí gana por encima de group_vars. Solo hace falta esa vez: en cuanto usuario_ansible crea la cuenta de verdad, las siguientes ejecuciones vuelven a usar el default normal.
2 — --skip-tags no avisa si el tag no existe. site.yml nunca había tenido tags: en sus roles — un --skip-tags hardening_ssh no encontró nada que saltarse y ejecutó el playbook entero, hardening incluido, sin ningún error ni aviso. La máquina quedó con root SSH cerrado sin que se hubiera pedido.
Solución: tag por rol, con el mismo nombre ({ role: hardening_ssh, tags: [hardening_ssh] }), y verificar con --check que el filtro funciona de verdad antes de confiarse — las tareas saltadas deben salir como skipping, no ok/changed. Revertir lo aplicado de más fue sencillo (reponer PermitRootLogin/PasswordAuthentication a mano en esa máquina), pero el aviso real es no fiarse de un flag que nunca se comprobó.

Resultado

Incorporar una máquina al homelab — Linux o Windows — pasó de quince minutos de comandos manuales a una línea en el inventario y un comando. Todas las máquinas quedan en un estado idéntico y verificable: en Linux, nadie entra por SSH con contraseña, root no entra ni con clave, fail2ban vigila los intentos y los parches de seguridad se aplican solos; en Windows, actualizaciones de seguridad instaladas y reinicio automático solo si hace falta. Cada persona entra con su propia cuenta y su propia clave — los logs distinguen quién hizo qué, no solo que "ansible" lo hizo. Todo desde un único punto de gestión auditado.

Y si mañana el mini PC muere, la configuración de todo el homelab no muere con él: está en el repo.

Desde que Terraform entró en juego, el ciclo se cierra del todo: una máquina nace declarada en código, y sale de aquí configurada y securizada — ttyd fue la primera en recorrer ese camino completo, de cero a servicio funcionando, sin un solo paso manual entre medio.

Lecciones aprendidas

Idempotencia como herramienta de diagnóstico: una tarea que sale changed en cada ejecución no es un detalle cosmético — es un servicio muriéndose en bucle.
Los arreglos no se hacen, se declaran: cada problema resuelto a mano es un problema que volverá; resuelto en el rol, desaparece para siempre.
El orden importa más que la técnica: el hardening en dos fases no requiere ningún comando avanzado, solo pensar qué puerta cierras y cuándo.
El modelo declarativo viaja entre sistemas operativos: cambian los módulos y el transporte (SSH vs WinRM), pero declarar estado, verificar idempotencia y leer el recap es igual en Debian que en Windows Server.
group_vars no avisa cuando lo usas mal: un archivo y una carpeta con el mismo nombre de grupo no dan error, uno de los dos simplemente se ignora. Si una variable "desaparece" sin motivo aparente, es el primer sitio donde mirar.
--check no es fiable en cadenas de tareas dependientes: si la tarea 1 se simula (usuario creado) en vez de ejecutarse de verdad, la tarea 2 (autorizar su clave) puede fallar por depender de algo que en modo simulación nunca llegó a existir. No es un bug del playbook, es un límite conocido del propio modo --check.
El flag de línea de comandos no siempre gana: -u pierde frente a ansible_user en group_vars; para forzar un usuario de verdad (primer contacto con una máquina nueva) hace falta -e, que sí tiene precedencia por encima.
Un filtro de tags que no encuentra nada no avisa — falla en silencio ejecutando de más, no de menos. --check antes de confiar en un --skip-tags nuevo, siempre.

Notas rápidas

Referencia de uso diario — para no tener que ir a buscarlo al repo.

Simulacro sin aplicar cambios (dry-run)
# Qué cambiaría, sin tocar nada:
ansible-playbook site.yml --check --diff

# Solo comprobar que el YAML es válido, sin conectar a ninguna máquina:
ansible-playbook site.yml --syntax-check
Añadir una máquina nueva al inventario

Si se creó a mano: una línea en inventory.ini, bajo el grupo que corresponda ([linux], [windows] o [homelab]). Si viene de Terraform, no hace falta tocar nada — se apunta directamente al inventario que genera solo en cada apply:

ansible-playbook site.yml --limit nombre_maquina_nueva
ansible-playbook site.yml -i ../homelab-terraform/inventory_generado.ini --limit nombre_maquina_nueva

En una máquina recién creada por Terraform, el usuario de gestión todavía no existe — el primer contacto necesita -e ansible_user=root (ver Problemas, más arriba).

Secretos con Ansible Vault
# Cifrar un archivo de variables:
ansible-vault encrypt group_vars/all/vault.yml

# Editarlo (lo descifra, abre el editor, vuelve a cifrar al guardar):
ansible-vault edit group_vars/all/vault.yml

# Ejecutar un playbook que usa variables cifradas:
ansible-playbook site.yml --ask-vault-pass
Ejecutar solo una parte (tags y roles)

Cada rol lleva su propia tag, con el mismo nombre — base, usuario_ansible, usuario_hugo, hardening_ssh, base_windows:

# Solo un rol concreto:
ansible-playbook site.yml --tags usuario_hugo

# Todo menos ese:
ansible-playbook site.yml --skip-tags hardening_ssh

# Reanudar desde una tarea concreta si algo falló a mitad:
ansible-playbook site.yml --start-at-task="Instalar fail2ban"

Un tag que no existe en site.yml no da error — --tags/--skip-tags simplemente no encuentran nada que coincida, y el playbook corre entero como si el flag no estuviera puesto. Verificar en seco antes de fiarse de un filtro nuevo:

ansible-playbook site.yml --limit <host> --skip-tags hardening_ssh --check --ask-vault-pass

Las tareas del rol saltado deben aparecer como skipping, no ok/changed.

Estructura correcta de group_vars

Nunca un archivo <grupo>.yml y una carpeta <grupo>/ a la vez para el mismo grupo — uno de los dos se ignora sin avisar. Si separas variables normales de secretos con vault, todo dentro de la carpeta:

group_vars/
├── all/
│   ├── vars.yml    # variables normales, en claro
│   └── vault.yml   # secretos, cifrados
└── windows/
    ├── vars.yml
    └── vault.yml
Añadir una persona nueva (usuario_hugo)

Una clave más en la lista de group_vars/all/vars.yml (o crear la variable de esa persona si es la primera vez) y volver a lanzar el playbook — no hace falta tocar el rol.

hugo_ssh_pubkeys:
  - "ssh-rsa AAAA... clave-portatil"
  - "ssh-rsa AAAA... clave-casa"

El hash de la contraseña de sudo, siempre cifrado en group_vars/all/vault.yml, nunca en claro:

openssl passwd -6    # pide la contraseña por teclado y devuelve el hash SHA-512
win_user y el grupo de administradores en Windows no-inglés

El nombre del grupo local depende del idioma de instalación de Windows — en español es Administradores, no Administrators. Comprobar dentro de la VM si falla:

Get-LocalGroup | Select-Object Name

Ver el código

El proyecto completo — roles, playbooks y documentación — es público: