The Cuckoo Escapement: field report, kernel patch, dashboard

What the Raspberry Pi time-server guides get wrong on a Pi 4, with the
measurements. The headline artifact is a four-line pps-gpio patch: PREEMPT_RT
force-threads IRQ handlers, and pps-gpio takes its timestamp inside its handler,
so the realtime kernel puts a scheduler between the electrical edge and the
clock. IRQF_NO_THREAD takes RMS offset from 2468 ns to 199 ns.

- kernel/     the patch
- dashboard/  live status page (position hidden by default)
- docs-site/  the write-up (Astro/Starlight, brass, no tutorial section)
This commit is contained in:
Ryan Malloy 2026-07-14 09:21:25 -06:00
commit 6881489bf6
56 changed files with 10297 additions and 0 deletions

View file

@ -0,0 +1,11 @@
# gps-ntp dashboard front door. `make caddy` renders {{DOMAIN}} from .env.
#
# The cert is issued OFF-BOX and synced to the Pi (see `make cert-sync`) rather
# than obtained by Caddy itself. A time server usually lives on a LAN with no
# inbound reachability, so HTTP-01 can't work; use DNS-01 wherever you already
# run ACME and copy the result here.
{{DOMAIN}} {
tls /etc/caddy/certs/{{DOMAIN}}/fullchain.pem /etc/caddy/certs/{{DOMAIN}}/privkey.pem
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}

View file

@ -0,0 +1,10 @@
# systemd drop-in: /etc/systemd/system/caddy.service.d/affinity.conf
#
# Keep Caddy off cpu0. The PPS interrupt is handled there and cannot be moved
# (Pi 4 GPIO IRQs are demuxed via pinctrl-bcm2835 and refuse an smp_affinity),
# so any work scheduled on cpu0 adds jitter to the PPS timestamp directly.
# Caddy is mostly idle, but on this box cpu0 belongs to the clock. See
# TIMING-NOTES.md.
[Service]
CPUAffinity=1
Nice=10

View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Idempotent install/update of the gps-ntp dashboard. Run on the Pi as root
# from the synced source tree (APPDIR). Safe to re-run.
set -euo pipefail
APPDIR="${APPDIR:-/opt/gpsntp-dashboard}"
SVC=gpsntp-dashboard
echo "==> dedicated system user"
id -u gpsntp &>/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin gpsntp
echo "==> python venv + editable install"
if [ ! -x "$APPDIR/.venv/bin/python" ]; then
if ! python3 -m venv "$APPDIR/.venv" 2>/dev/null; then
apt-get update -qq && apt-get install -y python3-venv python3-pip
python3 -m venv "$APPDIR/.venv"
fi
fi
"$APPDIR/.venv/bin/pip" install --quiet --upgrade pip
"$APPDIR/.venv/bin/pip" install --quiet -e "$APPDIR"
echo "==> narrow sudoers rule (clients command only)"
install -m 0440 "$APPDIR/deploy/gpsntp-dashboard.sudoers" /etc/sudoers.d/gpsntp-dashboard
visudo -cf /etc/sudoers.d/gpsntp-dashboard
echo "==> systemd unit"
install -m 0644 "$APPDIR/deploy/gpsntp-dashboard.service" "/etc/systemd/system/${SVC}.service"
echo "==> ownership"
chown -R gpsntp:gpsntp "$APPDIR"
echo "==> enable + (re)start"
systemctl daemon-reload
systemctl enable "${SVC}.service" >/dev/null 2>&1 || true
systemctl restart "${SVC}.service"
sleep 2
systemctl is-active "${SVC}.service" && echo "==> dashboard active on :8080"

View file

@ -0,0 +1,35 @@
[Unit]
Description=gps-ntp status dashboard
Documentation=https://git.supported.systems/time-pi
After=network-online.target gpsd.service chrony.service
Wants=network-online.target
[Service]
Type=exec
User=gpsntp
Group=gpsntp
ExecStart=/opt/gpsntp-dashboard/.venv/bin/gpsntp-dashboard
Environment=GPSNTP_PORT=8080
Environment=GPSNTP_HOST=0.0.0.0
Restart=on-failure
RestartSec=3
# Keep the dashboard OFF cpu0. The PPS interrupt is handled on cpu0 and cannot
# be moved (Pi 4 GPIO IRQs are demuxed through pinctrl-bcm2835 and refuse an
# smp_affinity), so any load there directly adds jitter to the timestamp.
# Measured: the dashboard cost ~36% more PPS jitter before this. It is a
# monitoring tool; it must never perturb the clock it is watching.
CPUAffinity=1
Nice=10
# Hardening. NoNewPrivileges is intentionally NOT set: the served-clients
# panel shells `sudo -n chronyc -c clients`, allowed by a narrow sudoers rule.
PrivateTmp=true
ProtectHome=true
ProtectControlGroups=true
ProtectKernelTunables=true
RestrictSUIDSGID=false
LockPersonality=true
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,4 @@
# Least-privilege: the dashboard user may run ONLY this one read-only command.
# `chronyc -c clients` is privileged (returns "501 Not authorised" otherwise),
# and this is the served-client list the dashboard displays. Nothing else.
gpsntp ALL=(root) NOPASSWD: /usr/bin/chronyc -c clients