// Package systemtime owns the narrow privileged boundary for changing the // product clock and timezone. HTTP handlers must not mutate either directly. package systemtime import ( "context" "errors" "fmt" "strings" "sync" "time" ) const ( minimumYear = 2000 maximumYear = 2100 ) type Request struct { BrowserTime time.Time `json:"browser_time"` Timezone string `json:"timezone"` } type Result struct { AppliedTime time.Time `json:"applied_time"` Timezone string `json:"timezone"` } type NTPRequest struct { Servers []string `json:"servers"` Timezone string `json:"timezone"` } type NTPResult struct { AppliedTime time.Time `json:"applied_time"` Timezone string `json:"timezone"` Servers []string `json:"servers"` } type Controller interface { Sync(context.Context, Request) (Result, error) } type NTPController interface { SyncNTP(context.Context, NTPRequest) (NTPResult, error) } // Applier performs the two privileged mutations. ApplyTimezone must return a // rollback function that restores the exact pre-call timezone state. type Applier interface { ApplyTimezone(context.Context, string) (func() error, error) SetTime(context.Context, time.Time) error } // NTPApplier performs a bounded one-shot synchronization and leaves the // configured daemon running for ongoing clock discipline. type NTPApplier interface { SyncNTP(context.Context, []string) (time.Time, error) } type TransactionalController struct { applier Applier ntpApplier NTPApplier mu sync.Mutex } func NewTransactionalController(applier Applier, ntpAppliers ...NTPApplier) *TransactionalController { controller := &TransactionalController{applier: applier} if len(ntpAppliers) > 0 { controller.ntpApplier = ntpAppliers[0] } return controller } func (c *TransactionalController) Sync(ctx context.Context, request Request) (Result, error) { normalized, err := NormalizeRequest(request) if err != nil { return Result{}, err } if c == nil || c.applier == nil { return Result{}, &UnavailableError{Reason: "tera-timed has no system time applier"} } // Clock/timezone changes are a single-device transaction and must not // interleave when multiple management clients submit a sync concurrently. c.mu.Lock() defer c.mu.Unlock() if err := ctx.Err(); err != nil { return Result{}, err } rollback, err := c.applier.ApplyTimezone(ctx, normalized.Timezone) if err != nil { return Result{}, err } if rollback == nil { return Result{}, errors.New("system time applier returned no timezone rollback") } if err := c.applier.SetTime(ctx, normalized.BrowserTime); err != nil { if rollbackErr := rollback(); rollbackErr != nil { return Result{}, fmt.Errorf("set system time: %v; restore timezone: %w", err, rollbackErr) } return Result{}, fmt.Errorf("set system time: %w", err) } return Result{AppliedTime: normalized.BrowserTime.UTC(), Timezone: normalized.Timezone}, nil } func (c *TransactionalController) SyncNTP(ctx context.Context, request NTPRequest) (NTPResult, error) { normalized, err := NormalizeNTPRequest(request) if err != nil { return NTPResult{}, err } if c == nil || c.applier == nil || c.ntpApplier == nil { return NTPResult{}, &UnavailableError{Reason: "tera-timed has no NTP applier"} } c.mu.Lock() defer c.mu.Unlock() if err := ctx.Err(); err != nil { return NTPResult{}, err } rollback, err := c.applier.ApplyTimezone(ctx, normalized.Timezone) if err != nil { return NTPResult{}, err } if rollback == nil { return NTPResult{}, errors.New("system time applier returned no timezone rollback") } appliedTime, err := c.ntpApplier.SyncNTP(ctx, normalized.Servers) if err != nil { if rollbackErr := rollback(); rollbackErr != nil { return NTPResult{}, fmt.Errorf("synchronize NTP: %v; restore timezone: %w", err, rollbackErr) } return NTPResult{}, fmt.Errorf("synchronize NTP: %w", err) } return NTPResult{ AppliedTime: appliedTime.UTC(), Timezone: normalized.Timezone, Servers: append([]string(nil), normalized.Servers...), }, nil } func NormalizeRequest(request Request) (Request, error) { if request.BrowserTime.IsZero() { return Request{}, errors.New("browser_time must be an RFC3339 timestamp") } utc := request.BrowserTime.UTC() if utc.Year() < minimumYear || utc.Year() > maximumYear { return Request{}, fmt.Errorf("browser_time year must be between %d and %d", minimumYear, maximumYear) } timezone, err := NormalizeTimezone(request.Timezone) if err != nil { return Request{}, err } return Request{BrowserTime: utc, Timezone: timezone}, nil } func NormalizeNTPRequest(request NTPRequest) (NTPRequest, error) { if len(request.Servers) == 0 { return NTPRequest{}, errors.New("NTP servers must include at least one server") } if len(request.Servers) > 4 { return NTPRequest{}, errors.New("NTP servers must include at most four servers") } servers := make([]string, 0, len(request.Servers)) seen := make(map[string]bool, len(request.Servers)) for _, server := range request.Servers { value := strings.ToLower(strings.TrimSpace(server)) if value == "" || len(value) > 128 { return NTPRequest{}, errors.New("NTP server must contain 1 to 128 characters") } for _, char := range value { if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '.' || char == '-' || char == '_' || char == ':' { continue } return NTPRequest{}, errors.New("NTP server contains invalid characters") } if !seen[value] { servers = append(servers, value) seen[value] = true } } timezone, err := NormalizeTimezone(request.Timezone) if err != nil { return NTPRequest{}, err } return NTPRequest{Servers: servers, Timezone: timezone}, nil } func NormalizeTimezone(value string) (string, error) { value = strings.TrimSpace(value) if value == "" { value = "UTC" } if len(value) > 64 { return "", errors.New("timezone must be 64 characters or fewer") } if strings.HasPrefix(value, "/") || strings.HasSuffix(value, "/") || strings.Contains(value, "\\") { return "", errors.New("timezone must be a relative IANA timezone name") } for _, segment := range strings.Split(value, "/") { if segment == "" || segment == "." || segment == ".." { return "", errors.New("timezone must not contain relative path segments") } for _, char := range segment { if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '+' { continue } return "", errors.New("timezone contains invalid characters") } } return value, nil } type MockController struct{} func (MockController) Sync(_ context.Context, request Request) (Result, error) { normalized, err := NormalizeRequest(request) if err != nil { return Result{}, err } return Result{AppliedTime: normalized.BrowserTime, Timezone: normalized.Timezone}, nil } func (MockController) SyncNTP(_ context.Context, request NTPRequest) (NTPResult, error) { normalized, err := NormalizeNTPRequest(request) if err != nil { return NTPResult{}, err } return NTPResult{ AppliedTime: time.Date(2026, 7, 30, 6, 0, 0, 0, time.UTC), Timezone: normalized.Timezone, Servers: append([]string(nil), normalized.Servers...), }, nil } type UnavailableController struct{ Reason string } func (c UnavailableController) Sync(context.Context, Request) (Result, error) { reason := c.Reason if reason == "" { reason = "tera-timed is not configured" } return Result{}, &UnavailableError{Reason: reason} } func (c UnavailableController) SyncNTP(context.Context, NTPRequest) (NTPResult, error) { reason := c.Reason if reason == "" { reason = "tera-timed is not configured" } return NTPResult{}, &UnavailableError{Reason: reason} } type UnavailableError struct{ Reason string } func (e *UnavailableError) Error() string { if e == nil || e.Reason == "" { return "system time service is unavailable" } return e.Reason } func IsUnavailable(err error) bool { var unavailable *UnavailableError return errors.As(err, &unavailable) }