Three machines ran timesyncd and one ran ntpd. The module declares timesyncd running with a 50-mesh.conf drop-in (European pool) and ntp absent (hq ADR 0180). A run-once step of its Go binary stops and disables ntpd first and takes out only dangling wants-links, so removing the package leaves no enabled unit pointing at nothing. A provider's drop-in sorting after the mesh's still wins and is reported, not removed. Tools: time_sync_status, _servers, _sync_now (to-be 42 Phase 1).
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package main
|
|
|
|
// systemd-timesyncd as the machine's one time daemon (novox/hq to-be 42 Phase 1, research 027/01:
|
|
// "two daemons across four machines"). Three machines ran timesyncd; one ran ntpd with timesyncd
|
|
// disabled. The module declares timesyncd running with its servers in a drop-in, and ntp absent
|
|
// (ADR 0180). Removing a package leaves the links that enabled its units behind, so before it goes
|
|
// the module's step stops and disables ntpd (`retire`, below) — and on a machine where it is gone
|
|
// already, takes out a link left pointing at nothing.
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The unit and the drop-in the manifest declares.
|
|
const (
|
|
Daemon = "systemd-timesyncd.service"
|
|
MeshDropIn = "/etc/systemd/timesyncd.conf.d/50-mesh.conf"
|
|
)
|
|
|
|
// OtherDaemons are the time daemons that are not timesyncd, reported wherever they are found.
|
|
var OtherDaemons = []string{"ntpd.service", "chronyd.service", "openntpd.service"}
|
|
|
|
// Unit is a unit's state as the service manager reports it.
|
|
type Unit struct {
|
|
Unit string `json:"unit"`
|
|
Load string `json:"load"`
|
|
Active string `json:"active"`
|
|
Boot string `json:"boot"`
|
|
}
|
|
|
|
func (m *Machine) unit(name string) (Unit, error) {
|
|
p, err := m.unitProps(name, "LoadState", "ActiveState", "UnitFileState")
|
|
if err != nil {
|
|
return Unit{}, err
|
|
}
|
|
return Unit{Unit: name, Load: p["LoadState"], Active: p["ActiveState"], Boot: p["UnitFileState"]}, nil
|
|
}
|
|
|
|
// Status is whether the clock is synchronised, and from where.
|
|
type Status struct {
|
|
Synchronized bool `json:"synchronized"`
|
|
NTPEnabled bool `json:"ntp_enabled"`
|
|
Timesyncd Unit `json:"timesyncd"`
|
|
Server string `json:"server,omitempty"`
|
|
OffsetMS *float64 `json:"offset_ms,omitempty"`
|
|
DelayMS *float64 `json:"delay_ms,omitempty"`
|
|
JitterMS *float64 `json:"jitter_ms,omitempty"`
|
|
Stratum int `json:"stratum,omitempty"`
|
|
PacketCount int `json:"packet_count,omitempty"`
|
|
Raw map[string]string `json:"timesync_status,omitempty"`
|
|
Others []Unit `json:"other_daemons"`
|
|
Error string `json:"timesync_error,omitempty"`
|
|
}
|
|
|
|
// ParseTimesyncStatus reads `timedatectl timesync-status`: aligned `Label: value` lines.
|
|
func ParseTimesyncStatus(out string) map[string]string {
|
|
kv := map[string]string{}
|
|
for _, l := range strings.Split(out, "\n") {
|
|
k, v, ok := strings.Cut(l, ": ")
|
|
if ok {
|
|
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return kv
|
|
}
|
|
|
|
var duration = regexp.MustCompile(`^([+-]?[0-9.]+)(ns|us|µs|ms|s|min)$`)
|
|
|
|
// Millis is one of timedatectl's durations ("-1.949ms", "+27us", "1.2s") in milliseconds.
|
|
func Millis(s string) *float64 {
|
|
m := duration.FindStringSubmatch(strings.TrimSpace(s))
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
v, err := strconv.ParseFloat(m[1], 64)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
switch m[2] {
|
|
case "ns":
|
|
v /= 1e6
|
|
case "us", "µs":
|
|
v /= 1e3
|
|
case "s":
|
|
v *= 1e3
|
|
case "min":
|
|
v *= 60e3
|
|
}
|
|
return &v
|
|
}
|
|
|
|
// Status reads timedatectl and the time daemons' units. timesyncd not running is an answer — the
|
|
// machine is not synchronised by it — and is said beside the rest rather than failing the call.
|
|
func (m *Machine) Status() (Status, error) {
|
|
s := Status{Others: []Unit{}}
|
|
td, err := m.Out("timedatectl", "show")
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
kv := keyValues(td, "=")
|
|
s.Synchronized, s.NTPEnabled = kv["NTPSynchronized"] == "yes", kv["NTP"] == "yes"
|
|
if s.Timesyncd, err = m.unit(Daemon); err != nil {
|
|
return s, err
|
|
}
|
|
for _, name := range OtherDaemons {
|
|
u, err := m.unit(name)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
if u.Load != "not-found" {
|
|
s.Others = append(s.Others, u)
|
|
}
|
|
}
|
|
r := m.Run(bg(), "timedatectl", "timesync-status")
|
|
if r.Status != 0 || r.Err != "" {
|
|
s.Error = failure("timedatectl", "timedatectl", r).Error()
|
|
return s, nil
|
|
}
|
|
s.Raw = ParseTimesyncStatus(r.Stdout)
|
|
s.Server = s.Raw["Server"]
|
|
s.OffsetMS, s.DelayMS, s.JitterMS = Millis(s.Raw["Offset"]), Millis(s.Raw["Delay"]), Millis(s.Raw["Jitter"])
|
|
s.Stratum, _ = strconv.Atoi(s.Raw["Stratum"])
|
|
s.PacketCount, _ = strconv.Atoi(s.Raw["Packet count"])
|
|
return s, nil
|
|
}
|
|
|
|
// ConfigFile is one file timesyncd reads, with the servers it sets.
|
|
type ConfigFile struct {
|
|
Path string `json:"path"`
|
|
NTP []string `json:"ntp,omitempty"`
|
|
SetsNTP bool `json:"sets_ntp"`
|
|
FallbackNTP []string `json:"fallback_ntp,omitempty"`
|
|
SetsFallback bool `json:"sets_fallback_ntp"`
|
|
Mesh bool `json:"mesh_owned"`
|
|
}
|
|
|
|
// Servers is which servers timesyncd uses, and which file decided them.
|
|
type Servers struct {
|
|
ServerName string `json:"server_name,omitempty"`
|
|
ServerAddress string `json:"server_address,omitempty"`
|
|
System []string `json:"system_servers"`
|
|
Fallback []string `json:"fallback_servers"`
|
|
Link []string `json:"link_servers"`
|
|
Runtime []string `json:"runtime_servers"`
|
|
Files []ConfigFile `json:"files"`
|
|
NTPDecidedBy string `json:"ntp_decided_by,omitempty"`
|
|
FallbackDecidedBy string `json:"fallback_decided_by,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
var fileHeader = regexp.MustCompile(`^# (/\S+)$`)
|
|
|
|
// ParseCatConfig reads `systemd-analyze cat-config systemd/timesyncd.conf`: each file under a
|
|
// `# /path` header, in the order timesyncd reads them, with what it sets of NTP= and FallbackNTP=.
|
|
func ParseCatConfig(out string) []ConfigFile {
|
|
var files []ConfigFile
|
|
prevBlank := true
|
|
for _, raw := range strings.Split(out, "\n") {
|
|
line := strings.TrimSpace(raw)
|
|
if h := fileHeader.FindStringSubmatch(line); h != nil && prevBlank {
|
|
files = append(files, ConfigFile{Path: h[1], Mesh: h[1] == MeshDropIn})
|
|
prevBlank = false
|
|
continue
|
|
}
|
|
prevBlank = line == ""
|
|
if len(files) == 0 || line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
|
continue
|
|
}
|
|
f := &files[len(files)-1]
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(k) {
|
|
case "NTP":
|
|
// An empty assignment resets the list; a later one adds to it.
|
|
if strings.TrimSpace(v) == "" {
|
|
f.NTP = nil
|
|
}
|
|
f.NTP, f.SetsNTP = append(f.NTP, strings.Fields(v)...), true
|
|
case "FallbackNTP":
|
|
if strings.TrimSpace(v) == "" {
|
|
f.FallbackNTP = nil
|
|
}
|
|
f.FallbackNTP, f.SetsFallback = append(f.FallbackNTP, strings.Fields(v)...), true
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
// Servers reads timesyncd's servers in force and the files that set them.
|
|
func (m *Machine) Servers() (Servers, error) {
|
|
s := Servers{}
|
|
show, err := m.Out("timedatectl", "show-timesync", "--all")
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
kv := keyValues(show, "=")
|
|
s.ServerName, s.ServerAddress = kv["ServerName"], kv["ServerAddress"]
|
|
s.System, s.Fallback = fields(kv["SystemNTPServers"]), fields(kv["FallbackNTPServers"])
|
|
s.Link, s.Runtime = fields(kv["LinkNTPServers"]), fields(kv["RuntimeNTPServers"])
|
|
cat, err := m.Out("systemd-analyze", "cat-config", "systemd/timesyncd.conf")
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
s.Files = ParseCatConfig(cat)
|
|
if s.Files == nil {
|
|
s.Files = []ConfigFile{}
|
|
}
|
|
for _, f := range s.Files {
|
|
if f.SetsNTP {
|
|
s.NTPDecidedBy = f.Path
|
|
}
|
|
if f.SetsFallback {
|
|
s.FallbackDecidedBy = f.Path
|
|
}
|
|
}
|
|
if s.NTPDecidedBy != "" && s.NTPDecidedBy != MeshDropIn {
|
|
for _, f := range s.Files {
|
|
if f.Mesh {
|
|
s.Note = fmt.Sprintf("%s sorts after the mesh's drop-in and its servers are the ones used; the mesh keeps it as found", s.NTPDecidedBy)
|
|
}
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func fields(s string) []string {
|
|
f := strings.Fields(s)
|
|
if f == nil {
|
|
return []string{}
|
|
}
|
|
return f
|
|
}
|
|
|
|
// SyncNow restarts timesyncd, which asks its server at once, and waits a little for an answer.
|
|
func (m *Machine) SyncNow() (Status, error) {
|
|
if _, err := m.Root("systemctl", "restart", Daemon); err != nil {
|
|
return Status{}, err
|
|
}
|
|
var s Status
|
|
var err error
|
|
for i := 0; i < 10; i++ {
|
|
m.Sleep(time.Second)
|
|
if s, err = m.Status(); err != nil {
|
|
return s, err
|
|
}
|
|
if s.Error == "" && s.PacketCount > 0 {
|
|
break
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Retired is what the retire step did.
|
|
type Retired struct {
|
|
Disabled []string
|
|
Removed []string
|
|
}
|
|
|
|
// Retire is the module's step, run once by the host as root before ntp is removed: each named unit
|
|
// that is installed is stopped and disabled; a link left in the service manager's wants directories
|
|
// pointing at a unit that is no longer installed is taken out. It never touches a link it can still
|
|
// follow.
|
|
func (m *Machine) Retire(units []string, wants func(unit string) ([]string, error), dangling func(path string) bool, remove func(path string) error) (Retired, error) {
|
|
var r Retired
|
|
for _, name := range units {
|
|
if !strings.HasSuffix(name, ".service") || strings.ContainsAny(name, "/ ") || strings.HasPrefix(name, "-") {
|
|
return r, fmt.Errorf("%q is not a service's unit name", name)
|
|
}
|
|
u, err := m.unit(name)
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
if u.Load == "loaded" && (u.Boot == "enabled" || u.Active == "active" || u.Active == "activating") {
|
|
if _, err := m.Root("systemctl", "disable", "--now", name); err != nil {
|
|
return r, err
|
|
}
|
|
r.Disabled = append(r.Disabled, name)
|
|
}
|
|
links, err := wants(name)
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
for _, link := range links {
|
|
if !dangling(link) {
|
|
continue
|
|
}
|
|
if err := remove(link); err != nil {
|
|
return r, fmt.Errorf("taking out %s, a link to a unit no longer installed: %w", link, err)
|
|
}
|
|
r.Removed = append(r.Removed, link)
|
|
}
|
|
}
|
|
if len(r.Removed) > 0 {
|
|
if _, err := m.Root("systemctl", "daemon-reload"); err != nil {
|
|
return r, err
|
|
}
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// Wants is every link to a unit in the system manager's wants and requires directories under /etc.
|
|
func Wants(unit string) ([]string, error) {
|
|
var out []string
|
|
for _, kind := range []string{"wants", "requires"} {
|
|
found, err := filepath.Glob(filepath.Join("/etc/systemd/system", "*."+kind, unit))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, found...)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Dangling is whether a path is a symbolic link whose target is gone.
|
|
func Dangling(path string) bool {
|
|
fi, err := os.Lstat(path)
|
|
if err != nil || fi.Mode()&os.ModeSymlink == 0 {
|
|
return false
|
|
}
|
|
_, err = os.Stat(path)
|
|
return os.IsNotExist(err)
|
|
}
|