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) }