
🌐 También en: English · Deutsch · Français
Después de mudar esta semana dos instancias de Nextcloud a servidores nuevos, me hice una pregunta sencilla: ¿atraen mis tres servidores (este blog y los dos Nextcloud) a distintos tipos de atacantes? La respuesta fue, en general, «no, es el mismo ruido de fondo en todas partes». Salvo por una línea que me tenía mosqueado: SSH. Corre en un puerto no estándar, solo acepta claves y fail2ban lo vigila. Y aun así, solo el blog registraba unos 16 intentos de conexión por hora al demonio SSH, desde todos los rincones del mundo.
La cuestión es que yo solo me conecto desde Alemania. Entonces, ¿por qué un servidor en un centro de datos alemán debería siquiera hablar SSH con el resto del planeta? El método de siempre: yo hago las preguntas y tomo las decisiones, y mi coadministrador de IA (Claude) rebusca en los logs, escribe el código y comprueba dos veces cada cifra antes de que nos fiemos de ella.
📊 Antes: ¿quién llama al puerto 10022?
En los últimos ocho días, 3.089 conexiones desde 510 direcciones IP distintas llegaron hasta el demonio SSH del blog (sin contar mis propias conexiones). Ninguna consiguió entrar, porque no hay contraseña que adivinar, pero cada una cuesta un handshake, una línea de log y una evaluación de fail2ban. De dónde venían:
| País | Porcentaje de intentos |
|---|---|
| China | 30 % |
| Estados Unidos | 16 % |
| Corea del Sur | 4 % |
| Alemania | 4 % |
| India, Reino Unido, Hong Kong, Vietnam, Sudáfrica | 3 % cada uno |
| Todo lo demás | ~31 % |
Los nombres de usuario que probaron son el diccionario de siempre: admin, ubuntu, user, test, ftpuser, deploy, git, steam. En los dos servidores Nextcloud el panorama era el mismo: el 98 % (blog) y el 99 % (Nextcloud) de todos los intentos SSH fallidos venían de fuera de Alemania. ¿Y los pocos alemanes? Casi exclusivamente servidores alquilados en los grandes proveedores de hosting alemanes: los escáneres también alquilan máquinas aquí.
🤔 «¡Pero los atacantes usan una VPN alemana y listo!»
Correcto, y quiero ser sincero con esto: un filtro por país no es una frontera de seguridad. Quien vaya a por mí en concreto alquila un VPS alemán por unos pocos euros y se lo salta sin despeinarse. La protección real sigue exactamente donde estaba: autenticación solo con clave, fail2ban y parches. Entonces, ¿para qué molestarse?
- Ruido. Un 98 % menos de basura en los logs significa que la entrada rara que de verdad importa por fin destaca.
- El próximo zero-day. Cuando aparece un agujero real en OpenSSH (acuérdate de regreSSHion, CVE-2024-6387, en 2024), la primera oleada es un escaneo masivo de todo Internet. Un servidor que no responde al 98 % de ese Internet simplemente se queda fuera de la mayor parte de esa oleada.
- Coste: prácticamente nulo, como verás más abajo.
🌍 ¿De dónde sale «Alemania»?
No hace falta ninguna base de datos GeoIP comercial. Los cinco registros regionales de Internet (RIR) publican a diario qué bloques IP han asignado a qué país. Para Alemania es el delegated-ripencc-extended-latest del RIPE NCC: unos 8.700 bloques IPv4 y 3.100 IPv6, aproximadamente 126 millones de direcciones IPv4. Fusionados en sets de intervalos de nftables, quedan 6.639 + 3.013 entradas.
¿Cuesta eso RAM o CPU? Medimos en lugar de adivinar:
| Qué | Medido |
|---|---|
| Memoria del kernel para ambos sets | ~1,5 MB |
| Carga de la lista completa | 0,06 s |
| Búsqueda por cada nueva conexión SSH | ~14 comparaciones (árbol de intervalos ordenado), nanosegundos |
| Efecto en el tráfico web | ninguno: solo se comprueban las conexiones nuevas al puerto 10022 |
Ojo: los datos de los RIR dicen a quién se asignó un bloque de direcciones, no dónde está físicamente el dispositivo. Para la pregunta «¿es esto un proveedor de acceso o un hosting alemán?», es justo la información adecuada.
🧱 La nueva función de Ansible: una variable
Los tres servidores se construyen a partir del mismo repositorio de Ansible, y el cortafuegos es el rol compartido common_firewall. El filtro por país ahora forma parte de él: desactivado por defecto y activable por host:
# inventory/host_vars/<host>/vars.yml
fw_ssh_geo_countries: [DE]
fw_ssh_geo_extra: [] # optional: CIDRs that are always allowedEn el conjunto de reglas de nftables generado, la regla de SSH simplemente gana una búsqueda en un set:
ip daddr 203.0.113.10 tcp dport 10022 ct state new ip saddr @ssh_geo4 accept
ip6 daddr 2001:db8::1 tcp dport 10022 ip6 saddr @ssh_geo6 acceptTodo lo que no coincide cae en la detección de escaneo de puertos ya existente y en el drop por defecto, exactamente como si el puerto estuviera cerrado. Un pequeño script, ssh-geo-update, descarga el fichero del RIPE, conserva los bloques de los países configurados, los fusiona y escribe /etc/nftables/ssh-geo.nft. Un timer de systemd lo actualiza cada domingo por la noche.
🪤 La parte interesante: cómo no quedarte fuera
Una lista de direcciones permitidas tiene un modo de fallo muy traicionero: si alguna vez se queda vacía, no entra nadie, yo incluido. La mayor parte del trabajo consistió en hacer eso imposible:
- Recargas del cortafuegos. Mi conjunto de reglas vuelve a crear toda su tabla en cada recarga. Lo aprendimos por las malas a principios de esta semana, cuando una recarga vació en silencio los sets de baneo de fail2ban. Aquí sería peor: sets vacíos significan SSH cerrado para todo el mundo. Por eso la lista se incluye en el fichero de reglas y se rellena en la misma transacción atómica. Nunca hay un instante sin ella.
- La primera ejecución. Ansible construye la lista antes de escribir el nuevo conjunto de reglas. Si la descarga falla, el playbook se detiene justo ahí, antes de que una sola regla pueda dejar fuera a nadie.
- Datos defectuosos. La actualización conserva la lista antigua si la descarga falla, si los datos del RIPE tienen más de 14 días o si la lista nueva encoge de golpe más de un 20 %. Las actualizaciones en caliente solo tocan los dos sets; los sets de fail2ban y el resto de reglas no se tocan.
- SELinux. El primer borrador guardaba la lista en
/var/lib. Pero en AlmaLinux la unidad de nftables carga las reglas en un dominio SELinux confinado que lee/etc, y un include ilegible haría fallar el conjunto de reglas entero al arrancar. Así que la lista vive en/etc/nftables. - Monitorización de integridad. AIDE vigila
/etcy envía un correo por cada cambio. Por eso el timer se ejecuta los domingos entre las 02:30 y las 03:00, después de la comprobación nocturna de AIDE y antes de la actualización de la línea base a las 04:00, para que la actualización semanal nunca dispare una falsa alarma. - La salida de emergencia. ¿Estás en el extranjero o detrás de una VPN de otro país? La consola web del proveedor sigue funcionando con la contraseña de root, y
fw_ssh_geo_extraadmite cualquier dirección que necesites.
Antes de activarlo, Claude generó la plantilla para los tres hosts y la comparó con las reglas en producción: sin la variable, la salida es idéntica byte a byte a la anterior, así que los dos servidores Nextcloud ni se enteraron del código nuevo. Las nuevas reglas del blog pasaron por nft -c (modo de comprobación) en el host real antes de ejecutar siquiera el playbook. Como el blog es el menos crítico de los tres, fue el primero.
📉 Después: silencio
Para contar lo que rechaza el filtro, añadí temporalmente una regla de log específica para los intentos SSH rechazados (el log normal de drops está limitado a dos líneas por minuto para todo). La primera ventana de medición:
| Antes (8 días) | Después (primeras ~2 horas) | |
|---|---|---|
| Conexiones que llegan al demonio SSH | 16,3 por hora | 0 |
| Rechazadas por el filtro por país | – | 41 intentos desde 9 IP (China, EE. UU., Hong Kong, Macao, México, Canadá, Reino Unido) |
| Mis propios inicios de sesión desde Alemania | funcionan | siguen funcionando |
Dos horas es una ventana corta, así que toma las cifras exactas con pinzas, pero cero frente a dieciséis por hora no es un error de redondeo. La regla de log temporal ya ha desaparecido; el filtro se queda.
👀 Extra: ¿quién lee de verdad este blog?
Ya que estábamos metidos en los logs, quise saber otra cosa: últimamente he publicado muchos artículos nuevos, así que ¿hay más gente que encuentra el blog a través de buscadores? Para eso el repo tiene un pequeño script, scripts/analytics/visitor-stats.py. Se ejecuta en el propio servidor, porque las IP de los visitantes son datos personales según el RGPD y nunca salen del host; solo muestra cifras agregadas y enmascaradas. Esta semana aprendió a contar también las llegadas desde buscadores.
Primero lo que baja los humos: de 18.084 peticiones en la ventana medida, la inmensa mayoría eran máquinas. El 81 % de todas las páginas vistas venía de bots que se identifican como tales (Amazonbot, PetalBot, SemrushBot, PerplexityBot, AhrefsBot, Googlebot, ClaudeBot, …), y buena parte del resto eran navegadores headless en centros de datos en la nube que cargan una página con todos sus recursos en un segundo y desaparecen. Lo que quedó tras el filtrado:
| Métrica | Valor |
|---|---|
| Visitantes reales | ~17 al día, ~120 a la semana |
| Países principales | Estados Unidos, China (probablemente aún algún scraper disfrazado), Italia, Reino Unido, Países Bajos, Suiza, Alemania, … |
| Llegaron desde un buscador | 13 de 33 visitantes (Google 7, DuckDuckGo 4, Bing 2) |
| Lo más leído | la portada, la guía de Samba en Debian 13, el artículo sobre el historial de temperaturas del iLO |
Una advertencia sincera: K3s solo guarda un par de días de logs de contenedores, así que esto es una instantánea de dos días, no una tendencia. Si el tráfico desde buscadores crece con los nuevos artículos es algo que solo podré responder cuando el servidor lleve un recuento diario y anónimo; es lo siguiente en la lista. Y sí: una IP no es una persona. Son órdenes de magnitud, no un censo.
📦 Llévatelo
Todo está en el repo público github.com/aptupgrademe/www_k3s (los secretos se quedan en local, claro): el rol del cortafuegos con la nueva función está en roles/common_firewall, y las estadísticas de visitantes en scripts/analytics. Comandos útiles una vez que esté funcionando:
# is this address allowed to reach SSH?
nft get element inet filter ssh_geo4 { 203.0.113.7 }
# when does the next refresh run, and what did the last one do?
systemctl list-timers ssh-geo-update.timer
journalctl -u ssh-geo-update.service🎯 Conclusión
Un puerto no estándar no esconde SSH de casi nadie, y la autenticación solo con clave hace que las llamadas a la puerta sean inofensivas, pero no silenciosas. Limitar SSH al único país desde el que trabajo de verdad eliminó prácticamente todo el ruido a cambio de 1,5 MB de RAM. Lo difícil no fue el filtro en sí, sino asegurarse de que una lista vacía, una recarga del cortafuegos, SELinux o una descarga defectuosa nunca puedan dejarme fuera. El blog va primero; si se porta bien durante un tiempo, los dos servidores Nextcloud le seguirán con una sola línea de YAML.





