package systemtime import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) const ( DefaultNTPConfigPath = "/etc/ntp.conf" DefaultNTPInitPath = "/etc/init.d/S49ntp" DefaultNTPDaemonPath = "/usr/sbin/ntpd" maxNTPConfigBytes = 64 * 1024 ) type commandRunner interface { Run(context.Context, string, ...string) error } type execRunner struct{} func (execRunner) Run(ctx context.Context, name string, args ...string) error { output, err := exec.CommandContext(ctx, name, args...).CombinedOutput() if err == nil { return nil } detail := strings.TrimSpace(string(output)) if len(detail) > 2048 { detail = detail[:2048] } if detail == "" { return err } return fmt.Errorf("%w: %s", err, detail) } type OSNTPApplier struct { configPath string initPath string daemonPath string runner commandRunner clock func() time.Time running func() (bool, error) } func NewOSNTPApplier(configPath, initPath, daemonPath string) (*OSNTPApplier, error) { for label, path := range map[string]string{ "NTP config": configPath, "NTP init script": initPath, "NTP daemon": daemonPath, } { if !filepath.IsAbs(path) { return nil, fmt.Errorf("%s path must be absolute", label) } } return &OSNTPApplier{ configPath: filepath.Clean(configPath), initPath: filepath.Clean(initPath), daemonPath: filepath.Clean(daemonPath), runner: execRunner{}, clock: time.Now, running: ntpDaemonRunning, }, nil } func (a *OSNTPApplier) SyncNTP(ctx context.Context, servers []string) (time.Time, error) { if a == nil || a.runner == nil || a.clock == nil { return time.Time{}, &UnavailableError{Reason: "tera-timed NTP applier is unavailable"} } normalized, err := NormalizeNTPRequest(NTPRequest{Servers: servers, Timezone: "UTC"}) if err != nil { return time.Time{}, err } previous, previousMode, existed, err := readExistingNTPConfig(a.configPath) if err != nil { return time.Time{}, err } if err := a.runner.Run(ctx, a.initPath, "stop"); err != nil { return time.Time{}, fmt.Errorf("stop NTP daemon: %w", err) } content := buildNTPConfig(normalized.Servers) if err := writeAtomicFile(a.configPath, content, 0o644); err != nil { _ = a.runRecovery(a.initPath, "start") return time.Time{}, fmt.Errorf("write NTP configuration: %w", err) } restore := func() error { if existed { if err := writeAtomicFile(a.configPath, previous, previousMode); err != nil { return err } } else if err := os.Remove(a.configPath); err != nil && !os.IsNotExist(err) { return err } return nil } fail := func(action string, syncErr error) (time.Time, error) { restoreErr := restore() restartErr := a.runRecovery(a.initPath, "start") if restoreErr != nil || restartErr != nil { return time.Time{}, fmt.Errorf("%s: %v; restore config: %v; restart prior NTP daemon: %v", action, syncErr, restoreErr, restartErr) } return time.Time{}, fmt.Errorf("%s: %w", action, syncErr) } if err := a.runner.Run(ctx, a.daemonPath, "-g", "-q", "-c", a.configPath); err != nil { return fail("one-shot NTP synchronization failed", err) } if err := a.runner.Run(ctx, a.initPath, "start"); err != nil { return fail("restart NTP daemon with synchronized configuration failed", err) } applied := a.clock().UTC() if applied.Year() < minimumYear || applied.Year() > maximumYear { _ = a.runRecovery(a.initPath, "stop") return fail("NTP daemon exited without a trustworthy clock", errors.New("system clock remains outside the supported year range")) } return applied, nil } func (a *OSNTPApplier) runRecovery(name string, args ...string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return a.runner.Run(ctx, name, args...) } func readExistingNTPConfig(path string) ([]byte, os.FileMode, bool, error) { info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return nil, 0, false, nil } return nil, 0, false, fmt.Errorf("inspect current NTP configuration: %w", err) } if !info.Mode().IsRegular() { return nil, 0, false, errors.New("current NTP configuration is not a regular file") } content, err := readBoundedFile(path, maxNTPConfigBytes) if err != nil { return nil, 0, false, fmt.Errorf("read current NTP configuration: %w", err) } return content, info.Mode().Perm(), true, nil } func buildNTPConfig(servers []string) []byte { var builder strings.Builder for _, server := range servers { fmt.Fprintf(&builder, "server %s iburst\n", server) } builder.WriteString("\n# Allow only rate-limited local time queries.\n") builder.WriteString("restrict default nomodify nopeer noquery limited kod\n") builder.WriteString("restrict 127.0.0.1\n") builder.WriteString("restrict [::1]\n") return []byte(builder.String()) }