Files
nadir-agent/internal/modules/system/timedate.go
T
2026-06-22 16:06:57 +02:00

201 lines
7.0 KiB
Go

package system
import (
"context"
"slices"
"strconv"
"strings"
"time"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
type TimeStatusBody struct {
Timezone string `json:"timezone" example:"Europe/Rome" doc:"IANA timezone name"`
LocalRTC bool `json:"local_rtc" doc:"Hardware clock kept in local time instead of UTC"`
NTP bool `json:"ntp" doc:"Network time synchronization enabled"`
NTPSynchronized bool `json:"ntp_synchronized" doc:"Clock is currently synchronized"`
CanNTP bool `json:"can_ntp" doc:"An NTP service is available on this host"`
Time string `json:"time" example:"2026-06-19T13:36:31Z" doc:"Current system time (RFC3339, UTC)"`
}
type GetTimeOutput struct{ Body TimeStatusBody }
type TimezonesOutput struct {
Body struct {
Timezones []string `json:"timezones" doc:"Available IANA timezone names"`
}
}
type SetTimezoneInput struct {
Body struct {
Timezone string `json:"timezone" example:"Europe/Rome" doc:"IANA timezone name"`
}
}
type SetNTPInput struct {
Body struct {
Enabled bool `json:"enabled" doc:"Enable network time synchronization"`
}
}
type SetTimeInput struct {
Body struct {
Time string `json:"time" example:"2026-06-19T13:36:31Z" doc:"New time (RFC3339). Requires NTP disabled."`
}
}
func timedatectlShow() (map[string]string, error) {
lines, err := oscmd.RunLines("timedatectl", "show")
if err != nil {
return nil, err
}
return oscmd.ParseKV(lines), nil
}
// readTimeStatus builds the current time/timezone/sync status. Shared by the
// GET endpoint and the write endpoints, which return the resulting state so
// clients render ground truth instead of guessing after an opaque "ok". Note
// NTPSynchronized lags NTP by seconds-to-minutes while the NTP daemon converges.
func readTimeStatus() (TimeStatusBody, error) {
m, err := timedatectlShow()
if err != nil {
return TimeStatusBody{}, err
}
return TimeStatusBody{
Timezone: m["Timezone"],
LocalRTC: m["LocalRTC"] == "yes",
NTP: m["NTP"] == "yes",
NTPSynchronized: m["NTPSynchronized"] == "yes",
CanNTP: m["CanNTP"] == "yes",
Time: time.Now().UTC().Format(time.RFC3339),
}, nil
}
func registerTimedate(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "system-get-time",
Method: "GET",
Path: "/api/system/time",
Summary: "Get time, timezone and sync status",
Description: "Returns the current UTC time plus timezone and " +
"synchronization state from timedatectl.",
Tags: []string{tagSystem},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*GetTimeOutput, error) {
body, err := readTimeStatus()
if err != nil {
return nil, huma.Error500InternalServerError("timedatectl failed", err)
}
return &GetTimeOutput{Body: body}, nil
})
huma.Register(api, huma.Operation{
OperationID: "system-list-timezones",
Method: "GET",
Path: "/api/system/timezones",
Summary: "List available timezones",
Description: "Returns every IANA timezone name known to the host, " +
"suitable for populating a selector.",
Tags: []string{tagSystem},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*TimezonesOutput, error) {
zones, err := oscmd.RunLines("timedatectl", "list-timezones")
if err != nil {
return nil, huma.Error500InternalServerError("timedatectl failed", err)
}
out := &TimezonesOutput{}
out.Body.Timezones = zones
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "system-set-timezone",
Method: "POST",
Path: "/api/system/timezone",
Summary: "Set system timezone",
Description: "Sets the timezone via timedatectl. The value is validated " +
"against the host's timezone list, so an unknown name returns 400.",
Tags: []string{tagSystem},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *SetTimezoneInput) (*oscmd.StatusOutput, error) {
tz := strings.TrimSpace(in.Body.Timezone)
if tz == "" {
return nil, huma.Error400BadRequest("empty timezone")
}
zones, err := oscmd.RunLines("timedatectl", "list-timezones")
if err != nil {
return nil, huma.Error500InternalServerError("timedatectl failed", err)
}
if !slices.Contains(zones, tz) {
return nil, huma.Error400BadRequest("unknown timezone: " + tz)
}
if _, err := oscmd.Run("timedatectl", "set-timezone", tz); err != nil {
return nil, huma.Error500InternalServerError("set-timezone failed", err)
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "system-set-ntp",
Method: "POST",
Path: "/api/system/ntp",
Summary: "Enable or disable time synchronization",
Description: "Toggles network time synchronization via " +
"`timedatectl set-ntp`. Selecting specific NTP servers is not yet " +
"supported. Returns the resulting time status: on enable, `ntp` is " +
"true immediately, but `ntp_synchronized` stays false until the NTP " +
"daemon converges (seconds to minutes).",
Tags: []string{tagSystem},
Metadata: op("write"),
Errors: []int{400, 401, 403, 409, 500},
}, func(ctx context.Context, in *SetNTPInput) (*GetTimeOutput, error) {
// Enabling is a no-op when no NTP daemon is installed (CanNTP=no):
// timedatectl reports success but nothing syncs. Reject it clearly.
if in.Body.Enabled {
if m, err := timedatectlShow(); err == nil && m["CanNTP"] != "yes" {
return nil, huma.Error409Conflict("cannot enable NTP: no NTP service available on this host")
}
}
val := strconv.FormatBool(in.Body.Enabled)
if _, err := oscmd.Run("timedatectl", "set-ntp", val); err != nil {
return nil, huma.Error500InternalServerError("set-ntp failed", err)
}
body, err := readTimeStatus()
if err != nil {
return nil, huma.Error500InternalServerError("set-ntp succeeded but reading status failed", err)
}
return &GetTimeOutput{Body: body}, nil
})
huma.Register(api, huma.Operation{
OperationID: "system-set-time",
Method: "POST",
Path: "/api/system/time",
Summary: "Set system time manually",
Description: "Sets the clock to an explicit RFC3339 time. This only works " +
"when NTP is disabled; otherwise timedatectl refuses and the call " +
"returns 409.",
Tags: []string{tagSystem},
Metadata: op("write"),
Errors: []int{400, 401, 403, 409, 500},
}, func(ctx context.Context, in *SetTimeInput) (*oscmd.StatusOutput, error) {
t, err := time.Parse(time.RFC3339, strings.TrimSpace(in.Body.Time))
if err != nil {
return nil, huma.Error400BadRequest("time must be RFC3339", err)
}
// timedatectl set-time interprets its argument as local wall-clock time.
stamp := t.Local().Format("2006-01-02 15:04:05")
if _, err := oscmd.Run("timedatectl", "set-time", stamp); err != nil {
// timedatectl refuses while NTP is active; make that actionable.
return nil, huma.Error409Conflict("set-time failed (disable NTP first?)", err)
}
return oscmd.OK(), nil
})
}