Skip to content

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

The threat model is short and it is the whole justification: this is very likely the only process on the host holding a GitHub token with read access to every repository of an account. A token is a bearer credential. Anything that can read this process’s memory or its environment file has the account.

So the unit gives the process exactly what it needs, which turns out to be almost nothing: an outbound TCP socket and one writable directory.

DirectiveWhat it removes
CapabilityBoundingSet=, AmbientCapabilities=Every Linux capability. It binds no privileged port and owns no device
NoNewPrivileges=trueAny path to gaining privileges through exec, including setuid binaries
ProtectSystem=strictWrite access to the entire filesystem, except ReadWritePaths
ProtectHome=trueEvery home directory, which is where the interesting credentials on a host usually live
PrivateTmp=true, PrivateDevices=trueShared temporary files, and the physical device nodes
ProtectProc=invisibleThe ability to see other users’ processes in /proc, so it cannot read another process’s command line
RestrictAddressFamilies=AF_INET AF_INET6Unix and netlink sockets. It talks HTTPS and nothing else
MemoryDenyWriteExecute=true, LockPersonality=trueThe usual shellcode primitives
SystemCallFilter=@system-serviceEvery syscall outside the ordinary service set, including the module and kernel-tuning ones
ProtectKernelTunables, ProtectKernelModules, ProtectControlGroups, ProtectClock, ProtectHostname, RestrictNamespaces, RestrictRealtime, RestrictSUIDSGIDEvery remaining route to changing the host from inside the service

StateDirectory=ghchronicle makes systemd create /var/lib/ghchronicle with the right ownership on start, so the state file has somewhere to live without a manual mkdir and a chown that someone will forget after a reinstall.

Two files live there, not one. Beside state.json the sweep keeps its write ledger, state-written.bin by default, which is what stops an unchanged point being written again; ReadWritePaths covers the directory, so both are already allowed. Put either somewhere else and that path needs adding here, and losing the ledger costs one sweep of rewriting: only what changed is written.

  1. Create the user and the directories.

    Terminal window
    sudo useradd --system --no-create-home --shell /usr/sbin/nologin ghchronicle
    sudo mkdir -p /etc/ghchronicle
  2. Put the configuration in place.

    • Directory/etc/ghchronicle/
      • config.yaml world readable, no secrets in it
      • ghchronicle.env mode 600, the tokens
    • Directory/var/lib/ghchronicle/
      • state.json created by the service
      • state-written.bin the write ledger, beside it
  3. Write the environment file, and nothing else in it.

    /etc/ghchronicle/ghchronicle.env
    GITHUB_TOKEN=github_pat_...
    INFLUX_TOKEN=...
    Terminal window
    sudo chmod 600 /etc/ghchronicle/ghchronicle.env

    Everything in config.yaml reads these through ${VAR}, which is what lets the config be world readable and version controlled while the secrets are not.

  4. Start it.

    Terminal window
    sudo systemctl daemon-reload
    sudo systemctl enable --now ghchronicle
    sudo systemctl status ghchronicle

The log says what was written and where.

level=INFO msg=written sink=influxdb family=traffic points=629
level=INFO msg="rate budget" bucket=core remaining=4354 limit=5000

Two warnings are worth an alert:

  • rate limit reserve reached means a family was skipped to protect the budget. Once is fine; every sweep means the cadences are too fast for the number of repositories.
  • family failed everywhere, not marking it as run means every repository failed for one family, so it will be retried rather than treated as done.
Terminal window
journalctl -u ghchronicle -f
journalctl -u ghchronicle -p warning --since today

-once runs a single sweep and exits, which is all a scheduler needs.

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

Keep the state file on a persistent path even in this mode. It is what stops the stargazer walk and the year-by-year contribution backfill happening again on every run. Note that an hourly cron gives every family an hourly cadence at best, so the fifteen-minute rhythm of actions is lost.