Problema

En entornos domésticos o de pequeña oficina, la mayoría de los filtros DNS se basa en listas negras estáticas. Esa aproximación funciona para bloquear anuncios o malware, pero no permite definir políticas diferenciadas por dispositivo ni por tipo de contenido. Cuando se necesita, por ejemplo, que los teléfonos de los niños bloqueen todo contenido adulto y juegos de apuestas mientras que los equipos de trabajo solo restrinjan redes sociales, la gestión manual de varias listas se vuelve inmanejable. El reto es crear un servicio DNS que:

  1. Aplique reglas por categoría (p.ej. “adult”, “gambling”, “social”) en lugar de dominios aislados.
  2. Asocie cada cliente (IP/MAC) a un perfil con su propio conjunto de categorías y horarios.
  3. Permita recargar la base de datos sin interrumpir la resolución de consultas.

Causa

Los filtros tradicionales fallan por tres motivos recurrentes:

  • Listas monolíticas – Un único archivo de dominios bloqueados no distingue entre usuarios. Cada cambio requiere volver a generar la lista completa, lo que introduce errores y tiempo de inactividad.
  • Asignación estática basada en subredes – Separar dispositivos por VLAN o rango DHCP funciona mientras la topología es estática. En entornos dinámicos (Wi‑Fi, BYOD) los dispositivos cambian de IP con frecuencia y la política se vuelve desactualizada.
  • Bloqueo de actualización – La mayoría de los resolvers usan mutexes alrededor de la tabla de dominios. Cada recarga implica bloquear la ruta de consulta, lo que se traduce en latencia visible para el cliente.

Solución

Una arquitectura basada en Go y SQLite permite combinar rendimiento, portabilidad y atomicidad. Los componentes clave son:

  1. Resolver en Go – Utiliza la librería miekg/dns para atender consultas UDP/TCP. Cada petición se procesa en una goroutine ligera, sin bloquear el hilo principal.
  2. Base de datos de categorías – SQLite (driver modernc.org/sqlite) almacena la relación category → dominio. Cada dominio puede pertenecer a varias categorías, lo que evita duplicación de listas.
  3. Perfiles de cliente – Tabla client_profile enlaza una dirección IP (o MAC) con un conjunto de categorías y un horario opcional. La coincidencia se realiza al inicio de la resolución.
  4. Recarga sin bloqueo – La tabla de dominios se carga en una estructura de mapa en memoria. Cuando se actualiza la base, se construye una nueva instancia y se intercambia mediante atomic.Value. El hilo de consulta siempre lee la referencia actual; el swap es O(1) y no genera contención.
  5. Despliegue OVA – El binario y los archivos estáticos (React SPA) se empaquetan en una máquina virtual preconfigurada. Al iniciar, el contenedor expone los puertos DNS (53) y HTTP (8080) y escribe los archivos de configuración en un volumen persistente.

Implementación del swap atómico (Go)

package resolver

import (
	"sync/atomic"
	"github.com/miekg/dns"
)

type dnsStore struct {
	records map[string][]string // dominio → IPs permitidos
}

// global pointer accesible por todas las goroutines
var currentStore atomic.Value // almacena *dnsStore

func loadStoreFromDB() *dnsStore {
	// consulta SQLite y llena el mapa
	// (código omitido por brevedad)
	return &dnsStore{records: make(map[string][]string)}
}

// llamado por el proceso de recarga
func reloadStore() {
	newStore := loadStoreFromDB()
	currentStore.Store(newStore) // swap atómico
}

// manejador de consultas
func handleDNS(w dns.ResponseWriter, r *dns.Msg) {
	q := r.Question[0].Name
	store := currentStore.Load().(*dnsStore)
	// lógica de filtrado basada en store.records y perfiles
	// (código simplificado)
}

Este patrón garantiza que cualquier número de consultas concurrentes siga usando la versión anterior mientras se construye la nueva, y que el cambio sea instantáneo.

Cuándo aplicar esta solución

Utilízala cuando:

  • Necesites políticas DNS granulares por dispositivo o por horario.
  • La infraestructura no permite dependencias externas (sin cuentas en la nube).
  • La carga de consultas es moderada (hasta varios miles por segundo) y el hardware disponible es un servidor x86 o una Raspberry Pi con suficiente RAM para el mapa en memoria.

No es adecuada si:

  • Requieres inspección profunda de paquetes (p.ej. DPI) más allá del nivel DNS.
  • El entorno está distribuido en múltiples sitios y necesita sincronización de políticas en tiempo real; en ese caso un servicio centralizado con replicación sería más apropiado.

Código

# 1. Clonar el repositorio (asumiendo que el código está en GitHub)
git clone https://github.com/ejemplo/dns-filter.git
cd dns-filter

# 2. Compilar el binario (requiere Go 1.22+)
go build -o dns-filter ./cmd/dns-filter

# 3. Crear la base SQLite inicial
sqlite3 data.db < schema.sql

# 4. Generar la OVA (usando Packer)
packer build packer-template.json

# 5. Importar la OVA en Proxmox/ESXi
# (interfaz web, seleccionar "Importar OVA")

Verificación

  1. Resolución básica – Desde un cliente configurado para usar el DNS del servidor, ejecutar dig example.com @<IP_DNS>. La respuesta debe contener la IP esperada o un NXDOMAIN si la categoría está bloqueada.
  2. Aplicación de perfil – Asignar la IP del cliente a un perfil que bloquee la categoría “gambling”. Consultar dig gambling-site.com. El servidor debe devolver REFUSED o una respuesta vacía según la configuración.
  3. Swap sin interrupción – Modificar la tabla category (añadir un dominio a “adult”). Ejecutar el endpoint HTTP /reload. Inmediatamente después, lanzar 100 consultas concurrentes con dig. Todas deben responder sin aumento perceptible de latencia.
  4. Horario – Definir un horario que desactive “social” entre 22:00 y 06:00. Verificar que durante ese rango la consulta a facebook.com sea bloqueada y fuera de él sea permitida.

Notas adicionales

  • Persistencia de perfiles – Guardar la asociación IP ↔ perfil en una tabla separada permite cambiar la política sin tocar la base de dominios. Si los dispositivos usan DHCP, un script que actualice la tabla al asignar una nueva lease evita “policy drift”.
  • Cache DNS – El resolver incluye una caché LRU de 10 000 entradas. La TTL de los registros se respeta, pero los dominios bloqueados se marcan con TTL mínima para evitar que un cambio de categoría quede en caché demasiado tiempo.
  • Seguridad del OVA – Desactivar SSH por defecto y habilitar solo la API HTTP con token estático. El token se monta como variable de entorno al iniciar la VM, evitando que quede escrito en la imagen.
  • Escalado horizontal – Si la carga supera el límite de una sola instancia, colocar varias VMs detrás de un balanceador DNS (por ejemplo, kube-dns en Kubernetes) y compartir la misma base SQLite mediante NFS o un volumen CSI. Cada nodo seguirá usando el swap atómico local, manteniendo la latencia mínima.