From 219ed656a2be2705f110ffdc6936aa8ee0eaf103 Mon Sep 17 00:00:00 2001 From: fontvielle Date: Wed, 21 Oct 2026 23:17:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=80=D0=B5=D0=BC=D1=8F=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D0=B0:=20=D1=87=D0=B0=D1=81=D1=8B,=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=8F=D1=81=20=D0=B8=20=C2=AB=D0=A1=D0=B8=D0=BD?= =?UTF-8?q?=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=81=D0=B5=D0=B9=D1=87=D0=B0=D1=81=C2=BB?= =?UTF-8?q?=20=D0=B2=20=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D0=B5=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0=20=C2=AB=D0=A1=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - живые часы сервера (сверка раз в 30 с), пояс, синхронизация, источник времени и расхождение с ним, сверка с часами компьютера администратора - выбор часового пояса прямо на странице (раньше — только в мастере) - «Синхронизировать сейчас»: bj-system-admin sync — разовая коррекция часов (chrony makestep, ntpd -gq, systemd-timesyncd); серверов нет или не отвечают, а свои не заданы — bj подбирает ВНИИФТРИ - мастер, шаг 12: кнопка «Синхронизировать» — та же синхронизация Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn --- deploy/linux/sbin/bj-keymedia-worker | 2 +- deploy/linux/sbin/bj-system-admin | 27 +++ internal/lkgateway/admin.go | 1 + internal/lkgateway/timesync.go | 173 +++++++++++++++--- .../lkgateway/web/templates/admin_setup.html | 63 ++++++- .../lkgateway/web/templates/admin_wizard.html | 2 +- 6 files changed, 232 insertions(+), 36 deletions(-) diff --git a/deploy/linux/sbin/bj-keymedia-worker b/deploy/linux/sbin/bj-keymedia-worker index 6d07c76..a474518 100755 --- a/deploy/linux/sbin/bj-keymedia-worker +++ b/deploy/linux/sbin/bj-keymedia-worker @@ -16,7 +16,7 @@ process_one(){ # датчик случайных чисел Валидаты: проверка и сеанс «двигайте мышью» (движения — из мастера bj) rng-status|rng-start|rng-cancel) out=$(/usr/local/sbin/bj-validata-rng "${action#rng-}" 2>&1) ;; # системные настройки сервера: серверы времени (NTP) - system-ntp|system-tz) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-system-admin "${action#system-}" 2>&1) ;; + system-ntp|system-tz|system-sync) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-system-admin "${action#system-}" 2>&1) ;; # резервное копирование и восстановление (настройки bj → bj-backup-admin); параметры — JSON в extra backup-config|backup-test|backup-run|backup-list|backup-verify|backup-restore) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-backup-admin "${action#backup-}" 2>&1) ;; # Перезапуск службы модуля из админки. Список закрытый: чужую службу diff --git a/deploy/linux/sbin/bj-system-admin b/deploy/linux/sbin/bj-system-admin index 5fa39e4..8e2c5cd 100755 --- a/deploy/linux/sbin/bj-system-admin +++ b/deploy/linux/sbin/bj-system-admin @@ -3,6 +3,7 @@ # tz — часовой пояс сервера (BJ_EXTRA: "Europe/Moscow") # ntp — серверы времени (BJ_EXTRA: "ntp1.example.ru ntp2.example.ru"): ntpd, chrony или systemd-timesyncd — # что стоит на сервере. Время важно для подписей и обмена с НРД. +# sync — поставить часы сейчас по серверам службы времени (не ждать её плавной подстройки) set -uo pipefail q() { python3 -c "import json,sys;print(json.dumps(sys.argv[1],ensure_ascii=False))" "$1"; } ok() { echo "{\"ok\":true,\"output\":$(q "$1")}"; } @@ -43,6 +44,32 @@ PY systemctl restart systemd-timesyncd || err "служба systemd-timesyncd не перезапустилась" ok "серверы времени: $servers (служба systemd-timesyncd)" fi ;; + sync) + synced() { hwclock --systohc >/dev/null 2>&1; ok "$1"; exit 0; } + if systemctl is-active --quiet chrony || systemctl is-active --quiet chronyd; then + chronyc -n sources 2>/dev/null | grep -q '^\^' || err "серверы времени не заданы" + chronyc -a 'burst 4/4' >/dev/null 2>&1; sleep 8 + chronyc -a makestep >/dev/null 2>&1 || err "chrony не выполнил коррекцию часов" + sleep 1; synced "время синхронизировано (chrony, расхождение $(chronyc -n tracking | sed -n 's/^System time *: //p'))" + elif systemctl is-active --quiet ntp || systemctl is-active --quiet ntpd || { [ -f /etc/ntp.conf ] && ! systemctl is-active --quiet systemd-timesyncd; }; then + srv=$(awk '/^(server|pool) / && !seen[$2]++ {print $2}' /etc/ntp.conf 2>/dev/null | xargs) + [ -n "$srv" ] || err "серверы времени не заданы" + command -v ntpd >/dev/null || err "нет программы ntpd" + svc=ntp; systemctl is-active --quiet ntpd && svc=ntpd + systemctl stop "$svc" >/dev/null 2>&1 + out=$(timeout 45 ntpd -gq 2>&1); rc=$? + systemctl start "$svc" >/dev/null 2>&1 + [ $rc -eq 0 ] || err "серверы времени ($srv) не ответили за 45 с" + synced "время синхронизировано (ntpd, серверы $srv)" + else + timedatectl set-ntp true >/dev/null 2>&1 + systemctl restart systemd-timesyncd || err "служба systemd-timesyncd не перезапустилась" + for _ in $(seq 1 15); do + [ "$(timedatectl show -p NTPSynchronized --value)" = yes ] && synced "время синхронизировано (systemd-timesyncd, сервер $(timedatectl show-timesync -p ServerName --value 2>/dev/null))" + sleep 2 + done + err "служба времени не получила время за 30 с — серверы времени недоступны" + fi ;; tz) zone=$(printf '%s' "${BJ_EXTRA:-}" | tr -d ' ') [[ "$zone" =~ ^[A-Za-z]+(/[A-Za-z_+-]+)*$ ]] && [ -f "/usr/share/zoneinfo/$zone" ] || err "неизвестный часовой пояс: $zone" diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index d001985..e8a7dc2 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -170,6 +170,7 @@ var templateFuncs = template.FuncMap{ "mailtoLink": mailtoLink, "handbookDepos": handbookDepos, "timeZones": func() any { return wizTimeZones }, + "timeZoneKnown": timeZoneKnown, "intList": func(v ...int) []int { return v }, "nsdSegTip": nsdSegTip, "devOn": authTemplateFuncs["devOn"], diff --git a/internal/lkgateway/timesync.go b/internal/lkgateway/timesync.go index bc8d2cc..f869c8b 100644 --- a/internal/lkgateway/timesync.go +++ b/internal/lkgateway/timesync.go @@ -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) { diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 7a15131..15b9db9 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -958,6 +958,60 @@ {{/* ============ РЕЗЕРВНОЕ КОПИРОВАНИЕ ============ */}}

Сервер: HTTPS, время, сеть

+ {{$tz := serverTimeZone}}{{$sync := timeSynced}} +
+

Время сервера

+
+
--:--:--
+
{{$tz}}
+
+
{{if $sync}}✓ время синхронизируется{{else}}⚠ время не синхронизируется{{end}}
+
+
+
+
+
+
+ + +
+
+ + +
+
+

Точное время нужно для подписей и обмена с НРД (время НРД — московское). «Синхронизировать сейчас» сразу ставит часы по серверам времени; если свои серверы не заданы или не отвечают — bj подберёт российские серверы ВНИИФТРИ.

+
Серверы времени{{if .Settings.Time.Servers}}: {{.Settings.Time.Servers}}{{end}} +

В закрытом контуре внешние серверы времени недоступны — укажите сервер времени вашей сети (обычно контроллер домена или маршрутизатор).

+
+ + + +
+
+ +
{{$h := httpsCert}}

HTTPS — шифрование входа в bj

@@ -982,15 +1036,6 @@ {{template "workHoursForm" (whForm .Settings.WorkHours "")}}
-
-

Время сервера

-

Точное время нужно для подписей и обмена с НРД. В закрытом контуре внешние серверы времени недоступны — укажите сервер времени вашей сети (обычно контроллер домена или маршрутизатор).

-

{{if timeSynced}}✓ время синхронизируется{{else}}⚠ время не синхронизируется{{end}}{{if .Settings.Time.Servers}} · серверы bj: {{.Settings.Time.Servers}}{{end}}

-
- - -
-

Сеть

Сервер: {{serverAddrs}}

diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index ddd41e5..642c254 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -563,7 +563,7 @@
Точное время {{if timeSynced}}✓ синхронизируется{{else}}⚠ не синхронизируется -
+
{{end}}