Tutoriales

Cómo escribir un complemento de Ansible para crear archivos de inventario

En artículos anteriores de esta serie, escribí sobre inventarios dinámicos de Ansible y cómo escribir un script de Python muy flexible para crearlos utilizando los resultados de Nmap.

Sin embargo, existen varias razones por las que es posible que desee utilizar los complementos de Ansible en lugar de las secuencias de comandos de Python para crear archivos de inventario:

  1. Desea estandarizar el lenguaje utilizado para escribir herramientas de aprovisionamiento.Es genial si su equipo sabe cómo escribir Perl, Ruby, Python, Go, Rust, etc., pero ¿puede asegurarse todos ¿Sus miembros son competentes en todos estos idiomas? Vale la pena apegarse a algunas herramientas y dominarlas.
  2. Esta puede ser una situación de «no te repitas» (SECA). Los complementos de Ansible le brindan muchas cosas de forma gratuita, como servicios de almacenamiento en caché, cifrado y administración de configuración.
  3. Se espera que los complementos de inventario de Ansible existan en ubicaciones específicas. Esto hace que sea predecible y más fácil de distribuir a otros servidores o compartir con otros equipos.

Aquí, presentaré una tercera forma de manejar el inventario dinámico: escriba un complemento de Ansible y aún concéntrese en Nmap como herramienta de descubrimiento. Continuaré fomentando las buenas prácticas para las herramientas de empaquetado, el uso de entornos virtuales y las pruebas unitarias de su código.

Escribir un módulo Ansible

La idea es aprovechar el ecosistema de Ansible para tareas comunes como la ejecución y el almacenamiento en caché, como se describe en la documentación de Ansible.

Usaré el analizador y el envoltorio Nmap que escribí en el artículo anterior, por lo que el archivo del módulo también incluirá estas clases.

[ Download A guide to implementing DevSecOps. ]

Agregar Ansible como una dependencia requirements.txt Facilitar el desarrollo. Esto proporciona cosas como autocompletar:

setuptools>=60.5.0
build>=0.7.0
packaging==21.3
wheel==0.37.1
pip-audit==2.0.0
ansible==5.4.0

A continuación, instale las dependencias (Ansible es Pesado bolsa, así que deberías ir a buscar un café):

# Also you can:
# pip install ansible==5.4.0
pip install -r requirements.txt

Crear un módulo

Para mantener las dependencias simples para este tutorial, incluyo OutputParser y NmapRunner juntos en un modulo nmap_plugin¿Dónde está la nueva clase de complemento? NmapInventoryModule. Se parece a esto:

"""
A simple inventory plugin that uses Nmap to get the list of hosts
Jose Vicente Nunez ([email protected])
"""

import os.path
from subprocess import CalledProcessError
import os
import shlex
import shutil
import subprocess
from typing import List, Dict, Any
from xml.etree import ElementTree
# The imports below are the ones required for an Ansible plugin
from ansible.errors import AnsibleParserError
from ansible.plugins.inventory import BaseInventoryPlugin, Cacheable, Constructable

DOCUMENTATION = r'''
    name: nmap_plugin
    plugin_type: inventory
    short_description: Returns a dynamic host inventory from Nmap scan
    description: Returns a dynamic host inventory from Nmap scan, filter machines that can be accessed with SSH
    options:
      plugin:
          description: Name of the plugin
          required: true
          choices: ['nmap_plugin']
      address:
        description: Address to scan, in Nmap supported format
        required: true
'''


class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable):

    NAME = 'nmap_plugin'

    def __init__(self):
        super(InventoryModule, self).__init__()
        self.address = None
        self.plugin = None

    def verify_file(self, path: str):
        if super(InventoryModule, self).verify_file(path):
            return path.endswith('yaml') or path.endswith('yml')
        return False

    def parse(self, inventory: Any, loader: Any, path: Any, cache: bool = True) -> Any:
        super(InventoryModule, self).parse(inventory, loader, path, cache)
        self._read_config_data(path)  # This also loads the cache
        try:
            self.plugin = self.get_option('plugin')
            self.address = self.get_option('address')
            hosts_data = list(NmapRunner(self.address))
            if not hosts_data:
                raise AnsibleParserError("Unable to get data for Nmap scan!")
            for host_data in hosts_data:
                for name, address in host_data.items():
                    self.inventory.add_host(name)
                    self.inventory.set_variable(name, 'ip', address)
        except KeyError as kerr:
            raise AnsibleParserError(f'Missing required option on the configuration file: {path}', kerr)
        except CalledProcessError as cpe:
            raise AnsibleParserError("There was an error while calling Nmap", cpe)


class OutputParser:
    def __init__(self, xml: str):
        self.xml = xml

    def get_addresses(self) -> List[Dict[str, str]]:
        """
        Several things need to happen for an address to be included:
        1. Host is up
        2. Port is TCP 22
        3. Port status is open
        4. Uses IPv4
        """
        addresses = []
        root = ElementTree.fromstring(self.xml)
        for host in root.findall('host'):
            name = None
            for hostnames in host.findall('hostnames'):
                for hostname in hostnames:
                    name = hostname.attrib['name']
                    break
            if not name:
                continue
            is_up = True
            for status in host.findall('status'):
                if status.attrib['state'] == 'down':
                    is_up = False
                    break
            if not is_up:
                continue
            port_22_open = False
            for ports in host.findall('ports'):
                for port in ports.findall('port'):
                    if port.attrib['portid'] == '22':
                        for state in port.findall('state'):
                            if state.attrib['state'] == "open":  # Up not the same as open, we want SSH access!
                                port_22_open = True
                                break
            if not port_22_open:
                continue
            address = None
            for address_data in host.findall('address'):
                address = address_data.attrib['addr']
                break
            addresses.append({name: address})
        return addresses


class NmapRunner:

    def __init__(self, hosts: str):
        self.nmap_report_file = None
        found_nmap = shutil.which('nmap', mode=os.F_OK | os.X_OK)
        if not found_nmap:
            raise ValueError("Nmap binary is missing!")
        self.nmap = found_nmap
        self.hosts = hosts

    def __iter__(self):
        command = [self.nmap]
        command.extend(__NMAP__FLAGS__)
        command.append(self.hosts)
        completed = subprocess.run(
            command,
            capture_output=True,
            shell=False,
            check=True
        )
        completed.check_returncode()
        out_par = OutputParser(completed.stdout.decode('utf-8'))
        self.addresses = out_par.get_addresses()
        return self

    def __next__(self):
        try:
            return self.addresses.pop()
        except IndexError:
            raise StopIteration


"""
Convert the args for proper usage on the Nmap CLI
Also, do not use the -n flag. We need to resolve IP addresses to hostname, even if we sacrifice a little bit of speed
"""
NMAP_DEFAULT_FLAGS = {
    '-p22': 'Port 22 scanning',
    '-T4': 'Aggressive timing template',
    '-PE': 'Enable this echo request behavior. Good for internal networks',
    '--disable-arp-ping': 'No ARP or ND Ping',
    '--max-hostgroup 50': 'Hostgroup (batch of hosts scanned concurrently) size',
    '--min-parallelism 50': 'Number of probes that may be outstanding for a host group',
    '--osscan-limit': 'Limit OS detection to promising targets',
    '--max-os-tries 1': 'Maximum number of OS detection tries against a target',
    '-oX -': 'Send XML output to STDOUT, avoid creating a temp file'
}
__NMAP__FLAGS__ = shlex.split(" ".join(NMAP_DEFAULT_FLAGS.keys()))

Notas sobre InventoryModule:

  • Si algunas de estas clases parecen familiares, es porque reutilicé el contenedor Nmap y el análisis XML que escribí para el script de inventario dinámico en el artículo anterior.

  • método verify_file No necesidad implementado, pero es una buena idea. Decide si el archivo de configuración es suficiente para usar.
  • este plugin necesidades de la clase parse método a implementar. Aquí es donde se llama a Nmap, se analiza la salida XML y se completa el inventario.
  • Utiliza herencia múltiple, por lo que obtienes algunas cosas gratis, como el análisis de configuración y el almacenamiento en caché.
  • Todas las excepciones de este módulo deben incluirse en un AnsibleParserError.

El archivo de configuración es del ejercicio anterior.

módulo de despliegue

A continuación, implemente el módulo donde Ansible pueda encontrarlo:

$ ansible-config dump|grep DEFAULT_INVENTORY_PLUGIN_PATH
DEFAULT_INVENTORY_PLUGIN_PATH(default) = ['/home/josevnz/.ansible/plugins/inventory', '/usr/share/ansible/plugins/inventory']
/bin/mkdir --parents --verbose /home/josevnz/.ansible/plugins/inventory/
/bin/cp -p -v Inventories/inventories/nmap_plugin.py /home/josevnz/.ansible/plugins/inventory/

Finalmente, defina un archivo de inventario que use el nuevo complemento nmap_plugin_inventory.yaml (test/nmap_plugin_inventory.yaml):

# Sample configuration file for custom nmap_plugin. Yes, it is the same file we used for tye dynamic inventory script
---
plugin: nmap_plugin
address: 192.168.1.0/24

tener una prueba

Hora de probar el nuevo módulo:

# Does Ansible recognize it?
$ ansible-doc -t inventory -l|grep nmap_plugin
nmap_plugin         Returns a dynamic host inventory from Nmap scan
# Smoke test, check if we get any host listed
(ExtendingAnsibleWithPythonInventory) [josevnz@dmaf5 Inventories]$ ansible-inventory --inventory $PWD/test/nmap_plugin_inventory.yaml  --list -v -v -v
[josevnz@dmaf5 ExtendingAnsibleWithPython]$ ansible-inventory --inventory Inventories/test/nmap_plugin_inventory.yaml --list
{
    "_meta": {
        "hostvars": {
            "dmaf5.home": {
                "ip": "192.168.1.25"
            },
            "macmini2": {
                "ip": "192.168.1.16"
            },
            "raspberrypi": {
                "ip": "192.168.1.11"
            }
        }
    },
    "all": {
        "children": [
            "ungrouped"
        ]
    },
    "ungrouped": {
        "hosts": [
            "dmaf5.home",
            "macmini2",
            "raspberrypi"
        ]
    }
}

Los resultados son los mismos que obtendría con el complemento Inventario dinámico. Sin embargo, si habilita otras funciones, como el almacenamiento en caché de resultados (que no se tratan aquí), verá beneficios como una mayor velocidad de generación de inventario. (Si tiene una gran cantidad de hosts, tal cosa puede ser enorme).

¿Que sigue?

En este tutorial, creó un complemento de inventario que aprovecha el entorno de Ansible para crear un escáner de red sin mucho código repetitivo. Es más restrictivo que los scripts de manifiesto dinámico, pero obtiene varios servicios de forma gratuita, como el almacenamiento en caché y el análisis de archivos de configuración.

¡Pero hay más que aprender! Ahora que conoce al menos tres formas de manejar el inventario dinámico, consulte lo siguiente:

Recuerda, puedes descargar el código y experimentar. La mejor manera de aprender es a través de la práctica y los errores.

LEER  Cómo enumerar los procesos en ejecución en Linux

Publicaciones relacionadas

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Botón volver arriba