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