Ghost Units en NixOS: el bug silencioso que spamea tu journal


4 de abril de 2026

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:

                  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 MAS

Un 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 -> GANA

El 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 unit

Checklist: cómo escribir módulos seguros

Cuando escribas un módulo NixOS compartido entre máquinas:

  1. Usa lib.mkDefault true en vez de true para los enable
  2. Envuelve todo override de systemd.services.* en lib.mkIf
  3. No solo servicios: también environment.etc, system.activationScripts, systemd.tmpfiles
  4. Añade máscara (.enable = false) para el caso deshabilitado
  5. Testea: systemctl cat X.service despué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.

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario