systemd
The unit
Section titled “The unit”[Unit]Description=ghchronicle, GitHub metrics collectorAfter=network-online.targetWants=network-online.target
[Service]Type=simpleUser=ghchronicleGroup=ghchronicleEnvironmentFile=/etc/ghchronicle/ghchronicle.envExecStart=/usr/local/bin/ghchronicle -config /etc/ghchronicle/config.yamlRestart=alwaysRestartSec=30s
# This is the only process on the host holding a GitHub token, so it gets# nothing it does not need.NoNewPrivileges=truePrivateTmp=truePrivateDevices=trueProtectSystem=strictProtectHome=trueProtectKernelTunables=trueProtectKernelModules=trueProtectControlGroups=trueProtectClock=trueProtectHostname=trueProtectProc=invisibleRestrictNamespaces=trueRestrictRealtime=trueRestrictSUIDSGID=trueLockPersonality=trueMemoryDenyWriteExecute=trueSystemCallArchitectures=nativeSystemCallFilter=@system-serviceCapabilityBoundingSet=AmbientCapabilities=RestrictAddressFamilies=AF_INET AF_INET6
StateDirectory=ghchronicleReadWritePaths=/var/lib/ghchronicle
[Install]WantedBy=multi-user.targetWhy it is hardened
Section titled “Why it is hardened”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.
| Directive | What it removes |
|---|---|
CapabilityBoundingSet=, AmbientCapabilities= | Every Linux capability. It binds no privileged port and owns no device |
No | Any path to gaining privileges through exec, including setuid binaries |
Protect | Write access to the entire filesystem, except ReadWritePaths |
Protect | Every home directory, which is where the interesting credentials on a host usually live |
PrivateTmp=true, PrivateDevices=true | Shared temporary files, and the physical device nodes |
Protect | The ability to see other users’ processes in /proc, so it cannot read another process’s command line |
Restrict | Unix and netlink sockets. It talks HTTPS and nothing else |
MemoryDenyWriteExecute=true, LockPersonality=true | The usual shellcode primitives |
System | Every syscall outside the ordinary service set, including the module and kernel-tuning ones |
ProtectKernelTunables, ProtectKernelModules, ProtectControlGroups, ProtectClock, ProtectHostname, RestrictNamespaces, RestrictRealtime, RestrictSUIDSGID | Every 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.
Installing it
Section titled “Installing it”-
Create the user and the directories.
Terminal window sudo useradd --system --no-create-home --shell /usr/sbin/nologin ghchroniclesudo mkdir -p /etc/ghchronicle -
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
-
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.envEverything in
config.yamlreads these through${VAR}, which is what lets the config be world readable and version controlled while the secrets are not. -
Start it.
Terminal window sudo systemctl daemon-reloadsudo systemctl enable --now ghchroniclesudo systemctl status ghchronicle
What to watch
Section titled “What to watch”The log says what was written and where.
level=INFO msg=written sink=influxdb family=traffic points=629level=INFO msg="rate budget" bucket=core remaining=4354 limit=5000Two warnings are worth an alert:
rate limit reserve reachedmeans 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 runmeans every repository failed for one family, so it will be retried rather than treated as done.
journalctl -u ghchronicle -fjournalctl -u ghchronicle -p warning --since todaycron instead of a service
Section titled “cron instead of a service”-once runs a single sweep and exits, which is all a scheduler needs.
0 * * * * /usr/local/bin/ghchronicle -config /etc/ghchronicle/config.yaml -onceKeep 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.