From 0f09f2b4a37e75b3531594b05d5d9ef8f68e33f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Thu, 5 Sep 2024 15:51:51 +0200 Subject: [PATCH 1/2] Add D-Bus API for configuring NTP servers of systemd-timesyncd Add new `time` package that currently provides methods that configure NTP and FallbackNTP options of systemd-timesyncd service. The service is restarted when the values are changed. Example usage through gdbus: gdbus call --system --dest io.hass.os \ --object-path /io/hass/os/Time/Timesyncd \ --method org.freedesktop.DBus.Properties.Set \ io.hass.os.Time.Timesyncd NTPServer \ "<['pool.ntp.org', 'time.google.com']>" A `lineinfile` helper has been implemented for adjusting Systemd unit files, the inspiration comes from Ansible's module of the same name, although the behavior is slightly different (hopefully still quite intuitive). Unit tests for the core methods handling the file content are included. In the future, the `time` package could also handle system timezone and other time-related tasks. --- main.go | 2 + time/timesyncd.go | 175 +++++++++++++++++++ utils/lineinfile/lineinfile.go | 253 ++++++++++++++++++++++++++++ utils/lineinfile/lineinfile_test.go | 161 ++++++++++++++++++ 4 files changed, 591 insertions(+) create mode 100644 time/timesyncd.go create mode 100644 utils/lineinfile/lineinfile.go create mode 100644 utils/lineinfile/lineinfile_test.go diff --git a/main.go b/main.go index 51ae3d5..70656e9 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + os_time "github.com/home-assistant/os-agent/time" "time" "github.com/coreos/go-systemd/v22/daemon" @@ -70,6 +71,7 @@ func main() { apparmor.InitializeDBus(conn) cgroup.InitializeDBus(conn) boards.InitializeDBus(conn, board) + os_time.InitializeDBus(conn) _, err = daemon.SdNotify(false, daemon.SdNotifyReady) if err != nil { diff --git a/time/timesyncd.go b/time/timesyncd.go new file mode 100644 index 0000000..8ab5484 --- /dev/null +++ b/time/timesyncd.go @@ -0,0 +1,175 @@ +package time + +import ( + "fmt" + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" + "github.com/godbus/dbus/v5/prop" + "github.com/home-assistant/os-agent/utils/lineinfile" + "regexp" + "strings" + + logging "github.com/home-assistant/os-agent/utils/log" +) + +const ( + objectPath = "/io/hass/os/Time/Timesyncd" + ifaceName = "io.hass.os.Time.Timesyncd" + timesyncdConf = "/etc/systemd/timesyncd.conf" +) + +var ( + optNTPServer []string + optFallbackNTPServer []string + configFile = lineinfile.LineInFile{FilePath: timesyncdConf} +) + +type timesyncd struct { + conn *dbus.Conn + props *prop.Properties +} + +func getNTPServers() []string { + return getTimesyncdConfigProperty("NTP") +} + +func getFallbackNTPServers() []string { + return getTimesyncdConfigProperty("FallbackNTP") +} + +func setNTPServer(c *prop.Change) *dbus.Error { + servers, ok := c.Value.([]string) + if !ok { + return dbus.MakeFailedError(fmt.Errorf("invalid type for NTPServer")) + } + + value := strings.Join(servers, " ") + + if err := setTimesyncdConfigProperty("NTP", value); err != nil { + return dbus.MakeFailedError(err) + } + + optNTPServer = servers + return nil +} + +func setFallbackNTPServer(c *prop.Change) *dbus.Error { + servers, ok := c.Value.([]string) + if !ok { + return dbus.MakeFailedError(fmt.Errorf("invalid type for FallbackNTPServer")) + } + + value := strings.Join(servers, " ") + + if err := setTimesyncdConfigProperty("FallbackNTP", value); err != nil { + return dbus.MakeFailedError(err) + } + + optFallbackNTPServer = servers + return nil +} + +func getTimesyncdConfigProperty(property string) []string { + value, err := configFile.Find(`^\s*(`+property+`=).*$`, `\[Time\]`, true) + + var servers []string + + if err != nil || value == nil { + return servers + } + + matches := regexp.MustCompile(property + `=([^\s#]+(?:\s+[^\s#]+)*)`).FindStringSubmatch(*value) + if len(matches) > 1 { + servers = strings.Split(matches[1], " ") + } + + return servers +} + +func setTimesyncdConfigProperty(property string, value string) error { + var params = lineinfile.NewPresentParams("NTP=" + value) + params.Regexp, _ = regexp.Compile(`^\s*#?\s*(` + property + `=).*$`) + // Keep it simple, timesyncd.conf only has the [Time] section + params.After = `\[Time\]` + if err := configFile.Present(params); err != nil { + return fmt.Errorf("failed to set %s: %s", property, err) + } + + if err := restartTimesyncd(); err != nil { + return fmt.Errorf("failed to restart timesyncd: %s", err) + } + + return nil +} + +func restartTimesyncd() error { + conn, err := dbus.SystemBus() + if err != nil { + return err + } + + obj := conn.Object("org.freedesktop.systemd1", "/org/freedesktop/systemd1") + call := obj.Call("org.freedesktop.systemd1.Manager.RestartUnit", 0, "systemd-timesyncd.service", "replace") + if call.Err != nil { + return call.Err + } + + return nil +} + +func InitializeDBus(conn *dbus.Conn) { + d := timesyncd{ + conn: conn, + } + + optNTPServer = getNTPServers() + optFallbackNTPServer = getFallbackNTPServers() + + propsSpec := map[string]map[string]*prop.Prop{ + ifaceName: { + "NTPServer": { + Value: optNTPServer, + Writable: true, + Emit: prop.EmitTrue, + Callback: setNTPServer, + }, + "FallbackNTPServer": { + Value: optFallbackNTPServer, + Writable: true, + Emit: prop.EmitTrue, + Callback: setFallbackNTPServer, + }, + }, + } + + props, err := prop.Export(conn, objectPath, propsSpec) + if err != nil { + logging.Critical.Panic(err) + } + d.props = props + + err = conn.Export(d, objectPath, ifaceName) + if err != nil { + logging.Critical.Panic(err) + } + + node := &introspect.Node{ + Name: objectPath, + Interfaces: []introspect.Interface{ + introspect.IntrospectData, + prop.IntrospectData, + { + Name: ifaceName, + Methods: introspect.Methods(d), + Properties: props.Introspection(ifaceName), + }, + }, + } + + err = conn.Export(introspect.NewIntrospectable(node), objectPath, "org.freedesktop.DBus.Introspectable") + if err != nil { + logging.Critical.Panic(err) + } + + logging.Info.Printf("Exposing object %s with interface %s ...", objectPath, ifaceName) +} diff --git a/utils/lineinfile/lineinfile.go b/utils/lineinfile/lineinfile.go new file mode 100644 index 0000000..2e26bd3 --- /dev/null +++ b/utils/lineinfile/lineinfile.go @@ -0,0 +1,253 @@ +package lineinfile + +import ( + "fmt" + logging "github.com/home-assistant/os-agent/utils/log" + "github.com/natefinch/atomic" + "os" + re "regexp" + "strings" +) + +type LineInFile struct { + FilePath string +} + +type Params struct { + // Line to insert at the line matching the Regexp, not needed for Absent + Line string + // Regular expression matching the line to edit or remove + Regexp *re.Regexp + // For Present, insert line after the expression, for Absent, remove line only if it occurs after expression; + // accepts special "BOF" and "EOF" values (beginning of file, end of file) + After string + // For Present, insert line before the expression, for Absent, remove line only if it occurs before expression; + // accepts special "BOF" and "EOF" values (beginning of file, end of file) + Before string +} + +func NewPresentParams(line string) Params { + params := Params{ + Line: line, + Regexp: nil, + After: "EOF", + Before: "", + } + return params +} + +func NewAbsentParams() Params { + params := Params{ + Line: "", + Regexp: nil, + After: "", + Before: "EOF", + } + return params +} + +func (l LineInFile) Present(params Params) error { + if _, err := os.Stat(l.FilePath); err != nil { + return err + } + + content, err := os.ReadFile(l.FilePath) + if err != nil { + return err + } + + lines := strings.Split(string(content), "\n") + + outLines, err := processPresent(lines, params) + if err != nil { + logging.Error.Printf("Failed to process file %s: %s", l.FilePath, err) + return err + } + + err = l.writeFile(outLines) + if err != nil { + return err + } + + return nil +} + +func (l LineInFile) Absent(params Params) error { + if _, err := os.Stat(l.FilePath); os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + + content, err := os.ReadFile(l.FilePath) + if err != nil { + return err + } + + lines := strings.Split(string(content), "\n") + + outLines, err := processAbsent(lines, params) + if err != nil { + logging.Error.Printf("Failed to process file %s: %s", l.FilePath, err) + return err + } + + err = l.writeFile(outLines) + if err != nil { + return err + } + + return nil +} + +func processPresent(inLines []string, params Params) ([]string, error) { + var outLines []string + + if params.Before != "" && params.After != "EOF" { + err := fmt.Errorf("cannot specify both Before and After") + return nil, err + } + + needsBefore := params.Before != "" + needsAfter := params.After != "EOF" + afterRegexp, _ := re.Compile(params.After) + beforeRegexp, _ := re.Compile(params.Before) + + var beforeIndex = -1 + var afterIndex = -1 + var foundIndex = -1 + + for idx, curr := range inLines { + outLines = append(outLines, curr) + if needsBefore && beforeIndex < 0 && beforeRegexp.MatchString(curr) { + beforeIndex = idx + continue + } + if needsAfter && afterIndex < 0 && afterRegexp.MatchString(curr) { + afterIndex = idx + continue + } + if (needsAfter && afterIndex >= 0 || needsBefore && beforeIndex >= 0) && foundIndex < 0 && params.Regexp.MatchString(curr) { + foundIndex = idx + } + } + + if foundIndex >= 0 { + // replace found line with the params.Line + outLines[foundIndex] = params.Line + } else if params.After == "EOF" { + outLines = append(outLines, params.Line) + } else if params.Before == "BOF" { + outLines = append([]string{params.Line}, outLines...) + } else if params.After != "" { + if afterIndex >= 0 { + // insert after the line matching the After regexp + outLines = append(outLines[:afterIndex+1], append([]string{params.Line}, outLines[afterIndex+1:]...)...) + } + } + + return outLines, nil +} + +func processAbsent(inLines []string, params Params) ([]string, error) { + var outLines []string + + if params.Before != "EOF" && params.After != "" { + err := fmt.Errorf("cannot specify both Before and After") + return nil, err + } + + needsBefore := params.Before != "EOF" + needsAfter := params.After != "" + afterRegexp, _ := re.Compile(params.After) + beforeRegexp, _ := re.Compile(params.Before) + + var beforeIndex = -1 + var afterIndex = -1 + var foundIndex = -1 + + for idx, curr := range inLines { + outLines = append(outLines, curr) + if needsBefore && beforeIndex < 0 && beforeRegexp.MatchString(curr) { + beforeIndex = idx + continue + } + if needsAfter && afterIndex < 0 && afterRegexp.MatchString(curr) { + afterIndex = idx + continue + } + if (needsAfter && afterIndex >= 0 || needsBefore && beforeIndex >= 0) && foundIndex < 0 && params.Regexp.MatchString(curr) { + foundIndex = idx + } + } + + if foundIndex >= 0 { + // remove found line + outLines = append(outLines[:foundIndex], outLines[foundIndex+1:]...) + } + + return outLines, nil +} + +func (l LineInFile) Find(regexp string, after string, allowMissing bool) (*string, error) { + if _, err := os.Stat(l.FilePath); os.IsNotExist(err) { + if allowMissing { + return nil, nil + } + logging.Error.Printf("File %s does not exist: %s", l.FilePath, err) + return nil, err + } + + content, err := os.ReadFile(l.FilePath) + if err != nil { + fmt.Print(err) + return nil, err + } + + lines := strings.Split(string(content), "\n") + + return processFind(regexp, after, lines), nil +} + +func processFind(regexp string, after string, inLines []string) *string { + if inLines == nil { + return nil + } + + lineRegexp, _ := re.Compile(regexp) + afterRegexp, _ := re.Compile(after) + + var foundAfter = false + + for _, curr := range inLines { + if !foundAfter && afterRegexp.MatchString(curr) { + foundAfter = true + continue + } + if after != "" && !foundAfter { + continue + } + if lineRegexp.MatchString(curr) { + return &curr + } + } + + return nil +} + +func (l LineInFile) writeFile(lines []string) error { + raw := strings.Join(lines, "\n") + if !strings.HasSuffix(raw, "\n") { + raw += "\n" + } + reader := strings.NewReader(raw) + + err := atomic.WriteFile(l.FilePath, reader) + + if err != nil { + logging.Error.Printf("Failed to write file %s: %s", l.FilePath, err) + return err + } + + return nil +} diff --git a/utils/lineinfile/lineinfile_test.go b/utils/lineinfile/lineinfile_test.go new file mode 100644 index 0000000..c1ad00c --- /dev/null +++ b/utils/lineinfile/lineinfile_test.go @@ -0,0 +1,161 @@ +package lineinfile + +import ( + "regexp" + "strings" + "testing" +) + +const ( + contentNoNTP = `[Time] +FallbackNTP=time.cloudflare.com +# Speed-up boot as first attempt is done before network is up +ConnectionRetrySec=10 +` + contentNTPSet = `[Time] +NTP=ntp.example.com +FallbackNTP=time.cloudflare.com +# Speed-up boot as first attempt is done before network is up +ConnectionRetrySec=10 +` + contentNTPCommented = `[Time] +#NTP=ntp.example.com +FallbackNTP=time.cloudflare.com +# Speed-up boot as first attempt is done before network is up +ConnectionRetrySec=10 +` + contentNTPNotAfter = `NTP=ntp.example.com +[Time] +FallbackNTP=time.cloudflare.com +ConnectionRetrySec=10 +` +) + +func TestFindExisting(t *testing.T) { + lines := strings.Split(contentNTPSet, "\n") + result := processFind(`^\s*(NTP=).*$`, `\[Time\]`, lines) + if result == nil { + t.Errorf("Expected a result, got nil") + return + } + t.Logf("Result: %s", *result) + expected := "NTP=ntp.example.com" + if *result != expected { + t.Errorf("Expected %s, got %s", expected, *result) + } +} + +func TestFindMissing(t *testing.T) { + lines := strings.Split(contentNoNTP, "\n") + result := processFind(`^\s*(NTP=).*$`, `\[Time\]`, lines) + if result != nil { + t.Errorf("Expected nil, got %s", *result) + } +} + +func TestFindMissingNotAfter(t *testing.T) { + lines := strings.Split(contentNTPNotAfter, "\n") + result := processFind(`^\s*(NTP=).*$`, `\[Time\]`, lines) + if result != nil { + t.Errorf("Expected nil, got %s", *result) + } +} + +func TestPresentSimple(t *testing.T) { + params := NewPresentParams("NTP=ntp2.example.com") + params.Regexp, _ = regexp.Compile(`^\s*#?\s*(NTP=).*$`) + params.After = `\[Time\]` + lines := strings.Split(contentNTPSet, "\n") + processed, _ := processPresent(lines, params) + result := strings.Join(processed, "\n") + expected := strings.Replace(contentNTPSet, "NTP=ntp.example.com", "NTP=ntp2.example.com", 1) + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } +} + +func TestPresentNotAfter(t *testing.T) { + params := NewPresentParams("NTP=ntp2.example.com") + params.Regexp, _ = regexp.Compile(`^\s*#?\s*(NTP=).*$`) + params.After = `\[Time\]` + lines := strings.Split(contentNTPNotAfter, "\n") + processed, _ := processPresent(lines, params) + result := strings.Join(processed, "\n") + expected := `NTP=ntp.example.com +[Time] +NTP=ntp2.example.com +FallbackNTP=time.cloudflare.com +ConnectionRetrySec=10 +` + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } +} + +func TestPresentCommented(t *testing.T) { + params := NewPresentParams("NTP=ntp.example.com") + params.Regexp, _ = regexp.Compile(`^\s*#?\s*(NTP=).*$`) + params.After = `\[Time\]` + lines := strings.Split(contentNTPCommented, "\n") + processed, _ := processPresent(lines, params) + result := strings.Join(processed, "\n") + if result != contentNTPSet { + t.Errorf("Expected %s, got %s", contentNTPSet, result) + } +} + +func TestPresentAdded(t *testing.T) { + params := NewPresentParams("NTP=ntp.example.com") + params.Regexp, _ = regexp.Compile(`^\s*#?\s*(NTP=).*$`) + params.After = `\[Time\]` + lines := strings.Split(contentNoNTP, "\n") + processed, _ := processPresent(lines, params) + result := strings.Join(processed, "\n") + expected := contentNTPSet + if result != expected { + t.Errorf("Expected %s, got %s", contentNTPSet, result) + } +} + +func TestPresentAppendedEOF(t *testing.T) { + params := NewPresentParams("NTP=ntp.example.com") + params.Regexp, _ = regexp.Compile(`^\s*#?\s*(NTP=).*$`) + params.After = `EOF` + lines := strings.Split(contentNoNTP, "\n") + processed, _ := processPresent(lines, params) + result := strings.Join(processed, "\n") + expected := contentNoNTP + "\nNTP=ntp.example.com" + if result != expected { + t.Errorf("Expected %s, got %s", contentNTPSet, result) + } +} + +func TestAbsent(t *testing.T) { + params := NewAbsentParams() + params.Regexp, _ = regexp.Compile(`^\s*(NTP=).*$`) + params.After = `\[Time\]` + lines := strings.Split(contentNTPSet, "\n") + processed, _ := processAbsent(lines, params) + result := strings.Join(processed, "\n") + expected := `[Time] +FallbackNTP=time.cloudflare.com +# Speed-up boot as first attempt is done before network is up +ConnectionRetrySec=10 +` + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } +} + +func TestAbsentNotAfter(t *testing.T) { + params := NewAbsentParams() + params.Regexp, _ = regexp.Compile(`^\s*(NTP=).*$`) + params.After = `\[Time\]` + lines := strings.Split(contentNTPNotAfter, "\n") + processed, _ := processAbsent(lines, params) + result := strings.Join(processed, "\n") + expected := contentNTPNotAfter + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } +} From ca6d55208bd237e961975989a0e180c905ef47dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Thu, 5 Sep 2024 16:09:24 +0200 Subject: [PATCH 2/2] Fix linter errors --- time/timesyncd.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/time/timesyncd.go b/time/timesyncd.go index 8ab5484..87af78b 100644 --- a/time/timesyncd.go +++ b/time/timesyncd.go @@ -92,11 +92,11 @@ func setTimesyncdConfigProperty(property string, value string) error { // Keep it simple, timesyncd.conf only has the [Time] section params.After = `\[Time\]` if err := configFile.Present(params); err != nil { - return fmt.Errorf("failed to set %s: %s", property, err) + return fmt.Errorf("failed to set %s: %w", property, err) } if err := restartTimesyncd(); err != nil { - return fmt.Errorf("failed to restart timesyncd: %s", err) + return fmt.Errorf("failed to restart timesyncd: %w", err) } return nil