Ghost Units en NixOS: el bug silencioso que spamea tu journal
El síntoma
Después de actualizar NixOS, el journal se llena de errores cada 30 segundos:
systemd[1]: crowdsec.service: Service has no ExecStart=, ExecStop=, or SuccessAction=. Refusing.
Lo raro: CrowdSec estaba deshabilitado en esa
máquina (enable = false). Entonces… por qué existía el
servicio?
Bienvenido al bug de las ghost units.
Lo mínimo que necesitas saber de NixOS
NixOS construye la configuración del sistema mergeando (fusionando) todos los módulos. No hay "un fichero que manda" - todos contribuyen al mismo árbol.
Módulo A Módulo B Host config
crowdsec: crowdsec: crowdsec:
path=[sys] ExecStart=X enable=false
| | |
+-------+--------+---------------+
|
NixOS MERGE
(fusión)
|
Configuración final
Esto es potente. Pero tiene una trampa.
El merge que crea fantasmas
Imagina dos módulos:
Módulo upstream (el de nixpkgs): genera el servicio CrowdSec completo, pero solo cuando
enable = true. UsamkIfpara condicionar todo.Nuestro módulo (security.nix): añade overrides al servicio (PATH, DynamicUser). Sin condición.
enable = true enable = false
--------------- ------------------
Módulo upstream: ExecStart=/bin/crowdsec (nada - mkIf lo anula)
Nuestro módulo: path=[systemd] path=[systemd] <- SIEMPRE
DynamicUser=false DynamicUser=false <- SIEMPRE
--------------- ------------------
Resultado merge: Unit COMPLETO Unit SIN ExecStart
GHOST UNIT!
La clave: cualquier escritura a
systemd.services.X.* genera un fichero de unit. Aunque solo
pongas .path o .serviceConfig.DynamicUser,
NixOS crea el .service con las variables de entorno por
defecto. Pero como el módulo upstream NO contribuyó
ExecStart (porque enable = false), el
resultado es un servicio vacío.
Systemd lo ve, intenta arrancarlo, falla, y lo reintenta cada 30 segundos. Para siempre.
Cómo se ve un ghost unit?
$ systemctl cat crowdsec.service
[Unit]
[Service]
Environment="PATH=/nix/store/...-systemd/bin"
Environment="LOCALE_ARCHIVE=..."
DynamicUser=false
# <- NO HAY ExecStart
# <- NO HAY WantedBy
# <- NADA MASUn cascarón vacío. Pero suficiente para que systemd lo considere existente y lo intente ejecutar.
La solución: mkIf + mask
Dos ingredientes:
1. Envolver overrides en mkIf
mkIf es la herramienta de NixOS para decir "esto solo
existe si se cumple una condición":
{ config, lib, pkgs, ... }:
let
crowdsecEnabled = config.services.crowdsec.enable;
in
{
-- Esto se aplica siempre - define el servicio con mkDefault
services.crowdsec.enable = lib.mkDefault true;
-- Esto SOLO se aplica cuando CrowdSec está activo
systemd.services.crowdsec.path =
lib.mkIf crowdsecEnabled (lib.mkForce [ pkgs.systemd ]);
systemd.services.crowdsec.serviceConfig.DynamicUser =
lib.mkIf crowdsecEnabled (lib.mkForce false);
}Cuando crowdsecEnabled = false, esas líneas no
existen. No contribuyen nada al merge. No se genera ghost
unit.
2. Enmascarar por si el upstream genera fantasmas
Algunos módulos NixOS generan un unit parcial aunque estén deshabilitados (bug upstream). Para esos casos, la red de seguridad:
systemd.services.crowdsec.enable =
lib.mkIf (!crowdsecEnabled) false;systemd.services.X.enable = false crea un symlink a
/dev/null. El servicio queda masked y
systemd lo ignora completamente.
Diagrama completo de la solución
ANTES (roto):
security.nix
services.crowdsec.enable = true <- mkDefault, host puede overridear
systemd.services.crowdsec
.path = [systemd] <- SIEMPRE se aplica
.DynamicUser = false <- SIEMPRE se aplica
En máquina con enable=false:
-> Ghost unit (sin ExecStart)
-> Journal spam cada 30s
DESPUES (arreglado):
security.nix
services.crowdsec.enable = mkDefault true
mkIf crowdsecEnabled:
systemd.services.crowdsec
.path = [systemd] <- Solo si está activo
.DynamicUser = false <- Solo si está activo
mkIf (!crowdsecEnabled):
systemd.services.crowdsec
.enable = false <- Mask = /dev/null
En máquina con enable=false:
-> Servicio masked
-> Cero spam
El sistema de prioridades de NixOS
Para entender por qué funciona, hay que conocer las prioridades:
| Función | Prioridad | Gana contra |
|---|---|---|
lib.mkForce valor |
50 (máxima) | Todo |
valor (normal) |
100 | mkDefault |
lib.mkDefault valor |
1000 (mínima) | Nada |
Ejemplo práctico:
# En el módulo base (compartido):
services.crowdsec.enable = lib.mkDefault true; # prioridad 1000
# En el host (macbook):
services.crowdsec.enable = false; # prioridad 100 -> GANAEl false del host gana porque tiene prioridad más alta
(100 < 1000). No necesitas mkForce.
services.X.enable vs systemd.services.X.enable
Esto confunde a mucha gente:
services.X.enable = false le dice al módulo
NixOS que no genere config. Es el nivel declarativo (Nix).
systemd.services.X.enable = false le dice a
systemd que enmascare el unit file. Crea un symlink a
/dev/null. Es el nivel imperativo.
Son capas distintas. Necesitas el segundo como red de seguridad cuando el módulo upstream no limpia bien sus units.
Cómo detectar ghost units en tu sistema
# Buscar errores de units sin ExecStart
journalctl -b -p err | grep "has no ExecStart"
# Listar servicios con bad-setting
systemctl list-units --state=bad-setting
# Inspeccionar un sospechoso
systemctl cat nombre.service
# Si ves [Service] sin ExecStart -> ghost unitChecklist: cómo escribir módulos seguros
Cuando escribas un módulo NixOS compartido entre máquinas:
- Usa
lib.mkDefault trueen vez detruepara losenable - Envuelve todo override de
systemd.services.*enlib.mkIf - No solo servicios: también
environment.etc,system.activationScripts,systemd.tmpfiles - Añade máscara (
.enable = false) para el caso deshabilitado - Testea:
systemctl cat X.servicedespués del rebuild
Lo que arreglamos
En nuestro caso, encontramos el bug en 3 módulos compartidos:
| Módulo | Problema | Fix |
|---|---|---|
| security.nix | CrowdSec ghost units | mkIf + mask |
| sunshine.nix | Puertos abiertos en VPS público | mkIf sunshineEnabled |
| virtualization.nix | IOMMU/VFIO/ipforward en Raspberry Pi | mkIf libvirtEnabled |
El más grave: sunshine.nix abría 10 puertos de streaming en un VPS con IP pública, aunque Sunshine estaba deshabilitado. Los puertos no tenían nada escuchando, pero era superficie de ataque innecesaria.
Todo esto se detectó con una auditoría sistemática de
systemd.services.* sin mkIf en los módulos
compartidos.
Conclusión
El module system de NixOS es potente pero tiene esta trampa: el merge es aditivo. Cualquier módulo puede contribuir atributos a cualquier servicio, y esa contribución genera un unit file aunque el servicio esté deshabilitado.
La regla es simple: si tu módulo toca
systemd.services.X.* y otro módulo puede deshabilitar X,
envuélvelo en mkIf. Siempre.
Comentarios (0)
Sin comentarios todavia. Se el primero!
Deja un comentario