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 }