package systemtime import ( "context" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" ) // Settings configures clock discipline without waiting for an external server // or rewriting an already valid clock. Browser bootstrap remains independent. type Settings struct { Timezone string `json:"timezone"` NTPEnabled bool `json:"ntp_enabled"` Servers []string `json:"servers"` } type SettingsController interface { Configure(context.Context, Settings) error } type NTPSettingsApplier interface { ConfigureNTP(context.Context, bool, []string) error } func (c *TransactionalController) Configure(ctx context.Context, settings Settings) error { zone, err := NormalizeTimezone(settings.Timezone) if err != nil { return err } if settings.NTPEnabled { normalized, err := NormalizeNTPRequest(NTPRequest{Timezone: zone, Servers: settings.Servers}) if err != nil { return err } settings.Servers = normalized.Servers } if c == nil || c.applier == nil { return errors.New("time settings applier unavailable") } ntp, ok := c.ntpApplier.(NTPSettingsApplier) if !ok { return errors.New("NTP settings applier unavailable") } c.mu.Lock() defer c.mu.Unlock() rollback, err := c.applier.ApplyTimezone(ctx, zone) if err != nil { return err } if rollback == nil { return errors.New("timezone rollback missing") } if err := ntp.ConfigureNTP(ctx, settings.NTPEnabled, settings.Servers); err != nil { return errors.Join(err, rollback()) } return nil } func (MockController) Configure(ctx context.Context, settings Settings) error { _, err := NormalizeTimezone(settings.Timezone) return err } func (a *OSNTPApplier) ConfigureNTP(ctx context.Context, enabled bool, servers []string) error { var normalized NTPRequest var err error if enabled { normalized, err = NormalizeNTPRequest(NTPRequest{Timezone: "UTC", Servers: servers}) if err != nil { return err } } previous, mode, existed, err := readExistingNTPConfig(a.configPath) if err != nil { return err } running, err := a.running() if err != nil { return err } if running { if err := a.runner.Run(ctx, a.initPath, "stop"); err != nil { return fmt.Errorf("stop NTP: %w", err) } if err := a.waitNTPState(ctx, false); err != nil { return err } } if !enabled { return nil } err = writeAtomicFile(a.configPath, buildNTPConfig(normalized.Servers), 0o644) if err == nil { err = a.runner.Run(ctx, a.initPath, "start") if err == nil { err = a.waitNTPState(ctx, true) } } if err == nil { return nil } // Preserve the previous configuration if the daemon rejects the change. if existed { err = errors.Join(err, writeAtomicFile(a.configPath, previous, mode)) } else if removeErr := os.Remove(a.configPath); removeErr != nil && !os.IsNotExist(removeErr) { err = errors.Join(err, removeErr) } return errors.Join(err, a.runRecovery(a.initPath, "start")) } func ntpDaemonRunning() (bool, error) { entries, err := os.ReadDir("/proc") if err != nil { return false, err } for _, entry := range entries { if _, err := strconv.Atoi(entry.Name()); err != nil { continue } exe, err := os.Readlink(filepath.Join("/proc", entry.Name(), "exe")) if err == nil && filepath.Base(strings.TrimSuffix(exe, " (deleted)")) == "ntpd" { return true, nil } } return false, nil } func (a *OSNTPApplier) waitNTPState(ctx context.Context, desired bool) error { timeout := time.NewTimer(2 * time.Second) defer timeout.Stop() for { running, err := a.running() if err != nil { return err } if running == desired { return nil } select { case <-ctx.Done(): return ctx.Err() case <-timeout.C: return errors.New("NTP daemon did not reach configured state") case <-time.After(50 * time.Millisecond): } } }