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-pruebas no es igual que producción, por muy cuidadoso que fuera el user-data de 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

  1. Estado deseado frente a instrucciones
  2. Idempotencia, y por qué lo cambia todo
  3. Por qué Ansible: sin agente, sobre SSH, en YAML
  4. Instalación, inventario y variables
  5. Módulos y tareas
  6. Playbooks: handlers, condiciones, bucles y bloques
  7. Plantillas Jinja2
  8. Roles: organizar para reutilizar
  9. Ansible Vault y los secretos
  10. Ejecución: --check, --diff, --tags y ansible-lint
  11. 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 operador

Ejecuta 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 operador

Funciona, 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: present

La 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=3

Ese changed=0 de la segunda ejecución es el objetivo, y tiene tres consecuencias que conviene enunciar:

  1. Se puede ejecutar sin miedo. No hay que preguntarse si ya se ejecutó. Si el servidor está como debe, no pasa nada.
  2. 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.
  3. 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_config sin 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.cfg

El 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=300s

host_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-generic

Ahí 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.db

Las 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 > 2

changed_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: reloaded

Los 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.target

Fí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, logrotate

Tres 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.

# roles/seguridad/meta/main.yml
dependencies:
  - role: comun

Y Ansible Galaxy para no reescribir lo ya escrito:

$ ansible-galaxy collection install community.general ansible.posix
$ ansible-galaxy role install geerlingguy.postgresql

Con 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:

$ ansible-vault create group_vars/all/secretos.yml
New Vault password:
Confirm New Vault password:
# 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 contrasena

Cifrar 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.sh
# ansible.cfg
[defaults]
vault_password_file = ~/.ansible-vault-pass.sh
$ ansible-playbook sitio.yml    # ya no pide la contrasena

Así 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:

# group_vars/all/main.yml
tramontana_db_password: "{{ vault_db_password }}"

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 tramontana

no_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 modulos

Y 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 puede

Los 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.118s

La 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 activo

Exposició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.402s

changed=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 command o shell donde hay un módulo. Pierde la idempotencia y la comprobación de estado. ansible-lint lo detecta.
  • Olvidar changed_when: false en las consultas. El playbook informa changed siempre y pierde su valor como detector de deriva.
  • Olvidar no_log: true en 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 validate en configuraciones críticas. Un sshd_config con un error de sintaxis te deja fuera. validate: /usr/sbin/sshd -t -f %s lo impide.
  • Habilitar ufw antes 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 existe srv-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 changed te lo dice. Trátalo como código: git, revisión de cambios, ansible-lint en 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 all

Las 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 tools

Falso 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_conf

Causa 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)
# --- Cambio 3: sysctl de seguridad ---
-kernel.yama.ptrace_scope = 0
+kernel.yama.ptrace_scope = 1

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.

# --- Cambio 4: faillock ---
-deny = 3
+deny = 5

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.conf

Hay 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 tramontana

El procedimiento, generalizado:

  1. --check --diff, guardando la salida con fecha.
  2. Para cada cambio, correlacionar con aide --check, ausearch, journalctl y /var/log/apt/history.log.
  3. Clasificar en A, B o C.
  4. 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.
  5. Reejecutar hasta changed=0.
  6. 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
fi

Fí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-tramontana existí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:

  1. 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.
  2. 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.
  3. 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

Módulo 2: Comandos Básicos de Linux

Módulo 3: Habilidades Avanzadas en la Línea de Comandos

Módulo 4: Scripting en Shell

Módulo 5: Administración del Sistema

Módulo 6: Redes y Seguridad

Módulo 7: Temas Avanzados

Módulo 8: Proyectos Prácticos

© Copyright 2026. Todos los derechos reservados