From 4ab66536b650e608039c0c3a205da897cb8bd2c5 Mon Sep 17 00:00:00 2001 From: Jason McCormick Date: Fri, 6 Nov 2026 19:10:44 -0500 Subject: new --- chrony.go | 175 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 chrony.go (limited to 'chrony.go') diff --git a/chrony.go b/chrony.go new file mode 100644 index 0000000..2c50a78 --- /dev/null +++ b/chrony.go @@ -0,0 +1,175 @@ +package main + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" + "time" +) + +// Tracking mirrors `chronyc -c tracking`. +type Tracking struct { + RefID string `json:"refId"` + RefName string `json:"refName"` + Stratum int `json:"stratum"` + RefTime float64 `json:"refTime"` + SystemOffset float64 `json:"systemOffset"` // seconds; positive = system clock slow of NTP time + LastOffset float64 `json:"lastOffset"` + RMSOffset float64 `json:"rmsOffset"` + FreqPPM float64 `json:"freqPpm"` // positive = clock runs slow + ResidFreqPPM float64 `json:"residFreqPpm"` + SkewPPM float64 `json:"skewPpm"` + RootDelay float64 `json:"rootDelay"` + RootDispersion float64 `json:"rootDispersion"` + UpdateInterval float64 `json:"updateInterval"` + Leap string `json:"leap"` +} + +// Source merges a `chronyc -c sources` row with its `sourcestats` row. +type Source struct { + Mode string `json:"mode"` // ^ server, = peer, # refclock + State string `json:"state"` // * + - x ~ ? + Name string `json:"name"` + Stratum int `json:"stratum"` + Poll int `json:"poll"` + Reach int `json:"reach"` + LastRx int64 `json:"lastRx"` // seconds ago, -1 if never + Offset float64 `json:"offset"` // adjusted offset of last sample + RawOffset float64 `json:"rawOffset"` + Error float64 `json:"error"` + + NP int `json:"np,omitempty"` + NR int `json:"nr,omitempty"` + Span int `json:"span,omitempty"` + Freq *float64 `json:"freqPpm,omitempty"` + Skew *float64 `json:"skewPpm,omitempty"` + StdDev *float64 `json:"stdDev,omitempty"` +} + +// ServerStats is the subset of `chronyc -c serverstats` we show. Requires +// access to chronyd's privileged command socket. +type ServerStats struct { + NTPReceived uint64 `json:"ntpReceived"` + NTPDropped uint64 `json:"ntpDropped"` + RatePerSec float64 `json:"ratePerSec"` +} + +type ChronySnap struct { + Tracking *Tracking `json:"tracking,omitempty"` + Sources []Source `json:"sources"` + Server *ServerStats `json:"server,omitempty"` + Error string `json:"error,omitempty"` +} + +type chronyClient struct { + path string + + serverStatsDenied bool + lastServerRx uint64 + lastServerAt time.Time +} + +func (c *chronyClient) run(ctx context.Context, cmd string) ([][]string, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, c.path, "-c", "-n", cmd).Output() + if err != nil { + msg := strings.TrimSpace(string(out)) + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + msg = strings.TrimSpace(string(ee.Stderr)) + } + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("chronyc %s: %s", cmd, msg) + } + var rows [][]string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line = strings.TrimSpace(line); line != "" { + rows = append(rows, strings.Split(line, ",")) + } + } + return rows, nil +} + +func f64(s string) float64 { v, _ := strconv.ParseFloat(s, 64); return v } +func atoi(s string) int { v, _ := strconv.Atoi(s); return v } + +func (c *chronyClient) Poll(ctx context.Context) ChronySnap { + snap := ChronySnap{Sources: []Source{}} + var errs []string + + if rows, err := c.run(ctx, "tracking"); err != nil { + errs = append(errs, err.Error()) + } else if len(rows) > 0 && len(rows[0]) >= 14 { + r := rows[0] + snap.Tracking = &Tracking{ + RefID: r[0], RefName: r[1], Stratum: atoi(r[2]), RefTime: f64(r[3]), + SystemOffset: f64(r[4]), LastOffset: f64(r[5]), RMSOffset: f64(r[6]), + FreqPPM: f64(r[7]), ResidFreqPPM: f64(r[8]), SkewPPM: f64(r[9]), + RootDelay: f64(r[10]), RootDispersion: f64(r[11]), + UpdateInterval: f64(r[12]), Leap: r[13], + } + } + + if rows, err := c.run(ctx, "sources"); err != nil { + errs = append(errs, err.Error()) + } else { + for _, r := range rows { + if len(r) < 10 { + continue + } + reach, _ := strconv.ParseInt(r[5], 8, 32) + lastRx, perr := strconv.ParseInt(r[6], 10, 64) + if perr != nil { + lastRx = -1 + } + snap.Sources = append(snap.Sources, Source{ + Mode: r[0], State: r[1], Name: r[2], Stratum: atoi(r[3]), Poll: atoi(r[4]), + Reach: int(reach), LastRx: lastRx, Offset: f64(r[7]), RawOffset: f64(r[8]), Error: f64(r[9]), + }) + } + } + + if rows, err := c.run(ctx, "sourcestats"); err != nil { + errs = append(errs, err.Error()) + } else { + byName := map[string]*Source{} + for i := range snap.Sources { + byName[snap.Sources[i].Name] = &snap.Sources[i] + } + for _, r := range rows { + if len(r) < 8 { + continue + } + if s := byName[r[0]]; s != nil { + fr, sk, sd := f64(r[4]), f64(r[5]), f64(r[7]) + s.NP, s.NR, s.Span = atoi(r[1]), atoi(r[2]), atoi(r[3]) + s.Freq, s.Skew, s.StdDev = &fr, &sk, &sd + } + } + } + + if !c.serverStatsDenied { + if rows, err := c.run(ctx, "serverstats"); err != nil { + if strings.Contains(err.Error(), "Not authorised") { + c.serverStatsDenied = true // don't keep spawning a doomed command + } + } else if len(rows) > 0 && len(rows[0]) >= 2 { + rx, _ := strconv.ParseUint(rows[0][0], 10, 64) + dr, _ := strconv.ParseUint(rows[0][1], 10, 64) + ss := &ServerStats{NTPReceived: rx, NTPDropped: dr} + now := time.Now() + if !c.lastServerAt.IsZero() && rx >= c.lastServerRx { + ss.RatePerSec = float64(rx-c.lastServerRx) / now.Sub(c.lastServerAt).Seconds() + } + c.lastServerRx, c.lastServerAt = rx, now + snap.Server = ss + } + } + + snap.Error = strings.Join(errs, "; ") + return snap +} -- cgit v1.2.3