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.
 
 
 
 
 
 

160 lines
4.7 KiB

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