diff --git a/modules/time-sync/README.md b/modules/time-sync/README.md new file mode 100644 index 0000000..7a856b9 --- /dev/null +++ b/modules/time-sync/README.md @@ -0,0 +1,39 @@ +# time-sync + +The machine's clock, kept by one daemon: systemd-timesyncd (novox/hq to-be 42 Phase 1, +research 027). + +## What it owns + +- `/etc/systemd/timesyncd.conf.d/50-mesh.conf`, written whole: `NTP=` the four European pool + servers, `FallbackNTP=` the distribution's pool. +- `systemd-timesyncd.service`, running and enabled, and restarted when the drop-in changes. +- `ntp`, declared **absent** (ADR 0180). +- A **step**, `time-sync-retire-ntpd`. The host runs the module's own binary once per version of the + bundle, as root, *before* timesyncd is started and ntp removed: + `time-sync-tools retire ntpd.service ntpdate.service`. The step stops and disables each unit that + is installed and running or enabled. Removing a package does not disable its units, so without the + step ntp's removal would leave `multi-user.target.wants/ntpd.service` pointing at nothing. Where + the package is already gone, the step takes out only such a dangling link, never a link it can + still follow. + +## What it improves + +- One daemon on every machine. Three ran timesyncd and one ran ntpd, with timesyncd disabled. +- The servers are declared, not left to whatever a machine was installed with. One machine had + edited `timesyncd.conf` itself; the drop-in now overrides that. + +## What it leaves found + +- **A hosting provider's own drop-in.** On a machine whose provider installed a timesyncd drop-in + (found on the anchor), that file sorts after `50-mesh.conf`, so its servers win. They are in the + same network as the machine. It is kept, and `time_sync_servers` names it as the file that decides. +- `/etc/systemd/timesyncd.conf`, and an `/etc/ntp.conf` that the package manager keeps as `.pacsave`. + +## Tools + +| tool | | answers | +|---|---|---| +| `time_sync_status` | r | synchronised, NTP on, timesyncd's unit; server, offset, delay, jitter (ms), stratum, packets, and every line of `timesync-status`; any other time daemon installed. If timesyncd is not running, that is said, not failed | +| `time_sync_servers` | r | the server in use; system, fallback, link and runtime servers; every config file in reading order with what it sets; which file decides | +| `time_sync_sync_now` | a | restarts timesyncd (sudo -n) and answers the status after waiting up to 10 s for a packet | diff --git a/modules/time-sync/cmd/time-sync-tools/machine.go b/modules/time-sync/cmd/time-sync-tools/machine.go new file mode 100644 index 0000000..691a19d --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/machine.go @@ -0,0 +1,289 @@ +package main + +// The commands this bundle runs on its machine, and who runs them. +// +// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), +// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — +// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only +// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the +// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the +// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an +// empty answer. +// +// The runner is injected, so every tool is tested over a fake one without the machine. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// Ran is what one command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. + Err string +} + +// Runner runs one command, so the tools can be tested without the machine. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a +// command that hangs is answered as such rather than as a call the runtime gave up on. +const CallTimeout = 20 * time.Second + +// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the +// process; well above anything a tool answers. +const outputLimit = 16 << 20 + +type bounded struct { + bytes.Buffer + cut bool +} + +func (b *bounded) Write(p []byte) (int, error) { + if room := outputLimit - b.Len(); room < len(p) { + if room > 0 { + b.Buffer.Write(p[:room]) + } + b.cut = true + return len(p), nil + } + return b.Buffer.Write(p) +} + +// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CallTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + var out, errb bounded + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + if ctx.Err() == context.DeadlineExceeded { + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + return r + } + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + r.Status = exit.ExitCode() + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): + r.Status, r.Err = 127, "ENOENT" + default: + r.Status, r.Err = 126, err.Error() + } + return r +} + +// Escalated is the command as it is run: as given when this process is root, else through sudo +// without a prompt. +func Escalated(uid int, name string, args ...string) (string, []string) { + if uid == 0 { + return name, args + } + return "sudo", append([]string{"-n", name}, args...) +} + +// Machine is this machine as the tools see it: a runner, who this process is, and its files. +type Machine struct { + Run Runner + UID int + User string + Account string + ReadFile func(path string) ([]byte, error) + Now func() time.Time + Sleep func(time.Duration) +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("LOGNAME") + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = user + } + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep} +} + +// Out runs a command that only reads, and fails with what went wrong named. +func (m *Machine) Out(name string, args ...string) (string, error) { + r := m.Run(context.Background(), name, args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, name, r) +} + +// Root runs a command that needs root, escalated when this process is not. +func (m *Machine) Root(name string, args ...string) (string, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, program, r) +} + +// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. +func (m *Machine) RootRan(name string, args ...string) (Ran, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Err != "" || (program == "sudo" && sudoRefused(r)) { + return r, failure(name, program, r) + } + return r, nil +} + +func sudoRefused(r Ran) bool { + return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") +} + +// failure names what failed by how it failed: the program missing is a spawn error, sudo missing +// or refusing speaks for itself, and the rest is the command's own first line. +func failure(cmd, program string, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Err == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Err != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Err) + } + if program == "sudo" && sudoRefused(r) { + if strings.Contains(said, "command not found") { + return fmt.Errorf("%s is not installed on this machine", cmd) + } + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func lines(text string) []string { + var out []string + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// text is a string argument; required says whether it may be absent. It is never something a +// command would read as an option, which under sudo would be root's option. +func text(args map[string]any, key string, required bool) (string, error) { + raw, present := args[key] + if !present || raw == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + s = strings.TrimSpace(s) + if required && s == "" { + return "", fmt.Errorf("%s is required", key) + } + if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { + return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) + } + return s, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// flag is a boolean argument, false when absent. +func flag(args map[string]any, key string) (bool, error) { + raw, present := args[key] + if !present || raw == nil { + return false, nil + } + b, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +// schema is a tool's input: its properties and the ones it requires. +func schema(properties map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// unitProps reads a unit's properties as systemctl shows them. +func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.Out("systemctl", args...) + if err != nil { + return nil, err + } + return keyValues(out, "="), nil +} + +// keyValues reads `keyvalue` lines; a line without the separator is skipped. +func keyValues(out, sep string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, sep) + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} diff --git a/modules/time-sync/cmd/time-sync-tools/machine_test.go b/modules/time-sync/cmd/time-sync-tools/machine_test.go new file mode 100644 index 0000000..c561be8 --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/machine_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// call is one command a fake runner was asked to run. +type call struct { + name string + args []string +} + +func (c call) String() string { + if len(c.args) == 0 { + return c.name + } + return c.name + " " + strings.Join(c.args, " ") +} + +// fake is a runner answering by the command line it is given, recording every call. +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(_ context.Context, name string, args ...string) Ran { + c := call{name, append([]string(nil), args...)} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +// byLine answers from a table keyed by the whole command line, and refuses anything else as a +// command the test did not expect. +func byLine(table map[string]Ran, calls *[]call) Runner { + return fake(func(c call) Ran { + if r, ok := table[c.String()]; ok { + return r + } + return Ran{Status: 99, Stderr: "unexpected command: " + c.String()} + }, calls) +} + +func machine(run Runner, uid int) *Machine { + return &Machine{Run: run, UID: uid, User: "operator", Account: "operator", + ReadFile: func(string) ([]byte, error) { return nil, errNoFile }, + Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }, + Sleep: func(time.Duration) {}} +} + +type noFile struct{} + +func (noFile) Error() string { return "no such file" } + +var errNoFile = noFile{} + +func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) { + if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" { + t.Fatalf("not root: %s %v", p, a) + } + if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" { + t.Fatalf("root: %s %v", p, a) + } +} + +func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) { + cases := []struct { + r Ran + want string + }{ + {Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"}, + {Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"}, + {Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"}, + {Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"}, + } + for _, c := range cases { + m := machine(fake(func(call) Ran { return c.r }, nil), 1000) + if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.r, err, c.want) + } + } + m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000) + if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") { + t.Errorf("a missing program: %v", err) + } +} + +func TestAnArgumentIsNeverAnOption(t *testing.T) { + for _, bad := range []any{"-rf", "a\nb", 3.0} { + if _, err := text(map[string]any{"x": bad}, "x", true); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" { + t.Errorf("a plain value: %q %v", s, err) + } + if _, err := text(map[string]any{}, "x", true); err == nil { + t.Error("a missing required value was accepted") + } + if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } + if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil { + t.Error("below the least was accepted") + } +} diff --git a/modules/time-sync/cmd/time-sync-tools/main.go b/modules/time-sync/cmd/time-sync-tools/main.go new file mode 100644 index 0000000..4d65106 --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/main.go @@ -0,0 +1,77 @@ +// time-sync's tools bundle (novox/hq to-be 42 Phase 1, research 026/05), and its step. +// +// Served by the node's runtime over MCP on stdio through the Go SDK (ADR 0188, ADR 0193) when it is +// started with no arguments. Started as `time-sync-tools retire …` it is the module's step, +// which the host runs once as root for every version of the bundle (timesync.go says why). +package main + +import ( + "context" + "fmt" + "os" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "time-sync-tools" + +func bg() context.Context { return context.Background() } + +func main() { + m := ThisMachine() + if len(os.Args) > 1 { + if os.Args[1] != "retire" || len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "usage: %s [retire …]\n", binaryName) + os.Exit(2) + } + r, err := m.Retire(os.Args[2:], Wants, Dangling, os.Remove) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("disabled: %s; dangling links taken out: %s\n", orNone(r.Disabled), orNone(r.Removed)) + return + } + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): time-sync. + if err := stdio.Serve("", tools(m)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func orNone(list []string) string { + if len(list) == 0 { + return "none" + } + return strings.Join(list, ", ") +} + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "time_sync_status", + Description: "Whether the clock is synchronised and how well: NTP on, synchronised, timesyncd's unit state, the server " + + "it uses, offset, delay and jitter in milliseconds, stratum and packet count (timedatectl timesync-status, with " + + "its every line), and any other time daemon installed beside it. timesyncd not answering is said, not failed.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Status() }, + }, + { + Name: "time_sync_servers", + Description: "The servers timesyncd uses (the one now, the configured, the fallback, the link's and the runtime's) " + + "and every configuration file in the order it reads them with the NTP= and FallbackNTP= each sets; which file " + + "decides, and a note when a drop-in sorting after the mesh's wins.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Servers() }, + }, + { + Name: "time_sync_sync_now", + Description: "Restart timesyncd (sudo -n), which asks its server at once, and answer the status after up to ten " + + "seconds of waiting for its first packet.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.SyncNow() }, + }, + } +} diff --git a/modules/time-sync/cmd/time-sync-tools/manifest_test.go b/modules/time-sync/cmd/time-sync-tools/manifest_test.go new file mode 100644 index 0000000..42dbaf0 --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/manifest_test.go @@ -0,0 +1,59 @@ +package main + +// The module's shape (novox/hq to-be 42 Phase 1, research 027): its servers in a drop-in timesyncd +// reads, timesyncd running and enabled and restarted when they change, ntp absent (ADR 0180) — and +// the step that retires ntpd declared before the package goes and before timesyncd is started, so the +// host, which applies a module's resources in order, never removes a daemon that is still enabled. + +import ( + "strings" + "testing" +) + +func TestTheOrderIsServersStepDaemonThenNtpAbsent(t *testing.T) { + m := manifest(t) + var ids []string + for _, r := range m.Resources { + ids = append(ids, r["id"].(string)) + } + if strings.Join(ids, " ") != "servers retire-ntpd daemon ntp" { + t.Fatalf("order: %v", ids) + } + step := m.resource(t, "retire-ntpd") + if step["type"] != "process" || step["run-once"] != true || step["user"] != nil { + t.Fatalf("step: %v", step) + } + if run := step["run"].([]any); run[0] != "./"+binaryName || run[1] != "retire" || run[2] != "ntpd.service" { + t.Fatalf("run: %v", run) + } + daemon := m.resource(t, "daemon") + if daemon["unit"] != Daemon || daemon["state"] != "running" || daemon["boot"] != "enabled" { + t.Fatalf("daemon: %v", daemon) + } + if on := daemon["restart-on"].([]any); len(on) != 1 || on[0] != "servers" { + t.Fatalf("restart-on: %v", on) + } + if ntp := m.resource(t, "ntp"); ntp["package"] != "ntp" || ntp["absent"] != true { + t.Fatalf("ntp: %v", ntp) + } +} + +func TestTheDropInSetsEuropeanServersAndTheDistributionsFallback(t *testing.T) { + f := manifest(t).resource(t, "servers") + if f["path"] != MeshDropIn { + t.Fatalf("path: %v", f["path"]) + } + files := ParseCatConfig("# " + MeshDropIn + "\n" + f["content"].(string)) + if len(files) != 1 || len(files[0].NTP) != 4 || len(files[0].FallbackNTP) != 4 { + t.Fatalf("%+v", files) + } + for _, s := range files[0].NTP { + if !strings.HasSuffix(s, ".europe.pool.ntp.org") { + t.Errorf("%s", s) + } + } + // A drop-in is read in name order; the mesh's must sort before a provider's own, which keeps it. + if !(strings.Compare("50-mesh.conf", "provider.conf") < 0) { + t.Fatal("the mesh's drop-in no longer sorts before a provider's") + } +} diff --git a/modules/time-sync/cmd/time-sync-tools/shape_test.go b/modules/time-sync/cmd/time-sync-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/shape_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Claims []map[string]any `json:"claims"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(machine(nil, 1000)) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} diff --git a/modules/time-sync/cmd/time-sync-tools/timesync.go b/modules/time-sync/cmd/time-sync-tools/timesync.go new file mode 100644 index 0000000..f497909 --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/timesync.go @@ -0,0 +1,330 @@ +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) +} diff --git a/modules/time-sync/cmd/time-sync-tools/timesync_test.go b/modules/time-sync/cmd/time-sync-tools/timesync_test.go new file mode 100644 index 0000000..830325c --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/timesync_test.go @@ -0,0 +1,199 @@ +package main + +import ( + "strings" + "testing" +) + +const timesyncStatus = ` Server: 185.51.192.63 (0.arch.pool.ntp.org) +Poll interval: 8min 32s (min: 32s; max 34min 8s) + Leap: normal + Version: 4 + Stratum: 2 + Reference: C0AB0196 + Precision: 1us (-21) +Root distance: 1.418ms (max: 5s) + Offset: -1.949ms + Delay: 27.986ms + Jitter: 1.648ms + Packet count: 4 + Frequency: -8.704ppm +` + +func show(load, active, boot string) Ran { + return Ran{Stdout: "LoadState=" + load + "\nActiveState=" + active + "\nUnitFileState=" + boot + "\n"} +} + +func unitLine(u string) string { + return "systemctl show " + u + " --no-pager --property=LoadState --property=ActiveState --property=UnitFileState" +} + +func TestDurationsAreMilliseconds(t *testing.T) { + for in, want := range map[string]float64{"-1.949ms": -1.949, "+27us": 0.027, "1.5s": 1500, "2min": 120000, "500ns": 0.0005} { + if got := Millis(in); got == nil || *got-want > 1e-9 || want-*got > 1e-9 { + t.Errorf("%s: %v", in, got) + } + } + if Millis("n/a") != nil { + t.Error("not a duration") + } +} + +func TestStatusReadsTimesyncAndNamesAnotherDaemon(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "timedatectl show": {Stdout: "NTP=yes\nNTPSynchronized=yes\n"}, + unitLine(Daemon): show("loaded", "active", "enabled"), + unitLine("ntpd.service"): show("loaded", "inactive", "disabled"), + unitLine("chronyd.service"): show("not-found", "inactive", ""), + unitLine("openntpd.service"): show("not-found", "inactive", ""), + "timedatectl timesync-status": {Stdout: timesyncStatus}, + }, nil), 1000) + s, err := m.Status() + if err != nil { + t.Fatal(err) + } + if !s.Synchronized || !s.NTPEnabled || s.Timesyncd.Active != "active" || s.Server != "185.51.192.63 (0.arch.pool.ntp.org)" { + t.Fatalf("%+v", s) + } + if *s.OffsetMS != -1.949 || *s.DelayMS != 27.986 || s.Stratum != 2 || s.PacketCount != 4 || s.Raw["Leap"] != "normal" { + t.Fatalf("%+v", s) + } + if len(s.Others) != 1 || s.Others[0].Unit != "ntpd.service" { + t.Fatalf("others: %+v", s.Others) + } +} + +func TestTimesyncNotRunningIsSaidBesideTheRest(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "timedatectl show": {Stdout: "NTP=no\nNTPSynchronized=yes\n"}, + unitLine(Daemon): show("loaded", "inactive", "disabled"), + unitLine("ntpd.service"): show("loaded", "active", "enabled"), + unitLine("chronyd.service"): show("not-found", "inactive", ""), + unitLine("openntpd.service"): show("not-found", "inactive", ""), + "timedatectl timesync-status": {Status: 1, Stderr: "Command requires systemd-timesyncd.service, but it is not available: unknown unit\n"}, + }, nil), 1000) + s, err := m.Status() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(s.Error, "requires systemd-timesyncd.service") || s.Others[0].Active != "active" || s.Server != "" { + t.Fatalf("%+v", s) + } +} + +const catConfigAnchor = `# /etc/systemd/timesyncd.conf +# This file is part of systemd. +# +# See timesyncd.conf(5) for details. + +[Time] +#NTP= +#FallbackNTP=0.arch.pool.ntp.org + +# /etc/systemd/timesyncd.conf.d/50-mesh.conf +# The mesh's (module time-sync, novox/hq to-be 42) +[Time] +NTP=0.europe.pool.ntp.org 1.europe.pool.ntp.org +FallbackNTP=0.arch.pool.ntp.org + +# /etc/systemd/timesyncd.conf.d/provider.conf +[Time] +NTP=ntp1.provider.example ntp2.provider.example +` + +func TestServersNameTheFileThatDecidesAndAProvidersDropInWinning(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "timedatectl show-timesync --all": {Stdout: "LinkNTPServers=\nSystemNTPServers=ntp1.provider.example ntp2.provider.example\nRuntimeNTPServers=\nFallbackNTPServers=0.arch.pool.ntp.org\nServerName=ntp2.provider.example\nServerAddress=2001:db8::2\n"}, + "systemd-analyze cat-config systemd/timesyncd.conf": {Stdout: catConfigAnchor}, + }, nil), 1000) + s, err := m.Servers() + if err != nil { + t.Fatal(err) + } + if len(s.Files) != 3 || !s.Files[1].Mesh || s.Files[0].SetsNTP || len(s.Files[1].NTP) != 2 { + t.Fatalf("files: %+v", s.Files) + } + if s.NTPDecidedBy != "/etc/systemd/timesyncd.conf.d/provider.conf" || s.FallbackDecidedBy != MeshDropIn { + t.Fatalf("decided: %s / %s", s.NTPDecidedBy, s.FallbackDecidedBy) + } + if !strings.Contains(s.Note, "provider.conf sorts after the mesh's drop-in") || s.ServerName != "ntp2.provider.example" || len(s.System) != 2 || len(s.Link) != 0 { + t.Fatalf("%+v", s) + } +} + +func TestAnEmptyAssignmentResetsTheList(t *testing.T) { + f := ParseCatConfig("# /etc/a.conf\n[Time]\nNTP=a b\nNTP=\nNTP=c\n") + if len(f) != 1 || strings.Join(f[0].NTP, " ") != "c" { + t.Fatalf("%+v", f) + } +} + +func TestSyncNowRestartsThroughSudoAndWaitsForAPacket(t *testing.T) { + var calls []call + packets := "0" + m := machine(fake(func(c call) Ran { + switch { + case c.String() == "sudo -n systemctl restart "+Daemon: + return Ran{} + case c.String() == "timedatectl show": + return Ran{Stdout: "NTP=yes\nNTPSynchronized=yes\n"} + case c.name == "systemctl": + return show("not-found", "inactive", "") + case c.String() == "timedatectl timesync-status": + r := Ran{Stdout: strings.Replace(timesyncStatus, "Packet count: 4", "Packet count: "+packets, 1)} + packets = "1" + return r + } + return Ran{Status: 99} + }, &calls), 1000) + s, err := m.SyncNow() + if err != nil || s.PacketCount != 1 { + t.Fatalf("%+v %v", s, err) + } + if calls[0].String() != "sudo -n systemctl restart "+Daemon { + t.Fatalf("first: %s", calls[0]) + } +} + +func TestRetireDisablesAnInstalledDaemonAndTakesOutOnlyDanglingLinks(t *testing.T) { + var calls []call + m := machine(byLine(map[string]Ran{ + unitLine("ntpd.service"): show("loaded", "active", "enabled"), + unitLine("ntpdate.service"): show("loaded", "inactive", "disabled"), + "systemctl disable --now ntpd.service": {}, + "systemctl daemon-reload": {}, + }, &calls), 0) + links := map[string][]string{ + "ntpd.service": {"/etc/systemd/system/multi-user.target.wants/ntpd.service"}, + "ntpdate.service": {"/etc/systemd/system/multi-user.target.wants/ntpdate.service"}, + } + gone := map[string]bool{"/etc/systemd/system/multi-user.target.wants/ntpdate.service": true} + var removed []string + r, err := m.Retire([]string{"ntpd.service", "ntpdate.service"}, + func(u string) ([]string, error) { return links[u], nil }, + func(p string) bool { return gone[p] }, + func(p string) error { removed = append(removed, p); return nil }) + if err != nil { + t.Fatal(err) + } + if strings.Join(r.Disabled, ",") != "ntpd.service" || strings.Join(removed, ",") != "/etc/systemd/system/multi-user.target.wants/ntpdate.service" { + t.Fatalf("%+v %v", r, removed) + } + for _, c := range calls { + if c.name == "sudo" { + t.Fatal("the step runs as root") + } + } +} + +func TestRetireOnAMachineWithoutTheDaemonDoesNothing(t *testing.T) { + var calls []call + m := machine(byLine(map[string]Ran{unitLine("ntpd.service"): show("not-found", "inactive", "")}, &calls), 0) + r, err := m.Retire([]string{"ntpd.service"}, func(string) ([]string, error) { return nil, nil }, func(string) bool { return false }, nil) + if err != nil || len(r.Disabled)+len(r.Removed) != 0 || len(calls) != 1 { + t.Fatalf("%+v %v %v", r, err, calls) + } + if _, err := m.Retire([]string{"../x"}, nil, nil, nil); err == nil { + t.Fatal("a path was taken for a unit") + } +} diff --git a/modules/time-sync/go.mod b/modules/time-sync/go.mod new file mode 100644 index 0000000..72c01ad --- /dev/null +++ b/modules/time-sync/go.mod @@ -0,0 +1,5 @@ +module time-sync + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/time-sync/go.sum b/modules/time-sync/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/time-sync/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/time-sync/module.json b/modules/time-sync/module.json new file mode 100644 index 0000000..f76a43d --- /dev/null +++ b/modules/time-sync/module.json @@ -0,0 +1,76 @@ +{ + "module": "time-sync", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "time_sync_status", + "time_sync_servers", + "time_sync_sync_now" + ], + "resources": [ + { + "id": "servers", + "type": "file", + "path": "/etc/systemd/timesyncd.conf.d/50-mesh.conf", + "mode": "0644", + "content": "# The mesh's (module time-sync, novox/hq to-be 42): the servers timesyncd asks. Written whole at\n# every push. A drop-in whose name sorts after this one wins over it: a hosting provider's own\n# servers are kept that way where the machine was found with them.\n[Time]\nNTP=0.europe.pool.ntp.org 1.europe.pool.ntp.org 2.europe.pool.ntp.org 3.europe.pool.ntp.org\nFallbackNTP=0.arch.pool.ntp.org 1.arch.pool.ntp.org 2.arch.pool.ntp.org 3.arch.pool.ntp.org\n", + "names-on-purpose": { + "0.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "1.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "2.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "3.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "0.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses", + "1.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses", + "2.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses", + "3.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses" + } + }, + { + "id": "retire-ntpd", + "type": "process", + "name": "time-sync-retire-ntpd", + "artifact": "tools", + "run": [ + "./time-sync-tools", + "retire", + "ntpd.service", + "ntpdate.service" + ], + "run-once": true + }, + { + "id": "daemon", + "type": "service", + "unit": "systemd-timesyncd.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "servers" + ] + }, + { + "id": "ntp", + "type": "package", + "package": "ntp", + "absent": true + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/time-sync-tools", + "binary": "time-sync-tools", + "loads": [ + "time-sync-tools" + ] + } + ] + } +}