Ir al contenido

systemd

/etc/systemd/system/ghchronicle.service
[Unit]
Description=ghchronicle, GitHub metrics collector
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ghchronicle
Group=ghchronicle
EnvironmentFile=/etc/ghchronicle/ghchronicle.env
ExecStart=/usr/local/bin/ghchronicle -config /etc/ghchronicle/config.yaml
Restart=always
RestartSec=30s
# This is the only process on the host holding a GitHub token, so it gets
# nothing it does not need.
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_INET AF_INET6
StateDirectory=ghchronicle
ReadWritePaths=/var/lib/ghchronicle
[Install]
WantedBy=multi-user.target

El modelo de amenaza es corto y es toda la justificación: este es muy probablemente el único proceso de la máquina que guarda un token de GitHub con acceso de lectura a todos los repositorios de una cuenta. Un token es una credencial al portador. Cualquier cosa que pueda leer la memoria de este proceso o su fichero de entorno tiene la cuenta.

Así que la unidad le da al proceso exactamente lo que necesita, que resulta ser casi nada: un socket TCP saliente y un directorio escribible.

DirectivaQué quita
CapabilityBoundingSet=, AmbientCapabilities=Todas las capacidades de Linux. No abre puertos privilegiados ni posee dispositivos
NoNewPrivileges=trueCualquier vía para ganar privilegios mediante exec, incluidos los binarios setuid
ProtectSystem=strictEl acceso de escritura a todo el sistema de ficheros, salvo ReadWritePaths
ProtectHome=trueTodos los directorios personales, que es donde suelen vivir las credenciales interesantes de una máquina
PrivateTmp=true, PrivateDevices=trueLos ficheros temporales compartidos y los nodos de dispositivo físicos
ProtectProc=invisibleLa capacidad de ver los procesos de otros usuarios en /proc, así que no puede leer la línea de órdenes de otro proceso
RestrictAddressFamilies=AF_INET AF_INET6Los sockets Unix y netlink. Habla HTTPS y nada más
MemoryDenyWriteExecute=true, LockPersonality=trueLas primitivas habituales de shellcode
SystemCallFilter=@system-serviceToda llamada al sistema fuera del conjunto ordinario de servicio, incluidas las de módulos y ajuste del núcleo
ProtectKernelTunables, ProtectKernelModules, ProtectControlGroups, ProtectClock, ProtectHostname, RestrictNamespaces, RestrictRealtime, RestrictSUIDSGIDTodas las vías que quedan para cambiar la máquina desde dentro del servicio

StateDirectory=ghchronicle hace que systemd cree /var/lib/ghchronicle con el dueño correcto al arrancar, así que el fichero de estado tiene dónde vivir sin un mkdir manual y un chown que alguien olvidará tras una reinstalación.

Ahí viven cuatro ficheros, no uno. Junto a state.json la pasada guarda su registro de escrituras, state-written.bin por omisión, que es lo que evita volver a escribir un punto que no ha cambiado, y su caché, state-cache.bin, que es lo que permite a un reinicio preguntar a GitHub solo por lo que cambió; y el servicio mantiene state-lock mientras corre, que es como -migrate -yes sabe que no debe cambiar los almacenes por debajo de él. Un relleno, o la relectura de una migración, que se detiene a medias deja también ahí su punto de control. ReadWritePaths cubre el directorio, así que todos están ya permitidos. Si pones el fichero de estado o el registro en otro sitio, esa ruta hay que añadirla aquí, y la caché sigue al fichero de estado vaya donde vaya. Perder el registro cuesta una pasada de reescritura: solo se escribe lo que ha cambiado; perder la caché, una pasada a precio completo: la caché que hay a su lado.

  1. Crea el usuario y los directorios.

    Ventana de terminal
    sudo useradd --system --no-create-home --shell /usr/sbin/nologin ghchronicle
    sudo mkdir -p /etc/ghchronicle
  2. Pon la configuración en su sitio.

    • Directorio/etc/ghchronicle/
      • config.yaml legible por todos, sin secretos dentro
      • ghchronicle.env modo 600, los tokens
    • Directorio/var/lib/ghchronicle/
      • state.json lo crea el servicio
      • state-written.bin el registro de escrituras, al lado
      • state-cache.bin la caché, también al lado
      • state-lock lo mantiene el servicio mientras corre
  3. Escribe el fichero de entorno, y nada más en él.

    /etc/ghchronicle/ghchronicle.env
    GITHUB_TOKEN=github_pat_...
    INFLUX_TOKEN=...
    Ventana de terminal
    sudo chmod 600 /etc/ghchronicle/ghchronicle.env

    Todo en config.yaml los lee mediante ${VAR}, que es lo que permite que la configuración sea legible por todos y esté versionada mientras los secretos no lo están.

  4. Arráncalo.

    Ventana de terminal
    sudo systemctl daemon-reload
    sudo systemctl enable --now ghchronicle
    sudo systemctl status ghchronicle

    La primera pasada corre todas las familias, porque aún no ha corrido ninguna, salvo que el servicio arranca como mucho una familia de seis horas o más por pasada: traffic, stats, forks y el resto de las once llegan al almacén a lo largo de las dos primeras horas y media, y solo esta vez. Ver las familias lentas se turnan.

El log dice qué se escribió y dónde.

level=INFO msg=written sink=influxdb family=repo points=934 unchanged=1955
level=INFO msg="rate budget" bucket=core remaining=4477 limit=5000

Qué dice el log un día bueno tiene todas las líneas de rutina.

Tres avisos merecen una alerta:

  • rate limit reserve reached significa que se saltó una familia para proteger el presupuesto. Una vez está bien; en cada pasada significa que las cadencias son demasiado rápidas para el número de repositorios.
  • family failed everywhere, not marking it as run significa que todos los repositorios fallaron en una familia, así que se reintentará en vez de darla por hecha.
  • migration pending, en cada arranque tras una actualización, significa que un almacén aún guarda filas con una forma que esta versión ya no escribe, y que el arranque las dejó: mira tras una actualización.
Ventana de terminal
journalctl -u ghchronicle -f
journalctl -u ghchronicle -p warning --since today

Sustituye el binario y reinicia el servicio. Con el valor por omisión, migrate: auto, el arranque comprueba cada almacén frente a los cambios que trae la nueva versión y, antes de su primera pasada, aplica por su cuenta cada uno que no pierde nada, y lo dice en WARN. Un cambio que deja es un WARN en cada arranque, con el almacén, el motivo y las dos órdenes: lo que hace un arranque.

Ejecútalas con el propio usuario del servicio y con su fichero de entorno, con el servicio parado, porque -migrate -yes se niega a correr a su lado:

Ventana de terminal
sudo systemctl stop ghchronicle
sudo systemd-run --uid=ghchronicle --gid=ghchronicle --pipe --wait --collect \
--property=EnvironmentFile=/etc/ghchronicle/ghchronicle.env \
/usr/local/bin/ghchronicle -config /etc/ghchronicle/config.yaml -migrate
sudo systemd-run --uid=ghchronicle --gid=ghchronicle --pipe --wait --collect \
--property=EnvironmentFile=/etc/ghchronicle/ghchronicle.env \
/usr/local/bin/ghchronicle -config /etc/ghchronicle/config.yaml -migrate -yes
sudo systemctl start ghchronicle

La primera imprime el plan y no cambia nada; la segunda lo aplica y vuelve a leer lo que despejó. systemd-run lee el fichero de entorno como root, igual que la unidad, así que puede seguir en modo 600, y ejecuta el binario como ghchronicle: medido en systemd 257, una orden lanzada así vio el token de un fichero que solo root podía leer y corrió como el usuario indicado. Si la lanza root, -migrate -yes guarda como de root, en modo 600, los ficheros que reescribe junto al de estado, state.json entre ellos, y el servicio, que corre como ghchronicle, se detiene entonces al arrancar y nombra el fichero en vez de empezar con uno nuevo, que olvidaría una relectura aún debida: devuélveselo con chown ghchronicle:ghchronicle.

Con -once lanzado desde cron en vez de un servicio, comenta la línea mientras duran las dos órdenes: un -once no tiene ningún bloqueo mientras hace su pasada, así que nada le impide correr a la vez que -migrate -yes.

-once ejecuta una sola pasada y termina, que es todo lo que necesita un planificador.

0 * * * * /usr/local/bin/ghchronicle -config /etc/ghchronicle/config.yaml -once

Deja el fichero de estado en una ruta persistente también en este modo. Sin él cada ejecución recoge todas las familias, diga lo que diga su cadencia, y vuelve a recorrer la lista de estrellas, el historial entero de estrellas y las pull requests en coautoría; el fichero de caché que hay a su lado es lo que deja a una ejecución preguntar a GitHub solo por lo que cambió. Ten en cuenta que un cron horario da a cada familia una cadencia horaria como mucho, así que las cinco que corren cada quince minutos, actions, events, notifs, activity y ratelimit, corren cuatro veces menos: el tiempo en cola de una ejecución de workflow se lee cuando ya ha terminado, y un hilo de notificaciones que se movió dos veces dentro de la hora muestra solo su segundo movimiento.