Skip to main content

Sincronización de varios PiHole

Ahora que ya dispone de un PiHole configurado, quiza interesa tener un segundo o tercer PiHole, para ponerlos como DNS1, DNS2, DNS3... el problema es que PiHole utiliza una base de datos SQLite por lo tanto la base de datos está en local y no tienen soporte actualmente para MySQL. Además PiHole hace uso de ciertos ficheros para aplicar algunas configuraciones.

Si se realiza un cambio en uno de los ficheros de PiHole, se deberá ir manualmente y modificar los datos en tantos PiHole como se dispongan. Hay un programa creado por un developer llamado vmstan que se encarga de realizar dicha sincronización, aunque a mí personalmente no me convence puesto que necesita conectarse por ssh al servidor donde esta el pihole principal, aparte de instalar cierto software que quiza no nos interesa.

También porque si infectan una de las máquinas, podran hacer uso de esa conexión SSH para conectarse a la otra máquina y por lo tanto hacer un salto entre ellas.

Por ello os dejo aquí la solución que se me ha ocurrido, que no es mas que hacer uso de un GIT que será el que tenga la configuración "master" que los otros PiHole replicarán.

Disponeis del código en su última versión en el repositorio de GitHub

Disclaimer No se sincronizan los registros de consulta, para ello serán enviados a un SIEM y se explicará en otro POST. Solo se sincronizan
  • Configuración (Settings)
  • Registros DNS
  • Registros CNAME
  • Base de datos (se proporciona un script mas abajo)
    • Adlist
    • Allowlist
    • Blocklist
  • Grupos
  • Clientes

Creación de PiHole que borraremos

Puede crear un PiHole del cual una vez haya configurado como desee, obtendrá los siguientes ficheros que posteriormente copiará al repositorio GIT.

  • etc-pihole/custom.list
  • etc-pihole/setupVars.conf
  • etc-dnsmasq.d/05-pihole-custom-cname.conf

custom.list

Es un fichero en el que se guardan los registros DNS locales, en otras palabras es como el fichero hosts de un equipo. Se suele usar para crear los registros que queramos resolver localmente. Por ejemplo, si queremos que al estar dentro de la empresa un dominio se solvente con la IP local, lo añadiremos ahí.

10.15.50.6 docs.psc.local
10.15.50.5 puppet.psc.local
10.15.60.10 hv1.psc.local
10.15.60.11 hv2.psc.local
10.15.50.10 dns1.psc.local
10.15.50.11 dns2.psc.local

setupVars.conf

El fichero setupVars.conf es el fichero que tiene la configuración de PiHole, como los servidores upstream (servidores DNS a los que consultará en caso de no conocer el dominio).

INSTALL_WEB_INTERFACE=true
WEBPASSWORD=contiene la password cifrada de la web
PIHOLE_INTERFACE=eth0
QUERY_LOGGING=true
BLOCKING_ENABLED=true
DNSMASQ_LISTENING=single
DNS_FQDN_REQUIRED=false
DNS_BOGUS_PRIV=false
DNSSEC=true
REV_SERVER=false
PIHOLE_DNS_1=9.9.9.11
PIHOLE_DNS_2=149.112.112.11
PIHOLE_DNS_3=1.1.1.1
PIHOLE_DNS_4=1.0.0.1

05-pihole-custom-cname.conf

Contiene los registros del tipo CNAME, con un contenido como el siguiente

cname=webserver1.psc.local,docs.psc.local
cname=webserver2.psc.local,docs.psc.local

Borrado de PiHole temporal

Una vez haya copiado los ficheros indicados (custom.list, setupVars.conf, el cname...) al escritorio de su equipo o a un lugar seguro, ya puede borrar el contenedor de PiHole.

docker container stop pihole
docker rm -f pihole

Creación de repositorio GIT

Para hacerlo, comenzaremos creando un repositorio en Github privado

Active la casilla Readme para que incialice el repositorio.

Guarde los cambios. Si desea poder gestionar o modificar los ficheros desde un notepad, necesitará poder subir los cambios con un cliente Git. Puede usar SourceTree para ello https://www.sourcetreeapp.com/ (disponible para Mac / Windows).

Clone el repositorio a SourceTree introduciendo la URL del repositorio que se ha creado, a continuación dejo un vídeo de como realizarlo https://www.youtube.com/watch?v=5K4gong_lA0.

Acceda a la carpeta local del repositorio (la que se ha clonado en su equipo) y copie los ficheros indicados anteriormente.

  • custom.list
  • setupVars.conf

A continuación añada un nuevo fichero llamado .gitignore con el siguiente contenido.

# Extensions
*.db
*.leases
*.sha1
*.domains
*.bak

# Explicit files
docker-compose.yml
etc-dnsmasq.d/01-pihole.conf
etc-dnsmasq.d/06-rfc6761.conf
etc-pihole/adlists.list
etc-pihole/logrotate
etc-pihole/dns-servers.conf
etc-pihole/pihole-FTL.conf
etc-pihole/local.list
etc-pihole/versions

# Folders
migration_backup/
var-log-pihole/

Este fichero indica cuales son los ficheros que GIT ignorará (no subirá al repositorio y no descargará en los otros clientes). Quedando finalmente el repositorio GIT con los siguientes ficheros.

Ordenados con la siguiente estructura

Si no dispone del fichero LICENSE no se preocupe, no es necesario. Finalmente incluya los ficheros en el commit y realice commit en el repositorio GIT https://confluence.atlassian.com/sourcetreekb/commit-push-and-pull-a-repository-on-sourcetree-785616067.html.

Deploy de SSH Public Keys

Para poder desplegar los cambios en los equipos que serán los finales, necesitamos una clave ssh. Para generarla, desde la consola shell desde donde haya instalado el docker de Pihole, ejecute el siguiente comando (en caso de no disponer de una ya), si dispone de una , omita la parte de generación de la clave.

ssh-keygen -t RSA -b 4096

En la carpeta del usuario de la shell se habrá creado la clave ssh privada (id_rsa) y publica (id_rsa.pub). Copie el contenido con el comando

cat ~/.ssh/id_rsa.pub

que tendrá una estructura similar a la siguiente

ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC3bqW6xac5v9QhwI3ysnFq4oH7fwOM6KGxG31+B46Ne05efVpIEzMa8Gs3BMh15eO7KYStlDl3L9juWIaDajliyMl/LujJ77f19FQxjTfvFXtjaTFF7xsr4qMV6XS6RSQqjvnXW1o1iY8583k6GN1C7xKciW8yIDkKbkJ9av0XcOWCtjutBkjB7aLqFaJdQxZTiS2Z0xYVm3+iucN/1BgLzJTTJH8ruBDqVlHcAMmrdPjVE06CUiueLPdlsgfIwnh4+ilNwb2AXkwnrU9KwY5zoqMuVCj+TPaZG6XcdVW90mADHeVYn6MozcxYIR3kU/0I63iRfNkkxMCaM8wn3zXLevdqtQngt/ROWY8o25Yw/1iV1UpOKQs0pVkjySrLmdcTLtFV9YhoFCIzYMp9TvxLme0Qf2ZdeYZVAtnVYtH7fkwBbQgbyRCwmY/PFm1YDLcugR8bJ02586Ok0YGTLJQUHuG5eNj/3JG7nZHxqLVK0V5L63GhFOdx0txGQlgVflJNbozeW83pvldhgGy3BNQoLa7ywLM9s5eMPF8j5h3xFuwTiG2F2YZYlzqw/qZd9pq0ZihFIcQaxwy9EsHOxv2lx6JnJ8FQ/fKOgwakd/ZWoR3yDzLn+h2NB+GqLIrJh3TSd4dT1zVK42cUr7Fc5Hax2b/ze/mQhmot/Cphfkr3OQ== root@docker02

Ahora en el repositorio de Github (web) acceda a la opción Settings. En el menú lateral derecho pulse en el apartado Deploy keys.

Pulse en Add Deploy Key e introduzca un nombre y la clave ssh pública que ha copiado.

El checkbox Allow write access solo dejelo marcado para el equipo PiHole que usará como máster (si finalmente añade uno a su infraestructura). Para los Pihole de lectura (los de debajo de la imagen, que son los que recibirán peticiones) dejelo desmarcado (solo lectura) así en caso de que ese equipo fuera comprometido, no podrían subir actualizaciones al repositorio de GIT (cambiar las IP de los dominios para apuntar a páginas malignas).

Cuando haya añadido todas las ssh keys públicas de los equipos que tendrán el sistema PiHole instalado, proceda con la instalación de los PiHole.

Instalación de PiHoles

Si en el host existe un Pihole, haga backup de los ficheros si así lo desea ya que se instalará desde 0. Detenga el contenedor de PiHole (si existe) y borrelo.

docker stop pihole
docker rm -f pihole

Posicionese en la carpeta de docker y borre cualquier rastro de la configuración del anterior PiHole (si existia uno)

cd /docker/
rm -rf /docker/pihole

Obtenga la url de su repositorio de Github (web) tal como se indica en la siguiente imagen

y ejecute el comando reemplazando la URL donde aparece el tag REPOSITORIO_DE_SU_URL

git clone ssh://REPOSITORIO_DE_SU_URL pihole

Devuelva el fichero docker-compose al interior de la carpeta PiHole y muevase a dicha carpeta.

mv docker-compose.yml /docker/pihole
cd /docker/pihole

Inicialice de nuevo el contenedor de PiHole (si no lo teneis a mano es el que hay en el capitulo de instalación de PiHole en Docker)

docker-compose up -d

Compruebe que todo funciona correctamente y que contiene los datos del primer PiHole que configuró (el que finalmente ha borrado). Vease que tiene los registros DNS locales que ha creado (si ha creado), registros CNAME, la configuración de DNS seleccionados previamente en Settings.

En este caso, se seleccionaron los de QUAD9 y los de Cloudflare y se puede ver que en el PiHole nuevo ya han quedado marcados los mismos servidores DNS upstream.

Ahora para cada vez que se añada un registro, se modifique la configuración en el PiHole master o bien en el fichero directamente en el GIT, querra que se replique en los nodos de lectura. En estos nodos NO DEBE MODIFICAR NADA manualmente, ni via WEB ni via fichero!.

Active la sincronización añadiendo una tarea programada que ejecutará la descarga de las actualizaciones en caso de que se haya creado alguna.

Abra el editor de tareas programadas

crontab -e

Y añada la siguiente linea. Cada 15 minutos se comprobará si existen actualizaciones.

*/15 * * * * cd /docker/pihole && git pull && docker exec -it pihole /bin/bash pihole restartdns reload

Sincronización de Adlist, Allowlist & Blocklist

Para efectuar la sincronización de las Adlist (listado de bloqueos externos), Allow List (páginas que nosotros permitimos siempre), Blocklist (páginas que bloquearemos siempre) he creado un pequeño script que se encarga de extraer la información del master, generar un fichero con los datos para que posteriormente los PiHole secundarios puedan aplicar.

Script de sincronización

El script encargado de exportar dichos datos, también sirve para importarlos en los PiHole secundarios.

Debe estar a la altura del fichero README.md /docker/pihole/ y ejecutar con las siguientes instrucciones.

Para exportar datos en el master

python3 gravity_sync.py -a export

Generara un fichero llamado changes.json que deberá ser subido al repositorio git.

Para importar los datos en los equipos PiHole secundarios, se ejecuta:

python3 gravity_sync.py -a import

Con lo que el cron de los equipos secundarios quedaría de la siguiente manera.

*/15 * * * * cd /docker/pihole && git pull && docker exec -it pihole /bin/bash pihole restartdns reload && python3 gravity_sync.py -a import
Script
import os.path
import sqlite3
import argparse
import json


def configure_parser():
    # Configure parser
    parser = argparse.ArgumentParser(
        prog="PiHole Gravity Syncer",
        description="Export/Import database Adlist, Whitelist and Blocklist"
    )
    parser.add_argument('-d','--database', default="etc-pihole/gravity.db")
    parser.add_argument('-f','--file', default="gravity_changes.json")
    parser.add_argument('-a','--action', required=True, help="import/export", choices=["import","export"])
    args = parser.parse_args()
    return args

def cursor_to_dict(cursor):
    desc = cursor.description
    column_names = [col[0] for col in desc]
    data = [dict(zip(column_names, row))
            for row in cursor]
    return data



#####################################################################
## IMPORT FUNCTIONS
#####################################################################
def load_changes_file(file):
    f = open(file)
    return json.load(f)

def apply_adlist(cursor,adlist):
    # Extraemos las listas que si queremos guardar
    where = "','".join([ x["address"] for x in adlist ])

    # Borramos las existentes que no estan en dicha lista
    query = f"DELETE FROM gravity WHERE adlist_id NOT IN (SELECT id FROM adlist WHERE address IN ('{where}'))"
    cursor.execute(query)
    gravity_deletes = cursor.rowcount
    query = f"DELETE FROM adlist_by_group WHERE adlist_id NOT IN (SELECT id FROM adlist WHERE address IN ('{where}'))"
    cursor.execute(query)
    query = f"DELETE FROM adlist WHERE address NOT IN ('{where}')"
    cursor.execute(query)

    print(f"\tDeleted {str(cursor.rowcount)} adlist from table")
    if cursor.rowcount > 0:
        print(f"\t\tDeleted {str(gravity_deletes)} gravity blocks")

    # Creamos si no existen, updateamos si ya existen
    for item in adlist:
        query = "SELECT id FROM adlist WHERE address=?"
        cursor.execute(query, (item['address'],))
        data = cursor.fetchone()
        #print(f"Checking if adlist {item['address']} is already in database")
        if data is None:
            # Creamos el registro
            query = "INSERT INTO adlist (address,enabled,comment) VALUES (?,?,?)"
            cursor.execute(query,(item["address"],item["enabled"],item["comment"]))
            print(f"\tAdding {item['address']}")
        else:
            # Actualizamos el registro
            query = "UPDATE adlist SET enabled=?, comment=? WHERE address=?"
            cursor.execute(query, (item["enabled"],item["comment"],item["address"]))
            print(f"\tUpdating fields enabled & comment for {item['address']}")

def apply_domainlist(cursor,domainlist):
    # Extraemos las listas que si queremos guardar
    where = "','".join([ x["domain"] for x in domainlist ])
    # print(where)

    query = f"DELETE FROM domainlist_by_group WHERE domainlist_id NOT IN (SELECT id FROM domainlist WHERE domain IN ('{where}'))"
    cursor.execute(query)
    query = f"DELETE FROM domainlist WHERE domain NOT IN ('{where}')"
    cursor.execute(query)

    print(f"\tDeleted {cursor.rowcount} domainlist" )
    for item in domainlist:
        query = "SELECT id FROM domainlist WHERE domain=?"
        cursor.execute(query, (item['domain'],))
        data = cursor.fetchone()
        # print(f"Checking if domain {item['domain']} is already in database")
        if data is None:
            # Creamos el registro
            query = "INSERT INTO domainlist (domain,enabled,type,date_added,comment) VALUES (?,?,?,?,?)"
            cursor.execute(query,(item["domain"],item["enabled"],item["type"],item["date_added"],item["comment"]))
            print(f"\tAdding {item['domain']}")
        else:
            # Actualizamos el registro
            query = "UPDATE domainlist SET enabled=?, type=?, date_added=?, comment=? WHERE domain=?"
            cursor.execute(query,(item["enabled"],item["type"],item["date_added"],item["comment"],item["domain"]))
            print(f"\tUpdating  {item['domain']}")


def apply_changes(cursor, data):
    print(f"\n##############################\n# ADLIST\n##############################")
    apply_adlist(cursor,data["adlist"])
    print(f"\n##############################\n# ALLOW & BLOCK LIST\n##############################")
    apply_domainlist(cursor,data["domainlist"])

def main_import(arguments):
    data = load_changes_file(arguments.file)
    dbcon = sqlite3.connect(arguments.database)
    cursor = dbcon.cursor()
    apply_changes(cursor, data)
    cursor.close()
    dbcon.commit()
    dbcon.close()

#####################################################################
## EXPORT FUNCTIONS
#####################################################################
def get_adlist(dbconnection):
    cursor = dbconnection.cursor()
    cursor.execute("SELECT address, enabled, comment FROM adlist")
    data = cursor_to_dict(cursor)
    cursor.close()
    return data

def get_allowblocklist(dbconnection):
    # Get rows of domainlist table
    cursor = dbconnection.cursor()
    cursor.execute("SELECT domain, enabled, type, comment, date_added FROM domainlist")
    data = cursor_to_dict(cursor)
    cursor.close()
    return data


def main_export(arguments):
    dbcon = sqlite3.connect(args.database)
    adlist = get_adlist(dbcon)
    domainlist = get_allowblocklist(dbcon)

    data = {
        "adlist":adlist,
        "domainlist":domainlist,
    }
    with open(arguments.file,"w") as of:
        json.dump(data,of)

    # Cerramos conexion
    dbcon.close()
    print(f"Export adlist, whitelist & blocklist to {arguments.file} file")



#####################################################################
## MAIN
#####################################################################
if __name__ == '__main__':
    args = configure_parser()

    if not os.path.isfile(args.database):
        print(f'El fichero {args.database} no existe o no es un fichero')
        exit(-1)

    if args.action == "export":
        main_export(args)
    else:
        if not os.path.isfile(args.file):
            print(f"The file {args.file} does not exist (or not readable)")
            exit(-1)

        main_import(args)

changes.json

Fichero que contiene los items de la base de datos a sincronizar con los otros PiHole. Ejemplo de fichero changes.json generado por el PiHole Master.

{"adlist": [{"address": "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", "enabled": 1, "comment": "PiHole default list"}, {"address": "https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txt", "enabled": 1, "comment": "Antimalware"}, {"address": "https://s3.amazonaws.com/lists.disconnect.me/simple_malvertising.txt", "enabled": 1, "comment": "Malvertising"}, {"address": "https://v.firebog.net/hosts/RPiList-Malware.txt", "enabled": 1, "comment": "Malware"}, {"address": "https://v.firebog.net/hosts/RPiList-Phishing.txt", "enabled": 1, "comment": "Phising"}, {"address": "https://raw.githubusercontent.com/crazy-max/WindowsSpyBlocker/master/data/hosts/spy.txt", "enabled": 1, "comment": "Windows Telemetry"}], "domainlist": [{"domain": "a.root-servers.net", "enabled": 1, "type": 1, "comment": "Added from Query Log", "date_added": 1698517414, "date_modified": 1698517414}, {"domain": "www.google-analytics.com", "enabled": 1, "type": 0, "comment": "Added from Query Logs", "date_added": 1698517443, "date_modified": 1698517442}]}

Para visualizarlo de una forma mas cómoda puede usar la herramienta online Json Parser Online e introducir el código directamente en la area de texto.

Crear actualizaciones desde el Master

En el PiHole master (el que no se usa para contestar peticiones DNS), realice los cambios que quiera via web. A continuación acceda a la consola ssh de ese servidor y ejecute el comando siguiente

cd /docker/pihole/
python3 gravity_sync.py -a export
git add -A
git commit
git push

Si no ha salido ningun error, a los 15 minutos como muy tarde, los otros PiHole deberían tener los cambios disponibles.

Opinion personal

Creo que esta es una buena manera de sincronizar los diferentes PiHole que se puedan tener en el entorno, puesto que a diferencia del proyecto de vmstan:

  • No necesitas que los PiHole se puedan ver entre si (diferentes redes, diferentes casas, diferentes vlans)
  • Solo necesitas GIT y Python3 en la máquina (no instalas ningúna aplicación más)
  • En caso de que uno de los equipos fuera comprometido
    • No podría subir cambios a los otros PiHole, ya que solo dispone de acceso de lectura al GIT.
    • El atacante no podrá usar la SSH Key para llegar a la otra máquina de Pihole.
  • La sincronización tiene en cuenta si ya existen los registros, evitando borrar para luego insertar de nuevo (con el consiguiente estress de borrado innecesario de los dominios en la tabla gravity (dominios proporcionados por las listas al ser ForeignKey).

Eso si, es un poco mas laborioso por el hecho de subir los cambios a GIT desde el Master... nada que no se pueda automatizar con un cron.

Arg Long Argument Values Default Description
-d --database etc-pihole/gravity.db Path and name of gravity database
-f --file gravity_changes.json Path of file where write/read export/import changes
-a --action import
export
You need to specify Action you want to perform
-ug --upgrade-gravity yesno no Execute force upgrade IOC after adding adlist
-cn --container-name pihole Container name of pihole