summaryrefslogtreecommitdiff
path: root/chrony.go
diff options
context:
space:
mode:
Diffstat (limited to 'chrony.go')
-rw-r--r--chrony.go175
1 files changed, 175 insertions, 0 deletions
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
+}