Cómo blindé mi Raspberry Pi sin perder el acceso: lo que probé, lo que falló y lo que dejé pendiente

Tengo una Raspberry Pi 4 en casa. Corre Debian 13, tiene K3s y la alcanzo desde cualquier lugar con Tailscale. La armé para aprender, y como casi todo lo que armo para aprender, un día me hice una pregunta incómoda: ¿qué tan expuesta está realmente?

No soy un experto en seguridad de redes. Soy alguien que le dedica horas a entender cómo funcionan las cosas, que se equivoca, que lee la salida de un comando tres veces y que a veces descubre que lo que daba por cierto no lo era. Este fue mi fin de semana de pruebas, tal como pasó: lo que hice, lo que de verdad comprobé y lo que me quedó pendiente.

Me puse una sola regla: no escribir que algo está verificado si no lo verifiqué. Por eso marco cada actividad así:

EtiquetaQué significa
RealizadoEjecuté el comando o cambio.
ProbadoVi una salida que confirma el resultado.
PendienteLo propuse, pero no tengo evidencia de que se completara.
RestauradoLo apliqué como prueba y luego lo revertí.

Sobre los datos: cambié mis direcciones públicas, IP de Tailscale, MAC, usuarios y huellas por marcadores. Si documentas tu propio laboratorio, haz lo mismo antes de publicar nada.

MarcadorQué representa
<IP_LAN_RASPI>IP privada de la Raspberry en mi red local
<IP_GATEWAY_FIBERHOME>Puerta de enlace, DHCP y DNS del FiberHome
<IP_TAILSCALE_RASPI>IP Tailscale de la Raspberry
<IP_TAILSCALE_ADMIN>IP Tailscale de mi MacBook
<USUARIO_ADMIN>Mi cuenta administrativa local

Primero, entender qué tenía montado

Antes de tocar nada, dibujé mi red. Esto es lo que creía tener:

Internet
│
▼
Router principal [revisión NAT/DMZ/UPnP pendiente]
│
▼
FiberHome <IP_GATEWAY_FIBERHOME> [DHCP + DNS + gateway; UPnP detectado]
│ Ethernet
▼
Raspi-Lab <IP_LAN_RASPI>
├── Debian 13 ARM64
├── K3s, nodo único
├── nftables + servicio systemd propio
└── tailscale0 <IP_TAILSCALE_RASPI>
▲
│ red privada Tailscale + SSH con clave Ed25519
MacBook <IP_TAILSCALE_ADMIN>

Primera sorpresa: yo pensaba que el FiberHome era solo un repetidor. Las pruebas de DHCP demostraron que también le daba a mi Raspberry su dirección, su DNS y su ruta por defecto. Mi mapa mental estaba equivocado, y apenas había empezado.

Una buena noticia: entrar solo con mi llave

Empecé por lo que más me importaba: cómo entro a mi servidor. Me conecté desde el MacBook por Tailscale, con una clave Ed25519 (Probado):

ssh -i ~/.ssh/raspi_lab_ed25519 \
-o IdentitiesOnly=yes \
-o PasswordAuthentication=no \
<USUARIO_ADMIN>@<IP_TAILSCALE_RASPI>

La clave privada vive en mi MacBook y nunca la copio a la Raspberry. Luego le pregunté a sshd cómo estaba realmente configurado, en lugar de confiar en lo que recordaba haber puesto (Probado):

sudo sshd -T | grep -Ei \
'permitrootlogin|passwordauthentication|pubkeyauthentication|kbdinteractiveauthentication'
permitrootlogin no
pubkeyauthentication yes
passwordauthentication no
kbdinteractiveauthentication no

Root no puede entrar, no se aceptan contraseñas y solo funciona la llave pública. También confirmé que no estaba publicando nada con Tailscale Serve ni Funnel (Probado):

sudo tailscale serve status
sudo tailscale funnel status

Ambos respondieron No serve config. Ojo con lo que esto significa: Tailscale no publica nada, pero no prueba que el router no tenga redirecciones. Esa duda me acompañó hasta el final.

Mirar por dentro: qué escucha mi Raspberry

ip -br addr
ip route
ip -6 route
sudo ss -tulpen
sudo nft list ruleset | head -80
sudo iptables -S INPUT
sudo iptables -S FORWARD
sudo iptables -S DOCKER-USER
  • eth0 activo con <IP_LAN_RASPI>/24; wlan0 inactivo.
  • tailscale0 con su IP privada y una dirección IPv6 de Tailscale.
  • Ruta por defecto por eth0, a través del FiberHome.
  • UFW no estaba instalado.
  • La política INPUT era ACCEPT, con salto a reglas de Tailscale.
  • En una etapa anterior, Docker había publicado los puertos 1880, 1883, 9000 y 9443, y DOCKER-USER no tenía restricciones. Después dejé Portainer, Node-RED y MQTT detenidos para dedicar la Raspberry al laboratorio K3s (no los eliminé).

Aquí aprendí algo que me costó asimilar: que un puerto esté publicado en la máquina no significa que se pueda alcanzar desde Internet. Para saberlo hay que mirar también los routers.

Antes de endurecer nada: ¿K3s está sano?

sudo k3s kubectl get nodes
sudo k3s kubectl get pods -A
NAME STATUS ROLES VERSION
raspi-lab Ready control-plane v1.36.5+k3s1

El nodo estaba Ready y CoreDNS, Local Path Provisioner y Metrics Server en Running. Repetí esta comprobación después de cada cambio de firewall. Endurecer un servidor que ya no funciona no sirve de nada.

Mi primer firewall con nftables

Creé /etc/nftables.d/raspi-firewall.nft para bloquear desde Ethernet los servicios administrativos que no quiero que nadie de mi red local alcance:

table inet raspi_firewall {
chain input {
type filter hook input priority 10; policy accept;
# SSH: bloquear acceso desde LAN
iifname "eth0" tcp dport 22 counter drop
# Servicios administrativos
iifname "eth0" tcp dport { 111, 6443, 10250 } counter drop
# RPCBind y Flannel
iifname "eth0" udp dport { 111, 8472 } counter drop
}
}
PuertoProtocoloServicio
22TCPSSH
111TCP/UDPRPCBind
6443TCPAPI de Kubernetes
10250TCPKubelet
8472UDPFlannel VXLAN

Ojo con un detalle: uso policy accept. Bloqueo lo que enumeré, pero no los servicios que aparezcan en el futuro. Más adelante intenté algo más estricto.

Para que sobreviva a los reinicios lo convertí en un servicio de systemd, /etc/systemd/system/raspi-firewall.service:

[Unit]
Description=Raspi-Lab Custom Firewall
Documentation=man:nft(8)
After=network-pre.target
Before=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStartPre=-/usr/sbin/nft delete table inet raspi_firewall
ExecStart=/usr/sbin/nft -f /etc/nftables.d/raspi-firewall.nft
ExecStop=/usr/sbin/nft delete table inet raspi_firewall
[Install]
WantedBy=multi-user.target

Y lo validé antes de activarlo:

sudo nft -c -f /etc/nftables.d/raspi-firewall.nft
sudo systemd-analyze verify /etc/systemd/system/raspi-firewall.service
sudo systemctl daemon-reload
sudo systemctl enable raspi-firewall.service
systemctl is-enabled raspi-firewall.service
sudo nft list table inet raspi_firewall
sudo k3s kubectl get nodes

Sintaxis válida, unidad sin errores, servicio enabled y K3s Ready. Desde Ethernet quedaron bloqueados SSH, RPCBind, la API de Kubernetes, Kubelet y Flannel (Probado).

El miedo de todo el que toca un firewall: quedarse afuera

Con el firewall básico funcionando, quise probar una política más estricta. Y ahí apareció el riesgo de siempre: una regla mal puesta y me quedo sin acceso a mi propio servidor. Así que, antes de probar nada, preparé una salida de emergencia. Primero un respaldo:

sudo cp -a /etc/nftables.d/raspi-firewall.nft /etc/nftables.d/raspi-firewall.nft.bak
sudo ls -l /etc/nftables.d/raspi-firewall.nft /etc/nftables.d/raspi-firewall.nft.bak

Después un script que restaura el original, en /usr/local/sbin/raspi-firewall-rollback.sh:

#!/bin/sh
set -eu
cp /etc/nftables.d/raspi-firewall.nft.bak \
/etc/nftables.d/raspi-firewall.nft
systemctl restart raspi-firewall.service
logger -t raspi-firewall "Rollback ejecutado: firewall restaurado"

Ese archivo se ejecuta con permisos de administrador, así que le puse chmod 700 y revisé su sintaxis con sh -n antes de confiar en él; el resultado fue 0. Y por último, un temporizador que lo dispara solo, para que si me quedo sin acceso el firewall se restaure sin que yo haga nada. Lo hice con dos ventanas, de 10 y de 15 minutos:

sudo systemd-run \
--unit=raspi-firewall-rollback \
--on-active=10min \
--collect \
/usr/local/sbin/raspi-firewall-rollback.sh
sudo systemd-run \
--unit=raspi-firewall-rollback-final \
--on-active=15min \
/usr/local/sbin/raspi-firewall-rollback.sh

Ambos aparecieron como active (waiting). Pero hay una limitación que no quiero esconder: son unidades transitorias en /run, así que no sobreviven a un reinicio. Mi red de seguridad tenía un hueco, y lo tuve presente todo el tiempo.

Me atreví a probar una política más estricta

Guardé una política candidata en /etc/nftables.d/raspi-firewall-candidato.nft. La idea: además de bloquear lo anterior, permitir solo respuestas de conexiones existentes, DHCP y el puerto UDP habitual de Tailscale, y denegar todo lo demás que llegue por Ethernet:

table inet raspi_firewall {
chain input {
type filter hook input priority 10; policy accept;
# Bloquear servicios administrativos desde la LAN
iifname "eth0" tcp dport { 22, 111, 6443, 10250 } counter drop
iifname "eth0" udp dport { 111, 8472 } counter drop
# Permitir respuestas de conexiones existentes
iifname "eth0" ct state established,related accept
# Permitir respuestas DHCP
iifname "eth0" udp sport 67 udp dport 68 accept
# Permitir puerto UDP habitual de Tailscale
iifname "eth0" udp dport 41641 accept
# Denegar el resto del tráfico entrante por Ethernet
iifname "eth0" counter drop
}
}

La validé (sudo nft -c -f devolvió 0). Antes de aplicarla comprobé que el temporizador estuviera activo y que el respaldo fuera idéntico al original:

systemctl is-active raspi-firewall-rollback.timer
sudo cmp /etc/nftables.d/raspi-firewall.nft /etc/nftables.d/raspi-firewall.nft.bak
echo "Resultado comparación: $?"

Y la apliqué:

sudo cp /etc/nftables.d/raspi-firewall-candidato.nft /etc/nftables.d/raspi-firewall.nft
sudo systemctl restart raspi-firewall.service
sudo nft list table inet raspi_firewall

Los contadores de la regla final empezaron a subir (en una revisión posterior vi 2768 paquetes). Es tentador leer eso como ataques, pero solo demuestra que la regla coincide con tráfico, nada más.

Con la política activa, lo primero fue probar que no me había quedado afuera (Probado):

  1. Abrí una nueva sesión SSH por Tailscale desde otra terminal: entró bien.
  2. K3s: nodo Ready y pods del sistema Running.
  3. DNS: getent ahostsv4 google.com devolvió direcciones IPv4.
  4. El firewall figuraba active y el temporizador seguía esperando.

Todo bien. Y sin embargo, no la dejé activa. Esto es lo que no probé:

  • Un reinicio con la política candidata.
  • Una renovación completa de DHCP.
  • La incorporación de otros nodos K3s por la LAN.
  • Todos los modos de conexión de Tailscale (directo o por DERP). Permitir solo UDP 41641 no garantiza conexión directa siempre.

Que algo funcione en la ventana de prueba no significa que funcione después de un reinicio. Y mi temporizador no habría sobrevivido a uno. Preferí no apostar.

La red de seguridad funcionó

sudo journalctl -u raspi-firewall-rollback.service --no-pager -n 20
sudo nft list table inet raspi_firewall
sudo cmp /etc/nftables.d/raspi-firewall.nft /etc/nftables.d/raspi-firewall.nft.bak
echo "Resultado: $?"
sudo k3s kubectl get nodes

El rollback se ejecutó solo, el firewall original volvió a cargarse, cmp devolvió 0 y K3s seguía Ready. Para la segunda unidad:

systemctl show raspi-firewall-rollback-final.service \
-p ActiveState -p Result -p ExecMainStatus
Result=success
ExecMainStatus=0
ActiveState=inactive

La política restrictiva quedó guardada y validada, pero no activa. Mi servidor terminó el fin de semana con el firewall básico, no con el más estricto, y estoy tranquilo con esa decisión.

Lo que me enseñó el router (y un error de principiante)

ip route show default
ip neigh show <IP_GATEWAY_FIBERHOME>
nmcli -f GENERAL.CONNECTION device show eth0
nmcli -f DHCP4 device show eth0

Confirmé que el FiberHome era mi servidor DHCP, mi DNS y mi router, con máscara /24 y concesión de 24 horas. Por el camino, dos consultas de nmcli me respondieron campo no válido. Por un momento pensé que algo estaba roto, pero no: mi versión de NetworkManager simplemente no admitía esas consultas. La que sí funcionó fue nmcli -f DHCP4 device show eth0. Un error de salida no siempre es un error de red.

Luego revisé cómo se conecta Tailscale:

tailscale netcheck
UDP: true
IPv4: yes, <IP_PUBLICA>:<PUERTO_EFIMERO>
IPv6: no, but OS has support
MappingVariesByDestIP: false
PortMapping: UPnP
CaptivePortal: false
Nearest DERP: Miami

UPnP estaba disponible en el FiberHome y Tailscale podía aprovecharlo. Eso no prueba que existan redirecciones hacia mi Raspberry, y no desactivé UPnP en esta revisión.

Lo que me quedó pendiente

Aquí es donde un fin de semana de pruebas termina siendo honesto: me faltó revisar los routers. Y sin eso, no puedo afirmar que mi servidor no esté expuesto.

PendienteEstado
Port Forwarding / Virtual Server en el FiberHomeNo revisado
Asignaciones UPnP activasNo revisado
DMZ del FiberHomeNo revisado
Identificar y revisar el router principalNo hecho
Confirmar o descartar doble NATNo concluido
Prueba desde una red realmente externaNo hecha
Renovación DHCP y reinicio con la política candidataNo probados
Ausencia de exposición desde InternetNo demostrada

Cuando revise los routers, voy a buscar redirecciones de estos puertos: TCP 22, TCP 6443, TCP 10250, TCP/UDP 111 y UDP 8472. El UDP 41641 de Tailscale normalmente no necesita regla manual. Mis próximos pasos:

  1. Entrar al FiberHome y anotar, sin modificar nada al inicio, Port Forwarding, UPnP y DMZ.
  2. Identificar el router principal y revisarlo igual.
  3. Probar desde una red externa. Escuchar localmente no es lo mismo que estar expuesto a Internet.
  4. Diseñar una recuperación persistente (o tener acceso físico a mano) antes de dejar activa la política restrictiva.
  5. Probar la renovación de DHCP y el reinicio de forma controlada.
  6. Decidir si mi laboratorio necesita descubrimiento LAN u otros nodos K3s antes de denegar todo en eth0.
  7. Si desactivo UPnP, repetir tailscale netcheck para ver si Tailscale sigue directo o pasa por DERP.

Todo el fin de semana en una tabla

ActividadRealizadoProbadoEstado al cierre
SSH con clave Ed25519 por TailscaleSíSíOperativo
Root por SSH deshabilitadoSíSíVigente
Contraseña y teclado interactivo por SSH deshabilitadosSíSíVigente
Serve/Funnel sin configuraciónConsultaSíSin publicación
K3s nodo únicoSíSíReady
Firewall nftables originalSíSíActivo
Servicio systemd del firewallSíSíHabilitado
Respaldo .bakSíSíDisponible
Script de rollbackSíSíEjecutó correctamente
Temporizador de rollbackSíSíConsumido; no persistente
Política candidata restrictivaSíSíRestaurada; archivo conservado
SSH, DNS y K3s con candidato activoSíSíExitosos
Renovación DHCP con candidatoNo concluyenteNoPendiente
Reinicio con candidatoNoNoPendiente (requiere recuperación local)
Asignaciones UPnP, Port Forwarding y DMZNoNoPendiente
Router principalNoNoPendiente
Ausencia de exposición desde InternetNoNoNo demostrada

Lo que me llevo (y lo que no es este texto)

  1. Respaldo y rollback antes de tocar el firewall. Y recordar que un temporizador transitorio no sobrevive a un reinicio.
  2. Probar con una segunda sesión abierta, no solo con la que uso para administrar.
  3. Verificar el servicio después de cada cambio, en mi caso K3s y DNS.
  4. Distinguir entre validado y probado en todos los escenarios. Por eso no dejé activa la política restrictiva.
  5. La seguridad depende de toda la red, incluidos los routers. Un firewall impecable no demuestra que no haya exposición.
  6. Documentar con honestidad: lo hecho, lo probado y lo pendiente.

Mi conclusión de este fin de semana: mi Raspberry quedó administrable por SSH con clave sobre Tailscale, con K3s saludable y con un firewall persistente que bloquea desde Ethernet los servicios administrativos críticos que identifiqué. No le atribuyo seguridad absoluta, y la evaluación externa no puede cerrarse hasta revisar el FiberHome y el router principal.

Este texto no es una receta ni una guía definitiva. Es un documento de apoyo para quienes quieran armar y revisar su propio home lab en casa, y sirve más como punto de partida que como instrucciones. Cada caso es distinto: tu router no será el mío, tu red tendrá otras reglas, tus servicios serán otros y tus errores también. Lo que sí te puedo asegurar es que aprender de esta forma, probando, equivocándose y documentando lo que realmente comprobaste, es lo que más me ha servido. Tómalo, adáptalo y, sobre todo, verifica por tu cuenta cada paso antes de aplicarlo en tu equipo.

— Jaider


Descubre más desde Jaiderreyes

Suscríbete y recibe las últimas entradas en tu correo electrónico.

Comentarios

Deja una respuesta

Descubre más desde Jaiderreyes

Suscríbete ahora para seguir leyendo y obtener acceso al archivo completo.

Seguir leyendo

Descubre más desde Jaiderreyes

Suscríbete ahora para seguir leyendo y obtener acceso al archivo completo.

Seguir leyendo