Al final de la lección anterior quedó planteado el problema, y conviene mirarlo de frente: toda la configuración de srv-tramontana vive en la memoria del administrador y en un runbook en prosa. Los usuarios y grupos de 05-01, las reglas de sudoers de 05-02, el pinning de paquetes de 05-03, el LVM de 05-04, las unidades y timers de 05-05, la rotación de logs de 05-06, la configuración de netplan de 06-01, el sshd_config endurecido de 06-02, las reglas de ufw de 06-03, AIDE y auditd de 06-04, los secretos de 06-05, PAM y AppArmor de 06-06, y los sysctl de 07-03. Todo eso se aplicó a mano, comando a comando, a lo largo de semanas.
Las consecuencias son concretas:
- El RTO de 8 horas que Marta aprobó depende de que alguien recuerde el orden correcto de todos esos pasos, bajo presión y probablemente de madrugada.
- La regla de 06-04 —«un servidor comprometido se reinstala, no se limpia»— es fácil de enunciar y muy cara de cumplir. Si reinstalar cuesta un día de trabajo, la tentación de «limpiar» es enorme.
srv-tramontana-pruebasno es igual que producción, por muy cuidadoso que fuera eluser-datade cloud-init. Y un entorno de pruebas que difiere no prueba lo que crees que prueba.- Nadie más que tú puede reconstruir el servidor.
Esta lección convierte todo eso en código versionado, revisable y ejecutable. No es una herramienta más: es el cambio de «yo sé cómo está configurado el servidor» a «el repositorio define cómo está configurado el servidor».
Contenido
- Estado deseado frente a instrucciones
- Idempotencia, y por qué lo cambia todo
- Por qué Ansible: sin agente, sobre SSH, en YAML
- Instalación, inventario y variables
- Módulos y tareas
- Playbooks: handlers, condiciones, bucles y bloques
- Plantillas Jinja2
- Roles: organizar para reutilizar
- Ansible Vault y los secretos
- Ejecución: --check, --diff, --tags y ansible-lint
- Caso Tramontana: reconstruir el servidor y medir el RTO
Estado deseado frente a instrucciones
desplegar.sh es un buen script. Tiene modo de simulación, bloqueo, copia previa, verificación con curl y rollback automático. Y aun así representa un modelo distinto del que hace falta aquí.
# Modelo IMPERATIVO: una secuencia de instrucciones
sudo useradd -r -u 997 -s /usr/sbin/nologin svc-tramontana
sudo groupadd -g 1002 tramontana
sudo usermod -aG tramontana operadorEjecuta eso dos veces y la segunda falla: el usuario ya existe. Para hacerlo repetible hay que escribir las comprobaciones a mano:
getent passwd svc-tramontana >/dev/null || \
sudo useradd -r -u 997 -s /usr/sbin/nologin svc-tramontana
getent group tramontana >/dev/null || sudo groupadd -g 1002 tramontana
id -nG operador | grep -qw tramontana || sudo usermod -aG tramontana operadorFunciona, y es lo que aprendiste a hacer en 04-06. Pero multiplícalo por las doscientas operaciones que configuran srv-tramontana y tendrás dos mil líneas de Bash llenas de comprobaciones, cada una una oportunidad de equivocarse.
# Modelo DECLARATIVO: describir el estado deseado
- name: Crear el grupo tramontana
ansible.builtin.group:
name: tramontana
gid: 1002
state: present
- name: Crear la cuenta de servicio
ansible.builtin.user:
name: svc-tramontana
uid: 997
group: tramontana
system: true
shell: /usr/sbin/nologin
create_home: false
state: presentLa diferencia no es de sintaxis: es de qué se escribe. En el primer modelo describes cómo llegar; en el segundo, dónde quieres estar. La herramienta comprueba el estado actual y hace solo lo que falte. Y la consecuencia práctica es enorme: el fichero se lee como una descripción del servidor, no como un procedimiento. Alguien que lo abra dentro de un año entiende cómo está configurada la máquina sin ejecutarlo.
| Imperativo (Bash) | Declarativo (Ansible) | |
|---|---|---|
| Qué escribes | Los pasos | El resultado |
| Ejecutar dos veces | Falla, salvo que lo programes | Sin efecto la segunda vez |
| Punto de partida | Debe ser conocido | Cualquiera |
| Se lee como | Un procedimiento | Una descripción |
| Bueno para | Operaciones puntuales, despliegue | Configuración |
Y una aclaración importante: Ansible no sustituye a desplegar.sh. Desplegar una versión concreta con rollback es una operación imperativa y el script lo hace bien. Lo que sustituye es la configuración del servidor.
Idempotencia, y por qué lo cambia todo
En 04-06 definiste idempotencia: una operación que se puede ejecutar varias veces con el mismo resultado. Ahí era una buena práctica; en gestión de configuración es la propiedad que hace viable todo el modelo.
$ ansible-playbook -i inventario.yml sitio.yml
PLAY RECAP *******************************************************************
srv-tramontana : ok=47 changed=12 unreachable=0 failed=0 skipped=3
# Y ejecutandolo otra vez, sin cambiar nada
$ ansible-playbook -i inventario.yml sitio.yml
PLAY RECAP *******************************************************************
srv-tramontana : ok=47 changed=0 unreachable=0 failed=0 skipped=3Ese changed=0 de la segunda ejecución es el objetivo, y tiene tres consecuencias que conviene enunciar:
- Se puede ejecutar sin miedo. No hay que preguntarse si ya se ejecutó. Si el servidor está como debe, no pasa nada.
- Detecta la deriva. Si mañana ejecutas el playbook y sale
changed=3, algo cambió fuera del código: alguien editó un fichero a mano, una actualización sobrescribió una configuración. El propio playbook es un control de integridad, complementario al AIDE de 06-04. - Se puede ejecutar periódicamente para que la configuración vuelva a converger sola.
El changed que informa cada tarea es una afirmación sobre el mundo, no sobre lo que hizo la herramienta. Y por eso los módulos que no pueden garantizarlo se marcan como problemáticos, que es la razón de la advertencia sobre command y shell que verás más adelante.
Por qué Ansible: sin agente, sobre SSH, en YAML
| Ansible | Puppet / Chef | Salt | Terraform | |
|---|---|---|---|---|
| Modelo | Push, sin agente | Pull, con agente | Ambos | Push, API |
| Transporte | SSH | HTTPS propio | ZeroMQ o SSH | API del proveedor |
| Lenguaje | YAML | DSL propio / Ruby | YAML | HCL |
| Qué gestiona | Configuración | Configuración | Configuración | Aprovisionamiento |
| Curva de entrada | Suave | Pronunciada | Media | Media |
| Escala grande | Cientos con ajustes | Miles | Miles | N/A |
Las tres razones por las que Ansible encaja aquí:
- Sin agente. No hay que instalar nada en el servidor gestionado ni mantener un demonio más. Menos superficie de ataque, en línea con 06-06.
- Sobre SSH. Usa exactamente la infraestructura que endureciste en 06-02: claves ed25519,
~/.ssh/config,sshd_configsin contraseñas. No añade un canal nuevo que asegurar, y la autenticación y la auditoría son las que ya tienes. - YAML. Se lee sin conocer el lenguaje, lo que importa cuando otra persona tiene que entender la configuración.
La distinción con Terraform conviene tenerla clara porque se confunden: Terraform aprovisiona —crea la máquina virtual, la red, el disco— y Ansible configura lo que hay dentro. Son complementarios: en 07-04 aprovisionaste con virt-install y cloud-init; ahora configuras con Ansible.
Instalación, inventario y variables
Ansible se instala en el equipo de control, no en el servidor gestionado:
# En portatil-alumno
$ sudo apt install ansible ansible-lint
$ ansible --version | head -2
ansible [core 2.16.3]
config file = /home/alumno/tramontana-infra/ansible.cfgEl repositorio, versionado en git:
$ mkdir -p ~/tramontana-infra/{inventario,group_vars,host_vars,roles,plantillas}
$ cd ~/tramontana-infra && git init# ansible.cfg
[defaults]
inventory = inventario/produccion.yml
roles_path = roles
host_key_checking = True
# Ver los cambios de fichero en cada ejecucion: aplica la convencion
# del curso de "diff -u despues de editar"
diff = True
stdout_callback = yaml
callbacks_enabled = profile_tasks, timer
interpreter_python = auto_silent
retry_files_enabled = False
[ssh_connection]
# Reutiliza la conexion SSH entre tareas: reduce mucho el tiempo total
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=300shost_key_checking = True es deliberado y va contra lo que recomiendan muchos tutoriales: desactivarlo evita el aviso de huella desconocida y, con él, la protección contra intermediarios que estudiaste en 06-02. Se deja activo y se acepta la huella la primera vez.
pipelining = True merece mención: sin él, Ansible copia un script al servidor y lo ejecuta, para cada tarea. Con él, lo envía por la conexión ya abierta. La diferencia en un playbook de cincuenta tareas son minutos.
El inventario
# inventario/produccion.yml
all:
children:
servidores_web:
hosts:
srv-tramontana:
ansible_host: 10.0.2.15
entorno: produccion
servidores_pruebas:
hosts:
srv-tramontana-pruebas:
ansible_host: 192.168.122.104
entorno: pruebas
vars:
ansible_user: operador
ansible_ssh_private_key_file: ~/.ssh/id_ed25519
ansible_python_interpreter: /usr/bin/python3$ ansible-inventory --graph
@all:
|--@servidores_pruebas:
| |--srv-tramontana-pruebas
|--@servidores_web:
| |--srv-tramontana
$ ansible all -m ping
srv-tramontana | SUCCESS => {"changed": false, "ping": "pong"}
srv-tramontana-pruebas | SUCCESS => {"changed": false, "ping": "pong"}Variables por grupo y por máquina
La precedencia de variables permite tener una definición común y diferencias por entorno, que es lo que hace posible que pruebas y producción sean casi idénticos de forma controlada:
# group_vars/all.yml — comun a todas las maquinas
tramontana_usuario_servicio: svc-tramontana
tramontana_uid_servicio: 997
tramontana_grupo: tramontana
tramontana_gid: 1002
tramontana_puerto: 8080
tramontana_version: "3.2.1"
tramontana_rutas:
- { ruta: /opt/tramontana, modo: '0755', dueno: root, grupo: root }
- { ruta: /opt/tramontana/releases, modo: '0755', dueno: root, grupo: root }
- { ruta: /opt/tramontana/shared/uploads, modo: '2770', dueno: svc-tramontana, grupo: tramontana }
- { ruta: /etc/tramontana, modo: '0750', dueno: root, grupo: tramontana }
- { ruta: /var/log/tramontana, modo: '0750', dueno: svc-tramontana, grupo: adm }
- { ruta: /srv/tramontana/backups, modo: '2770', dueno: operador, grupo: tramontana }
paquetes_base:
- postgresql-16
- restic
- ufw
- fail2ban
- auditd
- aide
- libpam-pwquality
- needrestart
- sysstat
sysctl_rendimiento:
net.core.default_qdisc: fq
net.ipv4.tcp_congestion_control: bbr
vm.swappiness: 10
vm.dirty_background_ratio: 5
vm.dirty_ratio: 10
fs.inotify.max_user_watches: 524288
sysctl_seguridad:
kernel.dmesg_restrict: 1
kernel.kptr_restrict: 2
kernel.randomize_va_space: 2
fs.protected_hardlinks: 1
fs.protected_symlinks: 1
fs.protected_fifos: 2
fs.protected_regular: 2
kernel.unprivileged_bpf_disabled: 1
kernel.yama.ptrace_scope: 1# group_vars/servidores_web.yml — solo produccion
tramontana_log_nivel: info
tramontana_max_conexiones: 80
tramontana_red_interna: 10.0.2.0/24
copia_hora: "02:30"# group_vars/servidores_pruebas.yml — solo pruebas
tramontana_log_nivel: debug
tramontana_max_conexiones: 20
tramontana_red_interna: 192.168.122.0/24
copia_hora: "22:00"
# Herramientas que en produccion no hacen falta
paquetes_extra:
- strace
- bpfcc-tools
- bpftrace
- linux-tools-genericAhí está el valor concreto: las diferencias entre entornos son explícitas y caben en diez líneas. En 07-04, con cloud-init, había que mantener dos ficheros paralelos y confiar en no olvidar nada. Aquí, lo que no aparece en group_vars/servidores_pruebas.yml es idéntico por construcción.
Los hechos (facts) son las variables que Ansible descubre de cada máquina:
$ ansible srv-tramontana -m ansible.builtin.setup \
-a 'filter=ansible_distribution*,ansible_memtotal_mb,ansible_processor_vcpus'
srv-tramontana | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Ubuntu",
"ansible_distribution_version": "24.04",
"ansible_memtotal_mb": 3891,
"ansible_processor_vcpus": 2
}
}Módulos y tareas
Un módulo es una unidad de trabajo idempotente. Los que se usan de verdad:
| Módulo | Para qué | Nota |
|---|---|---|
ansible.builtin.apt |
Paquetes en Debian/Ubuntu | state: present, latest, absent |
ansible.builtin.copy |
Copiar un fichero | Con mode, owner, group, backup |
ansible.builtin.template |
Copiar procesando Jinja2 | El que más se usa |
ansible.builtin.file |
Directorios, enlaces, permisos | state: directory, link, absent |
ansible.builtin.lineinfile |
Una línea en un fichero | Último recurso: mejor template |
ansible.builtin.user / group |
Identidades | uid/gid explícitos |
ansible.builtin.systemd_service |
Servicios y unidades | enabled, state, daemon_reload |
ansible.posix.sysctl |
Parámetros del kernel | Con sysctl_file |
community.general.ufw |
Cortafuegos | |
community.general.lvol / filesystem |
LVM y sistemas de ficheros | |
ansible.builtin.command / shell |
Ejecutar un comando | Último recurso |
Y la advertencia sobre los dos últimos, que es una de las cosas que separa un playbook bueno de uno malo:
# MAL: no es idempotente. Se ejecuta siempre y siempre informa 'changed'.
- name: Inicializar la base de datos de AIDE
ansible.builtin.command: aideinit
# BIEN: 'creates' hace que la tarea se salte si el fichero ya existe
- name: Inicializar la base de datos de AIDE
ansible.builtin.command:
cmd: aideinit -y -f
creates: /var/lib/aide/aide.dbLas tres formas de hacer idempotente un comando:
| Opción | Cuándo |
|---|---|
creates: <ruta> |
El comando produce un fichero: si existe, no se ejecuta |
removes: <ruta> |
Solo se ejecuta si el fichero existe |
changed_when: |
Decides tú, según la salida o el código de retorno |
- name: Comprobar si hay servicios pendientes de reinicio
ansible.builtin.command: needrestart -r l -p
register: pendientes
changed_when: false # nunca modifica nada: es una consulta
failed_when: pendientes.rc > 2changed_when: false en las consultas es lo que mantiene limpio el PLAY RECAP. Un playbook que siempre informa changed en cinco tareas pierde su valor como detector de deriva, porque no se distingue el ruido de la señal.
Playbooks: handlers, condiciones, bucles y bloques
# sitio.yml
- name: Configurar los servidores de Tramontana
hosts: all
become: true
gather_facts: true
tasks:
# ---------- Identidades ----------
- name: Crear el grupo tramontana
ansible.builtin.group:
name: "{{ tramontana_grupo }}"
gid: "{{ tramontana_gid }}"
state: present
tags: [usuarios]
- name: Crear la cuenta de servicio
ansible.builtin.user:
name: "{{ tramontana_usuario_servicio }}"
uid: "{{ tramontana_uid_servicio }}"
group: "{{ tramontana_grupo }}"
system: true
shell: /usr/sbin/nologin
create_home: false
state: present
tags: [usuarios]
# ---------- Rutas: un bucle sobre la lista de group_vars ----------
- name: Crear el arbol de directorios con sus permisos
ansible.builtin.file:
path: "{{ item.ruta }}"
state: directory
mode: "{{ item.modo }}"
owner: "{{ item.dueno }}"
group: "{{ item.grupo }}"
loop: "{{ tramontana_rutas }}"
loop_control:
label: "{{ item.ruta }}" # salida legible en vez del dict entero
tags: [rutas]
# ---------- Paquetes ----------
- name: Instalar los paquetes base
ansible.builtin.apt:
name: "{{ paquetes_base + (paquetes_extra | default([])) }}"
state: present
update_cache: true
cache_valid_time: 3600
tags: [paquetes]
- name: Fijar la version de PostgreSQL
ansible.builtin.dpkg_selections:
name: postgresql-16
selection: hold
tags: [paquetes]
# ---------- sysctl ----------
- name: Aplicar los parametros de seguridad del kernel
ansible.posix.sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
sysctl_file: /etc/sysctl.d/60-endurecimiento.conf
sysctl_set: true
reload: true
loop: "{{ sysctl_seguridad | dict2items }}"
loop_control:
label: "{{ item.key }}"
tags: [kernel, seguridad]
- name: Aplicar los parametros de rendimiento
ansible.posix.sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
sysctl_file: /etc/sysctl.d/70-rendimiento.conf
sysctl_set: true
reload: true
loop: "{{ sysctl_rendimiento | dict2items }}"
loop_control:
label: "{{ item.key }}"
tags: [kernel]
# ---------- Configuracion desde plantilla ----------
- name: Generar app.conf desde la plantilla
ansible.builtin.template:
src: plantillas/app.conf.j2
dest: /etc/tramontana/app.conf
owner: root
group: "{{ tramontana_grupo }}"
mode: '0640'
backup: true # deja una copia con fecha, como .bak-
validate: "grep -q '^db_host=' %s"
notify: Reiniciar tramontana
tags: [config]
# ---------- SSH, con la red de seguridad de 06-02 ----------
- name: Endurecer la configuracion de SSH
ansible.builtin.template:
src: plantillas/sshd_tramontana.conf.j2
dest: /etc/ssh/sshd_config.d/60-tramontana.conf
owner: root
group: root
mode: '0600'
# NO se aplica si la sintaxis es incorrecta: evita quedarte fuera
validate: /usr/sbin/sshd -t -f %s
notify: Recargar ssh
tags: [ssh, seguridad]
# ---------- Cortafuegos, en el ORDEN correcto ----------
- name: Politica por defecto de denegar entrante
community.general.ufw:
direction: incoming
policy: deny
tags: [firewall]
- name: Permitir SSH ANTES de habilitar el cortafuegos
community.general.ufw:
rule: limit
port: '22'
proto: tcp
comment: 'SSH con limite de tasa'
tags: [firewall]
- name: Permitir PostgreSQL solo desde la red interna
community.general.ufw:
rule: allow
port: '5432'
proto: tcp
src: "{{ tramontana_red_interna }}"
tags: [firewall]
- name: Habilitar el cortafuegos
community.general.ufw:
state: enabled
tags: [firewall]
# ---------- Servicio ----------
- name: Instalar la unidad de systemd
ansible.builtin.template:
src: plantillas/tramontana.service.j2
dest: /etc/systemd/system/tramontana.service
owner: root
group: root
mode: '0644'
notify:
- Recargar systemd
- Reiniciar tramontana
tags: [servicio]
- name: Activar el servicio
ansible.builtin.systemd_service:
name: tramontana.service
enabled: true
state: started
daemon_reload: true
tags: [servicio]
# ---------- Bloque con manejo de errores ----------
- name: Inicializar AIDE
block:
- name: Generar la base de datos de integridad
ansible.builtin.command:
cmd: aideinit -y -f
creates: /var/lib/aide/aide.db.new
register: aide_init
- name: Poner la base de datos en su sitio
ansible.builtin.copy:
src: /var/lib/aide/aide.db.new
dest: /var/lib/aide/aide.db
remote_src: true
mode: '0600'
when: aide_init.changed
rescue:
- name: Avisar de que AIDE no se pudo inicializar
ansible.builtin.debug:
msg: "AIDE no se inicializo. Revisar a mano; no bloquea el resto."
always:
- name: Registrar el intento
ansible.builtin.lineinfile:
path: /var/log/tramontana/ansible.log
line: "{{ ansible_date_time.iso8601 }} AIDE: {{ aide_init.rc | default('sin ejecutar') }}"
create: true
mode: '0640'
owner: "{{ tramontana_usuario_servicio }}"
group: adm
tags: [seguridad, aide]
handlers:
- name: Recargar systemd
ansible.builtin.systemd_service:
daemon_reload: true
- name: Reiniciar tramontana
ansible.builtin.systemd_service:
name: tramontana.service
state: restarted
- name: Recargar ssh
ansible.builtin.systemd_service:
name: ssh.service
state: reloadedLos mecanismos que aparecen ahí y merecen explicación:
notify y handlers. Un handler se ejecuta solo si la tarea que lo notifica informó changed, y una sola vez al final del play aunque lo notifiquen cinco tareas. Es exactamente lo que quieres: si app.conf y la unidad cambian, el servicio se reinicia una vez, no dos. Y si no cambia nada, no se reinicia.
validate. El módulo escribe el fichero en un temporal, ejecuta el comando de validación con %s sustituido por esa ruta, y solo si tiene éxito lo pone en su sitio. En la tarea de SSH esto es la red de seguridad de 06-02 automatizada: un sshd_config con un error de sintaxis nunca llega a instalarse, así que no te quedas fuera.
backup: true. Deja una copia con fecha antes de sobrescribir. Es la convención .bak-$(date +%F) del curso, aplicada por la herramienta.
El orden de las tareas de ufw. Permitir SSH antes de habilitar el cortafuegos, igual que en 06-03. En un playbook el orden es explícito y queda documentado, que es una ventaja sobre un runbook en prosa donde ese detalle se puede pasar por alto.
block/rescue/always. El equivalente del trap de 04-06: rescue se ejecuta si algo del bloque falla, always siempre. Aquí impide que un fallo de AIDE aborte toda la configuración.
Plantillas Jinja2
Es donde la gestión de configuración deja de ser «copiar ficheros» y pasa a ser útil:
{# plantillas/app.conf.j2 #}
# FICHERO GENERADO POR ANSIBLE — NO EDITAR A MANO
# Cualquier cambio manual se perdera en la proxima ejecucion.
# Fuente: {{ template_path | default('plantillas/app.conf.j2') }}
# Entorno: {{ entorno }}
# Generado: {{ ansible_date_time.iso8601 }}
db_host=127.0.0.1
db_port=5432
db_name=tramontana_reservas
max_conexiones={{ tramontana_max_conexiones }}
timeout_consulta={{ tramontana_timeout | default(30) }}
log_nivel={{ tramontana_log_nivel }}
escucha=127.0.0.1
puerto={{ tramontana_puerto }}
{% if entorno == 'pruebas' %}
# Solo en pruebas: trazas detalladas y datos sinteticos
traza_sql=true
datos_sinteticos=true
{% endif %}
{% for casa in casas | default([]) %}
casa_activa={{ casa }}
{% endfor %}$ ansible-playbook sitio.yml --tags config --diff --limit srv-tramontana-pruebas
TASK [Generar app.conf desde la plantilla] ************************************
--- before: /etc/tramontana/app.conf
+++ after: /etc/tramontana/app.conf
@@ -1,10 +1,14 @@
-# FICHERO GENERADO POR ANSIBLE — NO EDITAR A MANO
-# Entorno: pruebas
-max_conexiones=20
+# FICHERO GENERADO POR ANSIBLE — NO EDITAR A MANO
+# Entorno: pruebas
+max_conexiones=20
+traza_sql=true
+datos_sinteticos=true
changed: [srv-tramontana-pruebas]Esa cabecera de «no editar a mano» no es decorativa: sin ella, alguien editará el fichero, funcionará durante un mes, y la siguiente ejecución del playbook lo revertirá sin avisar. Con ella, al menos sabe lo que pasa.
Y una plantilla más, la de la unidad de systemd, que muestra el valor de generar configuración a partir de variables:
{# plantillas/tramontana.service.j2 #}
# GENERADO POR ANSIBLE — NO EDITAR
[Unit]
Description=Tramontana Reservas ({{ entorno }})
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=exec
User={{ tramontana_usuario_servicio }}
Group={{ tramontana_grupo }}
ExecStart=/opt/tramontana/app/tramontana --config /etc/tramontana/app.conf
Restart=on-failure
RestartSec=5s
LoadCredentialEncrypted=db_password:/etc/tramontana/secretos/db_password.cred
# Endurecimiento (06-06). Exposicion medida: 1.6 OK
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @obsolete
SystemCallFilter=getrandom
CapabilityBoundingSet=
ReadWritePaths=/var/log/tramontana /opt/tramontana/shared/uploads
MemoryMax={{ tramontana_memoria_max | default('512M') }}
TasksMax={{ tramontana_tareas_max | default(64) }}
LimitNOFILE=8192
[Install]
WantedBy=multi-user.targetFíjate en SystemCallFilter=getrandom: es la excepción que descubriste depurando el SIGSYS en el ejercicio de 06-06. Ahora está en el código, con la unidad completa, en lugar de en un drop-in que alguien tendría que recordar.
Roles: organizar para reutilizar
Un playbook de trescientas líneas es inmanejable. Los roles lo dividen en piezas con estructura fija:
$ ansible-galaxy init roles/tramontana
$ tree roles/ -L 2
roles/
├── comun
│ ├── defaults/main.yml # variables por defecto (prioridad baja)
│ ├── files/ # ficheros a copiar tal cual
│ ├── handlers/main.yml
│ ├── meta/main.yml # dependencias con otros roles
│ ├── tasks/main.yml # las tareas
│ ├── templates/ # plantillas Jinja2
│ └── vars/main.yml # variables del rol (prioridad alta)
├── seguridad
└── tramontana# sitio.yml, refactorizado
- name: Configurar los servidores de Tramontana
hosts: all
become: true
roles:
- role: comun # usuarios, paquetes, sysctl, zona horaria
- role: seguridad # ssh, ufw, fail2ban, auditd, aide, pam, apparmor
- role: tramontana # rutas, app.conf, unidad, timers, logrotateTres líneas, y cada rol es reutilizable. La ventaja se nota cuando haya un segundo servidor: comun y seguridad se aplican tal cual, y solo cambia el tercero.
Y Ansible Galaxy para no reescribir lo ya escrito:
$ ansible-galaxy collection install community.general ansible.posix
$ ansible-galaxy role install geerlingguy.postgresqlCon la misma advertencia que en 05-03 sobre los repositorios de terceros: instalar un rol de Galaxy es ejecutar código ajeno con privilegios de root en tu servidor. Se revisa antes, y se fija la versión.
Ansible Vault y los secretos
Los secretos no pueden ir en claro en un repositorio. Vault los cifra dentro del propio repositorio:
# Contenido (descifrado)
vault_db_password: "Zx9K2pQ7vLm4RtWn"
vault_restic_password: "R3st1c-Tr4m0nt4n4-2026"
vault_aide_clave: "..."$ head -3 group_vars/all/secretos.yml
$ANSIBLE_VAULT;1.1;AES256
36613264313632373933353436646138613831306638386566613864333331373766373530353137
6134663137363134333661386133343338326238623166390a3833326566...
$ ansible-vault view group_vars/all/secretos.yml
$ ansible-vault edit group_vars/all/secretos.yml
$ ansible-vault rekey group_vars/all/secretos.yml # cambiar la contrasenaCifrar un valor suelto, que permite que el resto del fichero siga siendo legible en un diff:
$ ansible-vault encrypt_string 'Zx9K2pQ7vLm4RtWn' --name 'vault_db_password'
vault_db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
36613264313632373933353436646138613831306638386566613864333331...Y la integración con el pass de 06-05, que evita tener dos fuentes de verdad de los secretos:
$ cat ~/.ansible-vault-pass.sh
#!/usr/bin/env bash
# Obtiene la contrasena de Vault desde pass, para no tenerla en un fichero.
set -euo pipefail
pass tramontana/ansible/vault
$ chmod 700 ~/.ansible-vault-pass.shAsí pass sigue siendo la fuente de verdad de todos los secretos, con su historial de git y su copia fuera del servidor, y Vault es solo el mecanismo de transporte hacia el playbook.
La convención de nombres vault_* para las variables cifradas, y referenciarlas desde variables normales, permite ver de un vistazo qué es secreto:
Y una advertencia: una variable de Vault usada en una plantilla acaba en claro en el servidor. Vault protege el repositorio, no el destino. La credencial de la base de datos sigue yendo a systemd-creds como en 06-05:
- name: Instalar la credencial cifrada de la base de datos
ansible.builtin.shell:
cmd: |
set -o pipefail
printf '%s' '{{ tramontana_db_password }}' \
| systemd-creds encrypt --name=db_password - \
/etc/tramontana/secretos/db_password.cred
creates: /etc/tramontana/secretos/db_password.cred
no_log: true # <- IMPRESCINDIBLE: sin esto, sale en la salida
notify: Reiniciar tramontanano_log: true es obligatorio en cualquier tarea que maneje un secreto. Sin él, Ansible imprime el comando completo —con la contraseña— en la salida y en cualquier registro que se guarde. Es el mismo principio de 06-05: un secreto en la línea de comandos es un secreto expuesto.
Ejecución: --check, --diff, --tags y ansible-lint
# --check: MODO SIMULACION. Es el --dry-run del curso, aplicado a todo.
$ ansible-playbook sitio.yml --check --diff --limit srv-tramontana-pruebas
TASK [comun : Instalar los paquetes base] *************************************
changed: [srv-tramontana-pruebas]
TASK [tramontana : Generar app.conf desde la plantilla] ***********************
--- before
+++ after
@@ -5,7 +5,7 @@
-max_conexiones=200
+max_conexiones=20
changed: [srv-tramontana-pruebas]
PLAY RECAP ********************************************************************
srv-tramontana-pruebas : ok=44 changed=7 unreachable=0 failed=0--check --diff juntos son la herramienta más valiosa del día a día: dicen exactamente qué cambiaría y qué líneas de cada fichero, sin tocar nada. Es la convención del curso —simular antes de actuar— aplicada a la configuración entera del servidor.
Con una limitación honesta: en modo --check, las tareas que dependen del resultado de una anterior pueden fallar o informar mal, porque la anterior no se ejecutó de verdad. Un command con creates sobre un fichero que se crearía en una tarea previa dará un falso positivo.
# Limitar el alcance
$ ansible-playbook sitio.yml --limit srv-tramontana-pruebas
$ ansible-playbook sitio.yml --tags firewall,ssh
$ ansible-playbook sitio.yml --skip-tags paquetes
$ ansible-playbook sitio.yml --start-at-task "Instalar la unidad de systemd"
# Detalle creciente
$ ansible-playbook sitio.yml -v # resultados
$ ansible-playbook sitio.yml -vvv # + conexion SSH y modulosY el análisis estático, que es a Ansible lo que shellcheck a Bash:
$ ansible-lint
WARNING Listing 3 violation(s) that are fatal
risky-file-permissions: File permissions unset or incorrect
roles/tramontana/tasks/main.yml:24 Task/Handler: Copiar el script de respaldo
command-instead-of-module: apt-mark used in place of dpkg_selections module
roles/comun/tasks/main.yml:41 Task/Handler: Fijar la version de postgresql
no-changed-when: Commands should not change things if nothing needs doing
roles/seguridad/tasks/main.yml:88 Task/Handler: Comprobar reinicios pendientes
$ ansible-lint --write # corrige automaticamente lo que puedeLos tres avisos son reales y del tipo que importa: permisos sin declarar —que dejarían el fichero con el umask por defecto—, un comando donde hay un módulo idempotente, y una consulta sin changed_when: false que ensucia el PLAY RECAP. ansible-lint en el gancho de pre-commit de git es la forma de que no se cuelen.
Caso Tramontana: reconstruir el servidor y medir el RTO
El objetivo del módulo: que reconstruir srv-tramontana desde cero sea una operación de código, no de memoria.
El experimento
# 1. Una maquina limpia, sin nada configurado
$ virsh destroy srv-tramontana-pruebas 2>/dev/null
$ virsh undefine srv-tramontana-pruebas --remove-all-storage
$ cd /var/lib/libvirt/images
$ sudo qemu-img create -f qcow2 -F qcow2 \
-b noble-server-cloudimg-amd64.img pruebas.qcow2 20G
$ sudo virt-install --name srv-tramontana-pruebas \
--memory 2048 --vcpus 2 --cpu host-passthrough \
--disk path=/var/lib/libvirt/images/pruebas.qcow2,bus=virtio \
--disk path=/var/lib/libvirt/images/semilla.iso,device=cdrom \
--network network=default,model=virtio --os-variant ubuntu24.04 \
--graphics none --import --noautoconsole
# El cloud-init minimo: solo usuario, clave SSH y python3. Nada mas.
# Todo lo demas lo hace Ansible.
$ sleep 90 && ansible srv-tramontana-pruebas -m ping
srv-tramontana-pruebas | SUCCESS => {"changed": false, "ping": "pong"}La reconstrucción, cronometrada
$ time ansible-playbook sitio.yml --limit srv-tramontana-pruebas
PLAY [Configurar los servidores de Tramontana] ********************************
TASK [comun : Crear el grupo tramontana] **************************************
changed: [srv-tramontana-pruebas]
...
TASK [tramontana : Activar el timer de respaldo] ******************************
changed: [srv-tramontana-pruebas]
RUNNING HANDLER [Recargar systemd] ********************************************
changed: [srv-tramontana-pruebas]
RUNNING HANDLER [Reiniciar tramontana] ****************************************
changed: [srv-tramontana-pruebas]
PLAY RECAP ********************************************************************
srv-tramontana-pruebas : ok=118 changed=94 unreachable=0 failed=0 skipped=6
real 6m41.882s
user 0m48.204s
sys 0m12.118sLa verificación
Un playbook que termina sin error no significa que el servidor funcione. Hay que comprobarlo con los mismos criterios de siempre:
$ ansible srv-tramontana-pruebas -m shell -a '~/scripts/revision_salud.sh; echo "estado: $?"'
srv-tramontana-pruebas | CHANGED | rc=0 >>
estado: 0
$ ansible srv-tramontana-pruebas -b -m shell -a 'systemd-analyze security tramontana.service | tail -1'
→ Overall exposure level for tramontana.service: 1.6 OK 🙂
$ ansible srv-tramontana-pruebas -b -m shell -a 'ufw status verbose | head -6'
$ ansible srv-tramontana-pruebas -b -m shell -a 'sshd -T | grep -E "permitrootlogin|passwordauth"'
permitrootlogin no
passwordauthentication no
$ ansible srv-tramontana-pruebas -b -m shell -a 'sysctl -n kernel.yama.ptrace_scope net.ipv4.tcp_congestion_control'
1
bbr
$ ansible srv-tramontana-pruebas -b -m shell -a 'aa-status --enabled && echo "apparmor activo"'
apparmor activoExposición 1.6, SSH endurecido, sysctl aplicados, AppArmor activo. Idéntico a producción, porque sale del mismo código.
Y la segunda ejecución
$ ansible-playbook sitio.yml --limit srv-tramontana-pruebas
PLAY RECAP ********************************************************************
srv-tramontana-pruebas : ok=118 changed=0 unreachable=0 failed=0 skipped=6
real 1m14.402schanged=0: el playbook es idempotente de verdad. Y esa segunda ejecución de 74 segundos es ahora también un detector de deriva que se puede lanzar semanalmente.
El RTO
| Fase | Antes (manual) | Con Ansible |
|---|---|---|
| Aprovisionar la máquina | 20-30 min | 2 min (cloud-init) |
| Configurar el sistema | 6-7 horas | 7 min |
| Restaurar los datos | 30-45 min | 30-45 min (restic) |
| Verificar | 30 min | 5 min (comprobaciones automáticas) |
| Total | ~8 horas | ~50 minutos |
De 8 horas a menos de una. Y la mejora no es solo el tiempo: la reconstrucción manual es propensa a errores y depende de una persona concreta; la automatizada produce siempre el mismo resultado y la puede lanzar cualquiera del equipo con acceso al repositorio.
Eso es lo que convierte la regla de 06-04 en un procedimiento cumplible: «un servidor comprometido se reinstala, no se limpia» deja de ser un consejo incómodo cuando reinstalar cuesta cincuenta minutos. Y conviene actualizar el runbook y hablar con Marta: el RTO acordado puede bajar de 8 horas a 2, con margen.
Lo que queda fuera, y hay que decirlo
| No automatizado | Por qué |
|---|---|
La clave GPG de pass |
Es la llave maestra: se restaura a mano desde su custodia física |
| El certificado de Let's Encrypt | Se reemite con certbot; no tiene sentido copiarlo |
| La base de datos de AIDE | Debe generarse en la máquina reconstruida, no copiarse |
Los datos (restic) |
Es un proceso aparte, con su propia verificación |
| La decisión de reconstruir | Es humana |
Errores Comunes y Consejos
- Usar
commandoshelldonde hay un módulo. Pierde la idempotencia y la comprobación de estado.ansible-lintlo detecta. - Olvidar
changed_when: falseen las consultas. El playbook informachangedsiempre y pierde su valor como detector de deriva. - Olvidar
no_log: trueen una tarea con secretos. El secreto aparece en la salida y en cualquier registro que se conserve. - Desactivar
host_key_checking. Es lo que recomiendan muchos tutoriales, y elimina la protección contra intermediarios de 06-02. - Editar a mano un fichero generado por plantilla. Se revierte en la siguiente ejecución. De ahí la cabecera de «NO EDITAR A MANO».
- No usar
validateen configuraciones críticas. Unsshd_configcon un error de sintaxis te deja fuera.validate: /usr/sbin/sshd -t -f %slo impide. - Habilitar
ufwantes de permitir SSH. El mismo error de 06-03, ahora en código. El orden de las tareas importa. - Ejecutar contra producción sin probar en pruebas. Para eso está
--limit, y para eso existesrv-tramontana-pruebas. - Fiarse ciegamente de
--check. Las tareas que dependen de una anterior pueden dar falsos positivos porque la anterior no se ejecutó. - Instalar roles de Galaxy sin revisarlos. Es ejecutar código ajeno como root en tu servidor. Revísalo y fija la versión.
- Creer que un playbook sin errores significa un servidor funcionando. Verifica siempre con comprobaciones reales:
revision_salud.sh,sshd -T,systemd-analyze security. - Consejo de método. El playbook es documentación ejecutable: la única que no puede quedar desfasada, porque si difiere de la realidad el
changedte lo dice. Trátalo como código: git, revisión de cambios,ansible-linten pre-commit, y probado en pruebas antes de producción.
Ejercicios
Ejercicio 1
Escribe el rol seguridad que aplique el endurecimiento de 06-06: PAM con pam_pwquality y pam_faillock, los sysctl de seguridad, los montajes con noexec, y los módulos del kernel bloqueados. Presta especial atención a no dejar la máquina inaccesible, y explica cada precaución.
Ejercicio 2
ansible-playbook sitio.yml --check sobre producción informa changed=5, cuando debería ser 0. Diseña el procedimiento para investigar de dónde viene esa deriva y decidir qué hacer en cada caso.
Ejercicio 3
Marta pregunta si con Ansible se puede reducir el RTO acordado de 8 horas. Redacta la respuesta con el nuevo número, lo que sigue sin estar automatizado, y qué haría falta para bajar más.
Soluciones
Solución 1
# roles/seguridad/defaults/main.yml
seguridad_pam_minlen: 12
seguridad_pam_minclass: 3
seguridad_faillock_deny: 5
seguridad_faillock_unlock: 600
seguridad_montajes_noexec: true
seguridad_modulos_bloqueados:
- cramfs
- freevxfs
- jffs2
- hfs
- hfsplus
- udf
- dccp
- sctp
- rds
- tipc# roles/seguridad/tasks/main.yml
---
# =====================================================================
# PRECAUCION GENERAL: este rol puede dejar la maquina inaccesible.
# Se prueba SIEMPRE contra srv-tramontana-pruebas antes de produccion.
# =====================================================================
# ---------- sysctl de seguridad ----------
# Riesgo bajo: un valor invalido falla en la tarea, no al arrancar.
- name: Aplicar los parametros de seguridad del kernel
ansible.posix.sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
sysctl_file: /etc/sysctl.d/60-endurecimiento.conf
sysctl_set: true
state: present
reload: true
loop: "{{ sysctl_seguridad | dict2items }}"
loop_control:
label: "{{ item.key }}"
tags: [kernel]
# ---------- Modulos bloqueados ----------
- name: Bloquear modulos del kernel no utilizados
ansible.builtin.template:
src: blacklist-tramontana.conf.j2
dest: /etc/modprobe.d/blacklist-tramontana.conf
owner: root
group: root
mode: '0644'
notify: Regenerar initramfs # <- IMPRESCINDIBLE (leccion de 07-01)
tags: [modulos]
# ---------- PAM: LA PARTE PELIGROSA ----------
# Un error de sintaxis en PAM impide TODO inicio de sesion, incluida la
# consola. Precauciones, en orden:
- name: "PAM | Precaucion 1: verificar que hay acceso alternativo"
ansible.builtin.assert:
that:
- ansible_connection != 'local'
- inventory_hostname in groups['servidores_pruebas'] or pam_confirmado | default(false)
fail_msg: >-
Configurar PAM en produccion requiere -e pam_confirmado=true y una
sesion de root abierta en otra terminal. Prueba primero en pruebas.
tags: [pam]
- name: "PAM | Precaucion 2: copia de seguridad con fecha"
ansible.builtin.copy:
src: "{{ item }}"
dest: "{{ item }}.bak-{{ ansible_date_time.date }}"
remote_src: true
mode: preserve
force: false # no sobrescribe una copia previa del mismo dia
loop:
- /etc/pam.d/common-auth
- /etc/pam.d/common-password
tags: [pam]
- name: "PAM | Instalar libpam-pwquality"
ansible.builtin.apt:
name: libpam-pwquality
state: present
tags: [pam]
- name: "PAM | Politica de calidad de contrasenas"
ansible.builtin.template:
src: pwquality.conf.j2
dest: /etc/security/pwquality.conf
owner: root
group: root
mode: '0644'
tags: [pam]
- name: "PAM | Configuracion de faillock"
ansible.builtin.template:
src: faillock.conf.j2
dest: /etc/security/faillock.conf
owner: root
group: root
mode: '0644'
tags: [pam]
# La pila de PAM se toca con pam_auth_update, que valida y mantiene la
# coherencia, en lugar de editar common-auth a mano con lineinfile.
- name: "PAM | Activar los modulos mediante pam-auth-update"
ansible.builtin.command:
cmd: pam-auth-update --enable pwquality faillock
register: pam_update
changed_when: "'Nothing to do' not in pam_update.stdout"
tags: [pam]
# ---------- Precaucion 3: VERIFICAR que se puede autenticar ----------
# Si esto falla, hay que revertir INMEDIATAMENTE con la sesion que sigue
# abierta.
- name: "PAM | Verificar que la autenticacion sigue funcionando"
ansible.builtin.command:
cmd: "runuser -u {{ ansible_user }} -- /usr/bin/true"
changed_when: false
register: pam_verificacion
failed_when: pam_verificacion.rc != 0
tags: [pam]
- name: "PAM | Verificar que sudo sigue funcionando"
ansible.builtin.command:
cmd: sudo -n -l
become: false
changed_when: false
tags: [pam]
# ---------- Montajes seguros: la OTRA parte peligrosa ----------
# Un fstab mal escrito impide arrancar (07-01). Por eso: copia, plantilla
# validada, y mount -a ANTES de dar por buena la tarea.
- name: "Montajes | Copia de seguridad de fstab"
ansible.builtin.copy:
src: /etc/fstab
dest: "/etc/fstab.bak-{{ ansible_date_time.date }}"
remote_src: true
mode: preserve
force: false
when: seguridad_montajes_noexec
tags: [montajes]
- name: "Montajes | Comprobar en caliente que noexec no rompe nada"
block:
- name: "Montajes | Remontar /tmp con noexec temporalmente"
ansible.posix.mount:
path: /tmp
state: remounted
opts: defaults,noatime,noexec,nosuid,nodev
fstype: ext4
src: "{{ ansible_mounts | selectattr('mount','equalto','/tmp')
| map(attribute='device') | first }}"
- name: "Montajes | Comprobar que apt sigue funcionando"
ansible.builtin.apt:
name: tree
state: present
force_apt_get: true
changed_when: false
- name: "Montajes | Comprobar que el despliegue sigue funcionando"
ansible.builtin.command:
cmd: /home/operador/scripts/desplegar.sh --dry-run {{ tramontana_version }}
changed_when: false
become_user: operador
rescue:
- name: "Montajes | Revertir: noexec rompe algo"
ansible.posix.mount:
path: /tmp
state: remounted
opts: defaults,noatime
fstype: ext4
src: "{{ ansible_mounts | selectattr('mount','equalto','/tmp')
| map(attribute='device') | first }}"
- name: "Montajes | Abortar el endurecimiento de montajes"
ansible.builtin.fail:
msg: "noexec en /tmp rompe apt o el despliegue. Investigar antes de persistir."
when: seguridad_montajes_noexec
tags: [montajes]
- name: "Montajes | Persistir en fstab (solo si la prueba en caliente paso)"
ansible.posix.mount:
path: "{{ item.ruta }}"
src: "{{ item.origen }}"
fstype: "{{ item.tipo }}"
opts: "{{ item.opciones }}"
state: mounted
loop:
- { ruta: /var/tmp, origen: /tmp, tipo: none, opciones: 'bind,noexec,nosuid,nodev' }
- { ruta: /dev/shm, origen: tmpfs, tipo: tmpfs, opciones: 'defaults,noexec,nosuid,nodev' }
loop_control:
label: "{{ item.ruta }}"
when: seguridad_montajes_noexec
tags: [montajes]
- name: "Montajes | VERIFICAR fstab antes de que alguien reinicie"
ansible.builtin.command:
cmd: mount -a
changed_when: false
tags: [montajes]
# ---------- Comprobacion final ----------
- name: "Verificar que el servicio sigue en pie tras el endurecimiento"
ansible.builtin.command:
cmd: /home/operador/scripts/revision_salud.sh
become_user: operador
changed_when: false
register: salud
failed_when: salud.rc != 0
tags: [verificacion]# roles/seguridad/handlers/main.yml
- name: Regenerar initramfs
ansible.builtin.command:
cmd: update-initramfs -u -k allLas precauciones, que es lo que pide el ejercicio:
| Precaución | Contra qué protege |
|---|---|
assert que exige pam_confirmado=true en producción |
Ejecutar el rol de PAM contra producción por descuido, sin sesión de rescate abierta |
force: false en las copias |
Sobrescribir una copia buena con una ya rota si se ejecuta dos veces el mismo día |
pam-auth-update en vez de lineinfile |
Editar common-auth a mano es la forma clásica de romper el orden de la pila. La herramienta valida y mantiene la coherencia |
Verificación con runuser y sudo -n -l |
Detectar que la autenticación se ha roto mientras la conexión sigue abierta, que es la única ventana para arreglarlo |
block/rescue en los montajes |
noexec en /tmp rompe instaladores; el rescue revierte al estado anterior en vez de dejar la máquina a medias |
Prueba en caliente antes de tocar fstab |
Un fstab roto impide arrancar (07-01). Se prueba con remounted, que es reversible |
mount -a al final |
La red de seguridad de 05-04 y 07-01, ahora automatizada |
notify: Regenerar initramfs |
Tocar modprobe.d sin regenerar el initramfs produce el prompt (initramfs) |
revision_salud.sh al final |
Endurecer y romper el servicio no es endurecer |
Y la precaución que no está en el código y hay que escribir en el runbook: antes de ejecutar este rol contra producción, virsh snapshot-create-as o el equivalente. El código minimiza el riesgo; no lo elimina.
Solución 2
changed=5 en --check significa que el servidor real difiere del código. Hay tres causas posibles y llevan a decisiones distintas, así que primero hay que identificar cuál es cada una.
# Paso 1: QUE cambiaria exactamente. --diff da las lineas concretas.
$ ansible-playbook sitio.yml --check --diff --limit srv-tramontana \
2>&1 | tee /tmp/deriva-$(date +%F).txt
$ grep -E '^(TASK|changed:)' /tmp/deriva-$(date +%F).txt | grep -B1 changed
TASK [comun : Instalar los paquetes base]
changed: [srv-tramontana]
TASK [tramontana : Generar app.conf desde la plantilla]
changed: [srv-tramontana]
TASK [seguridad : Aplicar los parametros de seguridad del kernel]
changed: [srv-tramontana]
TASK [seguridad | Configuracion de faillock]
changed: [srv-tramontana]
TASK [tramontana : Instalar la unidad de systemd]
changed: [srv-tramontana]# Paso 2: CUANDO cambio y QUIEN. Aqui es donde el trabajo del Modulo 6 rinde.
$ ansible srv-tramontana -b -m shell -a 'aide --check 2>&1 | head -30'
$ ansible srv-tramontana -b -m shell -a 'ausearch -k tramontana_conf -ts recent -i | tail -20'
$ ansible srv-tramontana -b -m shell -a 'journalctl --since "7 days ago" | grep -E "sudo:.*COMMAND" | tail -20'
$ ansible srv-tramontana -b -m shell -a 'grep -E "install|upgrade" /var/log/apt/history.log | tail -10'
$ ansible srv-tramontana -b -m shell -a 'ls -la /etc/tramontana/*.bak-* /etc/systemd/system/tramontana.service.d/ 2>/dev/null'Las tres causas y su tratamiento, que es el fondo del ejercicio:
| Causa | Cómo se reconoce | Qué hacer |
|---|---|---|
| A. Cambio manual legítimo no llevado al código | auditd muestra un sudo de alguien del equipo, hay un .bak- con fecha, y el cambio tiene sentido |
Llevar el cambio al código, no revertirlo |
| B. Deriva no autorizada | Nadie lo reconoce, no hay traza en auditd, o el auid es inesperado |
Investigar como incidente (06-04) antes de tocar nada |
| C. El código está desactualizado | El servidor está bien y el playbook refleja un estado antiguo | Corregir el playbook |
Aplicándolo a los cinco cambios de este caso:
# --- Cambio 1: paquetes ---
$ grep -A2 'Instalar los paquetes base' /tmp/deriva-*.txt
# El diff muestra que instalaria 'sysstat', que ya esta instalado...
$ ansible srv-tramontana -b -m shell -a 'dpkg -l sysstat | tail -1'
ii sysstat 12.6.1-2 amd64 system performance toolsFalso positivo de --check: apt con update_cache no puede comprobar el estado real sin actualizar el índice, así que informa changed de forma conservadora. No es deriva. Se confirma ejecutando sin --check en pruebas y viendo que da ok.
# --- Cambio 2: app.conf ---
--- before: /etc/tramontana/app.conf
+++ after: /etc/tramontana/app.conf
@@ -5,7 +5,7 @@
-timeout_consulta=45
+timeout_consulta=30
$ ansible srv-tramontana -b -m shell -a 'ausearch -k tramontana_conf -i | grep -A2 "success=yes" | tail -6'
type=SYSCALL ... auid=luis uid=root euid=root comm="vim" key=tramontana_confCausa A. Luis subió el tiempo de espera a 45 s para diagnosticar unos errores. Cambio legítimo pero no documentado. La decisión: no revertirlo en silencio. Se habla con Luis, se decide si 45 es correcto, y si lo es se lleva a group_vars:
# group_vars/servidores_web.yml
tramontana_timeout: 45 # subido el 2026-08-15 por consultas lentas (ver #142)Grave. Alguien bajó ptrace_scope, exactamente lo que se desaconsejó en 07-02, y eso permite leer la memoria de otros procesos del mismo usuario — incluida la credencial de la base de datos.
$ ansible srv-tramontana -b -m shell -a 'grep -rn ptrace /etc/sysctl.d/'
$ ansible srv-tramontana -b -m shell -a 'ausearch -k privilegios -ts recent -i | tail'Si hay traza de alguien del equipo, es causa A con una decisión que hay que revertir y explicar. Si no hay traza, es causa B: se trata como incidente según el procedimiento de 06-04, y la db_password se considera comprometida y se rota.
Causa C, probablemente: alguien ajustó el valor tras varios bloqueos accidentales y fue una decisión razonable. Se confirma y se corrige el código, que es lo que estaba desactualizado.
# --- Cambio 5: unidad de systemd ---
$ ansible srv-tramontana -b -m shell -a 'ls -la /etc/systemd/system/tramontana.service.d/'
-rw-r--r-- 1 root root 214 ago 18 16:12 endurecimiento.confHay un drop-in que el playbook no conoce. El playbook genera la unidad completa, y el drop-in la modifica: los dos coexisten y el resultado efectivo no es el del código. Causa A, y hay que decidir el modelo: o el playbook genera también el drop-in, o se integran sus directivas en la plantilla de la unidad. Lo segundo es más limpio, y de hecho la plantilla ya las incluye — el drop-in es un resto del trabajo manual de 06-06 que hay que retirar:
- name: Retirar drop-ins manuales ya integrados en la plantilla
ansible.builtin.file:
path: /etc/systemd/system/tramontana.service.d/endurecimiento.conf
state: absent
notify:
- Recargar systemd
- Reiniciar tramontanaEl procedimiento, generalizado:
--check --diff, guardando la salida con fecha.- Para cada cambio, correlacionar con
aide --check,ausearch,journalctly/var/log/apt/history.log. - Clasificar en A, B o C.
- A: llevar al código tras confirmar con quien lo hizo. B: procedimiento de incidentes, no tocar nada aún. C: corregir el código.
- Reejecutar hasta
changed=0. - Documentar en el registro de cambios qué era cada uno.
Y la mejora que evita repetirlo: automatizar la detección con un timer semanal que ejecute --check y avise solo si hay cambios, según el principio de silencio si todo va bien:
$ cat ~/tramontana-infra/comprobar_deriva.sh
#!/usr/bin/env bash
# Detecta deriva de configuracion. Silencio si no hay ninguna.
set -euo pipefail
salida="$(mktemp)"; trap 'rm -f "$salida"' EXIT
ansible-playbook sitio.yml --check --diff --limit srv-tramontana >"$salida" 2>&1 || true
cambios="$(grep -oP 'changed=\K[0-9]+' "$salida" | head -1)"
if [[ "${cambios:-0}" -gt 0 ]]; then
mail -s "[srv-tramontana] Deriva de configuracion: ${cambios} cambios" \
[email protected] <"$salida"
exit 1
fiFíjate en que esto convierte el playbook en un tercer control de integridad, complementario a AIDE (que vigila ficheros) y auditd (que vigila accesos): este vigila el estado lógico de la configuración, que ninguno de los otros dos ve.
Solución 3
Revisión del RTO tras la automatización de la configuración Para: Marta Vidal · De: Operaciones de sistemas · 18 de agosto de 2026
Respuesta corta: sí. Propongo bajar el RTO acordado de 8 horas a 2 horas, con margen holgado sobre el tiempo medido.
Qué ha cambiado. Hasta ahora, la configuración de
srv-tramontanaexistía en dos sitios: en el propio servidor y en mi cabeza, con un runbook en prosa como apoyo. Reconstruirlo significaba seguir a mano varios cientos de pasos —usuarios, permisos, paquetes, discos, servicios, cortafuegos, endurecimiento, registros, copias— en el orden correcto y sin olvidar ninguno.Desde esta semana, toda esa configuración está escrita como código versionado, revisable y ejecutable. Reconstruir el servidor consiste en lanzar un comando.
La medición. He reconstruido el servidor desde cero en el entorno de pruebas, cronometrándolo:
Fase Antes (manual) Ahora (automatizado) Crear la máquina 20-30 min 2 min Configurar el sistema completo 6-7 horas 7 min Restaurar los datos 30-45 min 30-45 min Verificar que todo funciona 30 min 5 min Total ~8 horas ~50 minutos Y he comprobado que el resultado es idéntico a producción con las mismas medidas objetivas que usamos habitualmente: la puntuación de seguridad del servicio, la configuración efectiva de SSH, los parámetros del sistema y el estado del cortafuegos.
Por qué propongo 2 horas y no 1. El tiempo medido es de 50 minutos en condiciones de laboratorio. Un incidente real añade cosas que no se pueden medir de antemano:
- Tiempo de decisión: darse cuenta, diagnosticar y decidir reconstruir.
- Hardware o proveedor: esperar a que haya una máquina disponible.
- Imprevistos: una restauración que falla al primer intento, un problema de red.
- Comunicación y verificación con el equipo.
Un compromiso de 2 horas nos deja más del doble del tiempo medido como colchón. Comprometer 1 hora sería ajustado, y un RTO que no se puede cumplir es peor que uno conservador.
Lo que sigue sin estar automatizado, y consta:
Elemento Por qué Tiempo La clave maestra de secretos Es la llave de todo lo demás; se restaura a mano desde su custodia física, deliberadamente 5 min El certificado del sitio web Se vuelve a emitir; no tiene sentido copiarlo 2 min La base de datos de integridad Debe generarse en la máquina nueva, no copiarse Incluido Los datos de reservas Es un proceso aparte, con su propia verificación 30-45 min La decisión de reconstruir Es humana, y debe serlo Variable Beneficios adicionales, más allá del tiempo. Tres que me parecen tan importantes como el RTO:
- Ya no dependemos de una sola persona. Cualquiera del equipo con acceso al repositorio puede reconstruir el servidor. Antes, si yo no estaba disponible, el RTO era indefinido.
- El entorno de pruebas es ahora idéntico a producción, porque sale del mismo código. Eso significa que lo que probamos allí es representativo, cosa que antes no podíamos garantizar.
- Detectamos cambios no autorizados. Ejecutando el código en modo simulación sabemos si el servidor difiere de lo que debería ser. Lo he programado semanalmente, y avisa solo si hay algo. De hecho, la primera ejecución ya detectó cinco diferencias, cuatro de ellas cambios legítimos que nadie había documentado.
Y algo que quiero destacar. Hace unas semanas te expliqué que un servidor comprometido debe reinstalarse, no limpiarse. Es la recomendación correcta y era, honestamente, difícil de cumplir: nadie decide alegremente perder un día de trabajo. Con 50 minutos de reconstrucción, esa recomendación pasa de ser un consejo incómodo a ser el procedimiento evidente. La automatización no es solo eficiencia: es lo que hace cumplible una decisión de seguridad.
Qué haría falta para bajar más. Si en algún momento el objetivo fuera un RTO por debajo de 30 minutos, las opciones serían:
Medida Efecto Coste Un segundo servidor ya configurado y en espera Elimina la fase de reconstrucción Duplicar el servidor Restauración continua a un servidor de reserva Reduce también el tiempo de datos Servidor + trabajo de mantenimiento Dos servidores activos con reparto de carga El fallo de uno no interrumpe el servicio Duplicar, más un balanceador Las tres cambian de problema: dejamos de hablar de recuperación y pasamos a hablar de redundancia. Es el asunto que tengo pendiente de traerte con números, y creo que es la conversación natural después de esta.
Propuesta concreta. Actualizar el acuerdo de nivel de servicio a RTO de 2 horas y RPO de 4 horas (este último sin cambios), y programar un simulacro de recuperación completo cada seis meses para verificar que el número se sostiene. El primero, en septiembre.
Conclusión
La configuración de srv-tramontana ya no vive en tu cabeza. Vive en un repositorio de git, escrita como estado deseado en lugar de como secuencia de instrucciones, y esa diferencia es lo que hace que el fichero se lea como una descripción del servidor y no como un procedimiento. Sabes por qué la idempotencia es la propiedad central —permite ejecutar sin miedo, detecta la deriva y hace posible la convergencia periódica—, y por qué command y shell son el último recurso: rompen justamente eso. Tienes inventarios con variables por grupo que hacen explícitas y mínimas las diferencias entre entornos, plantillas Jinja2 que generan app.conf y la unidad de systemd —con la excepción de getrandom que descubriste depurando un SIGSYS, ahora en el código y no en la memoria de nadie—, roles que organizan el conjunto, y Vault integrado con el pass de 06-05 para no tener dos fuentes de verdad de los secretos.
Y tienes las salvaguardas donde importan: validate: sshd -t -f %s impide instalar una configuración de SSH que te dejaría fuera, backup: true deja la copia con fecha de la convención del curso, los handlers reinician el servicio solo si algo cambió, el orden de las tareas de ufw permite SSH antes de habilitar el cortafuegos, y --check --diff es el --dry-run del curso llevado a la configuración entera del servidor. Con la honestidad de saber que --check tiene falsos positivos y que un playbook sin errores no significa un servidor funcionando: por eso cada ejecución termina verificando con revision_salud.sh, sshd -T y systemd-analyze security.
El número que resume el módulo es de ocho horas a cincuenta minutos. Y su consecuencia importa más que el número: la regla de 06-04 —«un servidor comprometido se reinstala, no se limpia»— deja de ser un consejo que nadie quiere seguir y pasa a ser el procedimiento evidente. La automatización no es solo eficiencia; es lo que hace cumplible una decisión de seguridad que antes era teórica. Como efecto secundario, el playbook se ha convertido en un tercer control de integridad, junto a AIDE y auditd: vigila el estado lógico de la configuración, que ninguno de los otros dos ve.
Pero fíjate en lo que sigue sin resolverse, y que las últimas tres respuestas a Marta han ido señalando cada vez con más claridad. Puedes reconstruir el servidor en cincuenta minutos. Puedes probar cualquier cambio antes de aplicarlo. Puedes detectar una intrusión, cifrar los secretos y medir el rendimiento. Y aun así, si srv-tramontana se cae, Tramontana Reservas está caída. Un disco, una fuente de alimentación, un corte de red, un kernel que no arranca tras una actualización: cincuenta minutos de interrupción en el mejor de los casos, y eso solo si alguien está despierto para lanzar el playbook. Todo el trabajo de siete módulos descansa sobre una única máquina.
En la lección 07-07: Alta Disponibilidad y Balanceo de Carga se ataca eso directamente. Aprenderás el vocabulario con precisión —disponibilidad y los «nueves» con los minutos de caída al año que representan de verdad, escalado vertical frente a horizontal, MTBF y MTTR y su relación con el RPO y el RTO que ya tienes acordados—, y verás por qué el estado es el problema difícil: replicar procesos es fácil, replicar datos y sesiones no. Montarás un balanceador con HAProxy delante de dos instancias, con comprobaciones de salud que reutilizan los códigos 0/1/2 de revision_salud.sh y drenaje de conexiones para desplegar sin cortar; darás alta disponibilidad al propio balanceador con keepalived y una IP virtual flotante, evitando el error de diseño más común del área; verás la replicación de PostgreSQL, por qué la conmutación automática es peligrosa sin quórum, y qué es el split-brain. Y terminarás con el análisis que Marta lleva tres lecciones pidiendo: cuánto cuesta de verdad esta arquitectura, cuánto cuesta una hora de caída, y si Tramontana la necesita — porque la respuesta profesional no siempre es «sí».
Curso de Linux: De Principiante a Administrador de Sistemas
Módulo 1: Introducción a Linux
- ¿Qué es Linux?
- Historia de Linux
- Distribuciones de Linux
- Instalando Linux
- Primer Contacto con el Sistema
- Estructura del Sistema de Archivos de Linux
Módulo 2: Comandos Básicos de Linux
- Introducción a la Línea de Comandos
- Obtener Ayuda y Documentación del Sistema
- Navegando el Sistema de Archivos
- Operaciones con Archivos y Directorios
- Visualización y Edición de Archivos
- Enlaces Duros y Simbólicos
- Permisos y Propiedad de Archivos
Módulo 3: Habilidades Avanzadas en la Línea de Comandos
- El Entorno del Shell: Variables, Alias e Historial
- Uso de Comodines y Expresiones Regulares
- Búsqueda de Archivos y Contenido: find, locate y grep
- Tuberías y Redirección
- Procesamiento de Texto: cut, sort, uniq, sed y awk
- Gestión de Procesos
- Programación de Tareas con Cron
- Comandos de Redes
Módulo 4: Scripting en Shell
- Introducción al Scripting en Shell
- Variables y Tipos de Datos
- Entrada, Salida y Argumentos de un Script
- Estructuras de Control
- Funciones y Librerías
- Depuración y Manejo de Errores
- Scripts de Producción: Buenas Prácticas
Módulo 5: Administración del Sistema
- Gestión de Usuarios y Grupos
- sudo y Permisos Especiales
- Gestión de Paquetes
- Gestión de Discos
- systemd y la Gestión de Servicios
- Registros del Sistema: journald y syslog
- Monitoreo del Sistema y Optimización del Rendimiento
- Respaldo y Restauración
Módulo 6: Redes y Seguridad
- Configuración de Redes
- SSH y Acceso Remoto
- Firewall y Seguridad Perimetral
- Sistemas de Detección de Intrusos
- Gestión de Secretos y Certificados TLS
- Asegurando Sistemas Linux
Módulo 7: Temas Avanzados
- El Proceso de Arranque y la Recuperación del Sistema
- Diagnóstico Avanzado: strace, perf y eBPF
- Optimización del Kernel de Linux
- Virtualización con Linux
- Contenedores de Linux y Docker
- Automatización con Ansible
- Alta Disponibilidad y Balanceo de Carga
