Public telemetry#

Why#

Publishing a snapshot of the server’s state (chrony_stats.txt, served by nginx) lets anyone audit the service’s quality without SSH access, worthwhile transparency for a reference time service. But this publication must be designed around one absolute security constraint: never expose position data. A GNSS receiver also picks up latitude/longitude/altitude; publishing this information, even approximately, would reveal the server’s exact physical location.

How#

Atomic file generation#

cat > /opt/stats/stats.sh <<'EOF'
#!/bin/bash
set -e

OUT=/var/time/chrony_stats.txt
TMP=$(mktemp /var/time/.stats/chrony_stats.XXXXXX)
trap 'rm -f "$TMP"' EXIT

CHRONYC="/usr/bin/chronyc -h /run/chrony/chronyd.sock"

{
    echo "--- NTP ---"
    $CHRONYC -m tracking serverstats 'sources -v'

    echo "--- PTP ---"
    PHC_MEDIAN=$(/usr/local/sbin/phc-offset-median /dev/ptp0 5)
    [ -n "$PHC_MEDIAN" ] && echo "offset ${PHC_MEDIAN}ns (median)"

    echo "--- GNSS ---"
    timeout 10 gpspipe -w -n 8 2>/dev/null | /opt/stats/gnss_sky.sh
} > "$TMP"

chmod 0644 "$TMP"
mv "$TMP" "$OUT"
EOF
chmod +x /opt/stats/stats.sh

Writing goes through a temp file, renamed atomically (mktemp + mv): nginx never sees a half-written file, since rename() is an atomic filesystem operation.

The GNSS filter: never position#

cat > /opt/stats/gnss_sky.sh <<'EOF'
#!/bin/bash
# Isolates ONLY gpsd's "SKY" JSON report (reception health: satellites,
# DOP). Deliberately ignores the "TPV" report (position/velocity).
set -euo pipefail

SKY=$(jq -c 'select(.class == "SKY")' | head -n1)
[ -z "$SKY" ] && { echo "GNSS: SKY report unavailable"; exit 0; }

HAS_SAT_ARRAY=$(jq -r 'if .satellites then "1" else "0" end' <<<"$SKY")

if [ "$HAS_SAT_ARRAY" = "1" ]; then
    N_USED=$(jq -r '[.satellites[] | select(.used == true)] | length' <<<"$SKY")
else
    N_USED=$(jq -r '.uSat // empty' <<<"$SKY")
fi
HDOP=$(jq -r '.hdop // empty' <<<"$SKY")

echo "Satellites used: ${N_USED:-unavailable}"
[ -n "$HDOP" ] && echo "HDOP: $HDOP"
EOF
chmod +x /opt/stats/gnss_sky.sh

The script never reads the lat, lon, alt, track, speed fields, not even by accident, since it exclusively isolates the SKY report (reception health), not TPV (position/velocity).

Publishing via nginx#

location = /chrony_stats.txt {
    allow all;
    access_log off;
    add_header Cache-Control "public, max-age=300";
}

Regenerated every 5 minutes via a systemd timer (stats.timer + stats.service), rather than computed on demand, avoids a client request triggering an expensive real-time chrony read.

Pitfalls to avoid#

Per-satellite azimuth/elevation angles must never be published, even if they seem harmless. Combined with the public ephemeris of GNSS satellites (each satellite’s position is known at any given time) and a precise timestamp, these angles could in theory allow reconstructing an approximate position via inverse triangulation. This is a risk category of its own, “Location”, distinct from the usual performance or diagnostic risks: always refuse by default, regardless of the metric requested.

  • Assuming gpsd’s JSON report always has the same shape. Some receivers (particularly those using NMEA rather than a proprietary binary protocol) emit a minimal SKY report, without the detailed satellites[] array, only aggregated root-level fields (uSat, hdop). A script assuming the satellites[] array is always present will silently produce a misleading “0 satellites” count instead of the real number, a robust script must handle both formats.
  • Mis-sizing the number of lines read versus the timeout. Requesting too many lines (gpspipe -n 30) with too short a timeout can cause systematic truncation, when a single complete SKY line is enough. A small -n value that completes naturally, with a generous timeout as a pure safety net, beats the reverse.
  • Using printf to format numbers extracted from JSON. On a system with a non-English locale (fr_FR, for instance), printf interprets the decimal separator as a comma and rejects a JSON-formatted number (period), combined with set -e, this silently truncates the output. Always delegate numeric formatting to jq, which is guaranteed locale-independent.

Verify#

sudo systemctl start stats.service
cat /var/time/chrony_stats.txt

# Static check: no position leak in the code
grep -iEw 'lat|lon|alt|track|speed|az|el' /opt/stats/gnss_sky.sh
# Should return NO executable line (only, possibly, explanatory comments)

curl -A "Mozilla/5.0" https://time.example.org/chrony_stats.txt

nginx blocks curl/wget User-Agents by default (NGINX chapter), to manually test public access, always pass a browser User-Agent (-A "Mozilla/5.0"), otherwise the test fails for a reason unrelated to telemetry itself.