You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

266 lines
7.9 KiB

// 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)
}