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..87af78b --- /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: %w", property, err) + } + + if err := restartTimesyncd(); err != nil { + return fmt.Errorf("failed to restart timesyncd: %w", 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) + } +}