Время сервера: часы, пояс и «Синхронизировать сейчас» в начале раздела «Сервер»

- живые часы сервера (сверка раз в 30 с), пояс, синхронизация, источник времени
  и расхождение с ним, сверка с часами компьютера администратора
- выбор часового пояса прямо на странице (раньше — только в мастере)
- «Синхронизировать сейчас»: bj-system-admin sync — разовая коррекция часов
  (chrony makestep, ntpd -gq, systemd-timesyncd); серверов нет или не отвечают,
  а свои не заданы — bj подбирает ВНИИФТРИ
- мастер, шаг 12: кнопка «Синхронизировать» — та же синхронизация

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
This commit is contained in:
fontvielle
2026-10-21 23:17:44 +03:00
parent c9ab60130f
commit 219ed656a2
6 changed files with 232 additions and 36 deletions
+148 -25
View File
@@ -6,10 +6,12 @@ package lkgateway
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
@@ -65,38 +67,151 @@ func registerTime(mux *http.ServeMux, rc *RuntimeConfig) {
setupFlash(w, r, "Время уже синхронизируется.")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
var reachable []string
for _, srv := range ruTimeServers {
if ntpReachable(ctx, srv) {
reachable = append(reachable, srv)
if len(reachable) >= 4 {
break
}
}
}
if len(reachable) == 0 {
setupFlash(w, r, "Из вашей сети российские серверы точного времени (ВНИИФТРИ) недоступны. "+
"Укажите сервер времени вашей сети — обычно контроллер домена или маршрутизатор.")
servers := pickRuTimeServers(r.Context())
if servers == "" {
setupFlash(w, r, ruTimeUnreachable)
return
}
opctx, opcancel := context.WithTimeout(r.Context(), time.Minute)
defer opcancel()
servers := strings.Join(reachable, " ")
res, err := runKeymediaOp(opctx, "system-ntp", map[string]string{"extra": servers})
if err != nil {
if err := applyTimeServers(r.Context(), rc, servers); err != nil {
setupFlash(w, r, "Время: "+err.Error())
return
}
if res["ok"] != true {
setupFlash(w, r, "Время: "+pickStr(asString(res["error"]), "не применено"))
return
}
_ = rc.UpdateTime(TimeSettings{Servers: servers, SavedAt: time.Now()})
setupFlash(w, r, "Нашёл доступные российские серверы времени (ВНИИФТРИ): "+servers+
". Синхронизирую — займёт до нескольких минут.")
})
// time-sync — «Синхронизировать сейчас»: часы сразу ставятся по серверам времени (не ждать, пока
// служба подтянет их сама). Серверов нет или не отвечают, а свои в bj не заданы — bj подбирает ВНИИФТРИ.
mux.HandleFunc("/admin/setup/time-sync", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
msg, err := syncTimeNow(ctx)
if err != nil && strings.TrimSpace(rc.Snapshot().Time.Servers) == "" {
servers := pickRuTimeServers(ctx)
if servers == "" {
setupFlash(w, r, "Время не синхронизировано: "+err.Error()+". "+ruTimeUnreachable)
return
}
if aerr := applyTimeServers(ctx, rc, servers); aerr != nil {
setupFlash(w, r, "Время: "+aerr.Error())
return
}
msg, err = syncTimeNow(ctx)
if err == nil {
msg += "; серверы времени bj: " + servers + " (ВНИИФТРИ)"
}
}
if err != nil {
setupFlash(w, r, "Время не синхронизировано: "+err.Error())
return
}
setupFlash(w, r, msg+". Сейчас на сервере "+time.Now().Format("15:04:05")+".")
})
// time-now — часы сервера для страницы: время, пояс, синхронизация, источник и его расхождение.
mux.HandleFunc("/admin/setup/time-now", func(w http.ResponseWriter, r *http.Request) {
now := time.Now()
abbr, off := now.Zone()
synced, _ := timeStatus(r.Context())
src, srcOff := timeSource(r.Context())
jsonOut(w, map[string]any{"epoch_ms": now.UnixMilli(), "zone": serverTimeZone(), "abbr": abbr, "offset": off,
"synced": synced, "source": src, "source_offset": srcOff})
})
}
const ruTimeUnreachable = "Из вашей сети российские серверы точного времени (ВНИИФТРИ) недоступны. " +
"Укажите сервер времени вашей сети — обычно контроллер домена или маршрутизатор."
// pickRuTimeServers — до 4 отвечающих серверов ВНИИФТРИ через пробел; "" — ни один не ответил.
func pickRuTimeServers(ctx context.Context) string {
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
var reachable []string
for _, srv := range ruTimeServers {
if ntpReachable(ctx, srv) {
reachable = append(reachable, srv)
if len(reachable) >= 4 {
break
}
}
}
return strings.Join(reachable, " ")
}
// applyTimeServers — прописать серверы времени в службу сервера и запомнить в настройках bj.
func applyTimeServers(ctx context.Context, rc *RuntimeConfig, servers string) error {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
res, err := runKeymediaOp(ctx, "system-ntp", map[string]string{"extra": servers})
if err != nil {
return err
}
if res["ok"] != true {
return fmt.Errorf("%s", pickStr(asString(res["error"]), "не применено"))
}
return rc.UpdateTime(TimeSettings{Servers: servers, SavedAt: time.Now()})
}
// syncTimeNow — разовая коррекция часов по серверам службы времени (root-скрипт bj-system-admin sync).
func syncTimeNow(ctx context.Context) (string, error) {
res, err := runKeymediaOp(ctx, "system-sync", nil)
if err != nil {
return "", err
}
if res["ok"] != true {
return "", fmt.Errorf("%s", pickStr(asString(res["error"]), "не выполнено"))
}
return asString(res["output"]), nil
}
// timeSource — с каким сервером сейчас сверяются часы и насколько они расходятся (chrony, ntpd,
// systemd-timesyncd — что отвечает). Пусто — служба не сообщает.
func timeSource(ctx context.Context) (src, offset string) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if out, err := exec.CommandContext(ctx, "chronyc", "-n", "tracking").Output(); err == nil {
for _, l := range strings.Split(string(out), "\n") {
k, v, ok := strings.Cut(l, ":")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "Reference ID":
if i, j := strings.Index(v, "("), strings.Index(v, ")"); i >= 0 && j > i {
src = v[i+1 : j]
}
case "System time":
f := strings.Fields(v)
if len(f) >= 3 {
if x, err := strconv.ParseFloat(f[0], 64); err == nil {
offset = fmt.Sprintf("%.1f мс", x*1000)
if f[2] == "slow" {
offset = "−" + offset
}
}
}
}
}
if src != "" {
return src, offset
}
}
if out, err := exec.CommandContext(ctx, "ntpq", "-pn").Output(); err == nil {
for _, l := range strings.Split(string(out), "\n") {
if f := strings.Fields(l); len(f) >= 9 && strings.HasPrefix(f[0], "*") {
return f[0][1:], f[8] + " мс"
}
}
return "", ""
}
if out, err := exec.CommandContext(ctx, "timedatectl", "show-timesync", "-p", "ServerName", "--value").Output(); err == nil {
return strings.TrimSpace(string(out)), ""
}
return "", ""
}
func asString(v any) string {
@@ -122,6 +237,15 @@ var wizTimeZones = []struct{ Zone, Title string }{
{"Asia/Kamchatka", "Камчатка (UTC+12)"},
}
func timeZoneKnown(z string) bool {
for _, x := range wizTimeZones {
if x.Zone == z {
return true
}
}
return false
}
// serverTimeZone — часовой пояс сервера (timedatectl; запасной — /etc/timezone).
func serverTimeZone() string {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
@@ -244,7 +368,6 @@ func validHostname(h string) bool {
return true
}
// registerHostname — POST /admin/setup/hostname: доменные имена сервера для сертификата HTTPS.
// bj вписывает их в самоподписанный сертификат (DNS-записи заводит администратор сети отдельно).
func registerHostname(mux *http.ServeMux, rc *RuntimeConfig) {