feat(webui): sync host clock and timezone from browser

Adds POST /api/system/time-sync so the dashboard's "Sync with this
browser" button can set the host's date, time, and IANA timezone from
the client's browser — useful for appliances with no NTP/network
access to keep their own clock straight. Disables NTP before writing
the manual time so it doesn't get immediately overwritten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-06 13:43:31 +03:00
co-authored by Claude Sonnet 5
parent b2b3f86c8d
commit 3ec7ca08da
2 changed files with 56 additions and 0 deletions
+55
View File
@@ -1483,6 +1483,61 @@ func (h *handler) handleAPISystemShutdown(w http.ResponseWriter, r *http.Request
writeJSON(w, map[string]string{"status": "shutting down"})
}
// timezoneNameRE matches IANA timezone identifiers like "Europe/Moscow" or "UTC".
var timezoneNameRE = regexp.MustCompile(`^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$`)
func validTimezoneName(tz string) bool {
if tz == "" || !timezoneNameRE.MatchString(tz) {
return false
}
_, err := os.Stat(filepath.Join("/usr/share/zoneinfo", tz))
return err == nil
}
// handleAPISystemTimeSync sets the host's timezone and wall-clock time from
// values supplied by the client's browser (used when the appliance has no
// network/NTP access to keep its own clock in sync).
func (h *handler) handleAPISystemTimeSync(w http.ResponseWriter, r *http.Request) {
var req struct {
Timezone string `json:"timezone"`
EpochMS int64 `json:"epoch_ms"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.EpochMS <= 0 {
writeError(w, http.StatusBadRequest, "epoch_ms required")
return
}
var out strings.Builder
if req.Timezone != "" {
if !validTimezoneName(req.Timezone) {
writeError(w, http.StatusBadRequest, "invalid timezone")
return
}
if b, err := exec.Command("timedatectl", "set-timezone", req.Timezone).CombinedOutput(); err != nil {
writeError(w, http.StatusInternalServerError, "set-timezone failed: "+strings.TrimSpace(string(b)))
return
}
fmt.Fprintf(&out, "timezone set to %s\n", req.Timezone)
}
// Manual time only sticks if NTP sync is off.
_ = exec.Command("timedatectl", "set-ntp", "false").Run()
sec := req.EpochMS / 1000
if b, err := exec.Command("date", "-u", "-s", "@"+strconv.FormatInt(sec, 10)).CombinedOutput(); err != nil {
writeError(w, http.StatusInternalServerError, "set-time failed: "+strings.TrimSpace(string(b)))
return
}
out.WriteString("system clock synced\n")
writeJSON(w, map[string]string{"status": "ok", "output": out.String()})
}
// ── Tools ─────────────────────────────────────────────────────────────────────
var standardTools = []string{
+1
View File
@@ -346,6 +346,7 @@ func NewHandler(opts HandlerOptions) http.Handler {
mux.HandleFunc("POST /api/system/install-to-ram", h.handleAPIInstallToRAM)
mux.HandleFunc("POST /api/system/reboot", h.handleAPISystemReboot)
mux.HandleFunc("POST /api/system/shutdown", h.handleAPISystemShutdown)
mux.HandleFunc("POST /api/system/time-sync", h.handleAPISystemTimeSync)
// Preflight
mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight)