
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í:
| Etiqueta | Qué significa |
|---|---|
| Realizado | Ejecuté el comando o cambio. |
| Probado | Vi una salida que confirma el resultado. |
| Pendiente | Lo propuse, pero no tengo evidencia de que se completara. |
| Restaurado | Lo 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.
| Marcador | Qué 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 nopubkeyauthentication yespasswordauthentication nokbdinteractiveauthentication 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 statussudo 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 addrip routeip -6 routesudo ss -tulpensudo nft list ruleset | head -80sudo iptables -S INPUTsudo iptables -S FORWARDsudo iptables -S DOCKER-USER
eth0activo con<IP_LAN_RASPI>/24;wlan0inactivo.tailscale0con 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-USERno 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 nodessudo k3s kubectl get pods -ANAME STATUS ROLES VERSIONraspi-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 }}
| Puerto | Protocolo | Servicio |
|---|---|---|
| 22 | TCP | SSH |
| 111 | TCP/UDP | RPCBind |
| 6443 | TCP | API de Kubernetes |
| 10250 | TCP | Kubelet |
| 8472 | UDP | Flannel 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 FirewallDocumentation=man:nft(8)After=network-pre.targetBefore=network.target[Service]Type=oneshotRemainAfterExit=yesExecStartPre=-/usr/sbin/nft delete table inet raspi_firewallExecStart=/usr/sbin/nft -f /etc/nftables.d/raspi-firewall.nftExecStop=/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.nftsudo systemd-analyze verify /etc/systemd/system/raspi-firewall.servicesudo systemctl daemon-reloadsudo systemctl enable raspi-firewall.servicesystemctl is-enabled raspi-firewall.servicesudo nft list table inet raspi_firewallsudo 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.baksudo 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/shset -eucp /etc/nftables.d/raspi-firewall.nft.bak \ /etc/nftables.d/raspi-firewall.nftsystemctl restart raspi-firewall.servicelogger -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.shsudo 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.timersudo cmp /etc/nftables.d/raspi-firewall.nft /etc/nftables.d/raspi-firewall.nft.bakecho "Resultado comparación: $?"
Y la apliqué:
sudo cp /etc/nftables.d/raspi-firewall-candidato.nft /etc/nftables.d/raspi-firewall.nftsudo systemctl restart raspi-firewall.servicesudo 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):
- Abrí una nueva sesión SSH por Tailscale desde otra terminal: entró bien.
- K3s: nodo
Readyy pods del sistemaRunning. - DNS:
getent ahostsv4 google.comdevolvió direcciones IPv4. - El firewall figuraba
activey 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 20sudo nft list table inet raspi_firewallsudo cmp /etc/nftables.d/raspi-firewall.nft /etc/nftables.d/raspi-firewall.nft.bakecho "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 ExecMainStatusResult=successExecMainStatus=0ActiveState=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 defaultip neigh show <IP_GATEWAY_FIBERHOME>nmcli -f GENERAL.CONNECTION device show eth0nmcli -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 netcheckUDP: trueIPv4: yes, <IP_PUBLICA>:<PUERTO_EFIMERO>IPv6: no, but OS has supportMappingVariesByDestIP: falsePortMapping: UPnPCaptivePortal: falseNearest 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.
| Pendiente | Estado |
|---|---|
| Port Forwarding / Virtual Server en el FiberHome | No revisado |
| Asignaciones UPnP activas | No revisado |
| DMZ del FiberHome | No revisado |
| Identificar y revisar el router principal | No hecho |
| Confirmar o descartar doble NAT | No concluido |
| Prueba desde una red realmente externa | No hecha |
| Renovación DHCP y reinicio con la política candidata | No probados |
| Ausencia de exposición desde Internet | No 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:
- Entrar al FiberHome y anotar, sin modificar nada al inicio, Port Forwarding, UPnP y DMZ.
- Identificar el router principal y revisarlo igual.
- Probar desde una red externa. Escuchar localmente no es lo mismo que estar expuesto a Internet.
- Diseñar una recuperación persistente (o tener acceso físico a mano) antes de dejar activa la política restrictiva.
- Probar la renovación de DHCP y el reinicio de forma controlada.
- Decidir si mi laboratorio necesita descubrimiento LAN u otros nodos K3s antes de denegar todo en
eth0. - Si desactivo UPnP, repetir
tailscale netcheckpara ver si Tailscale sigue directo o pasa por DERP.
Todo el fin de semana en una tabla
| Actividad | Realizado | Probado | Estado al cierre |
|---|---|---|---|
| SSH con clave Ed25519 por Tailscale | Sí | Sí | Operativo |
| Root por SSH deshabilitado | Sí | Sí | Vigente |
| Contraseña y teclado interactivo por SSH deshabilitados | Sí | Sí | Vigente |
| Serve/Funnel sin configuración | Consulta | Sí | Sin publicación |
| K3s nodo único | Sí | Sí | Ready |
| Firewall nftables original | Sí | Sí | Activo |
| Servicio systemd del firewall | Sí | Sí | Habilitado |
| Respaldo .bak | Sí | Sí | Disponible |
| Script de rollback | Sí | Sí | Ejecutó correctamente |
| Temporizador de rollback | Sí | Sí | Consumido; no persistente |
| Política candidata restrictiva | Sí | Sí | Restaurada; archivo conservado |
| SSH, DNS y K3s con candidato activo | Sí | Sí | Exitosos |
| Renovación DHCP con candidato | No concluyente | No | Pendiente |
| Reinicio con candidato | No | No | Pendiente (requiere recuperación local) |
| Asignaciones UPnP, Port Forwarding y DMZ | No | No | Pendiente |
| Router principal | No | No | Pendiente |
| Ausencia de exposición desde Internet | No | No | No demostrada |
Lo que me llevo (y lo que no es este texto)
- Respaldo y rollback antes de tocar el firewall. Y recordar que un temporizador transitorio no sobrevive a un reinicio.
- Probar con una segunda sesión abierta, no solo con la que uso para administrar.
- Verificar el servicio después de cada cambio, en mi caso K3s y DNS.
- Distinguir entre validado y probado en todos los escenarios. Por eso no dejé activa la política restrictiva.
- La seguridad depende de toda la red, incluidos los routers. Un firewall impecable no demuestra que no haya exposición.
- 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
Deja una respuesta