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