201 lines
7.0 KiB
Go
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
|
|
})
|
|
}
|