Files
Bridge-and-Join-s/internal/lkgateway/timesync.go
T
fontvielle 219ed656a2 Время сервера: часы, пояс и «Синхронизировать сейчас» в начале раздела «Сервер»
- живые часы сервера (сверка раз в 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
2026-10-21 23:17:44 +03:00

423 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// Сервер времени (NTP) — из веб-интерфейса (05.10.2026). В закрытом контуре внешние серверы времени
// недоступны и часы уплывают, а время важно для подписей и обмена с НРД. Меняет root-скрипт
// bj-system-admin (ntpd, chrony или systemd-timesyncd — что стоит на сервере).
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// TimeSettings — серверы времени, заданные в bj.
type TimeSettings struct {
Servers string `json:"servers,omitempty"`
SavedAt time.Time `json:"saved_at,omitempty"`
}
// timeStatus — синхронизируются ли часы и текущее время сервера.
func timeStatus(ctx context.Context) (synced bool, now string) {
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(cctx, "timedatectl", "show", "-p", "NTPSynchronized", "--value").Output()
return err == nil && strings.TrimSpace(string(out)) == "yes", time.Now().Format("02.01.2006 15:04:05 MST")
}
func registerTime(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/setup/time", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
servers := strings.Join(strings.Fields(strings.NewReplacer(",", " ", ";", " ").Replace(r.FormValue("servers"))), " ")
if servers == "" {
setupFlash(w, r, "Время: укажите сервер времени (NTP) — адрес или имя")
return
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
res, err := runKeymediaOp(ctx, "system-ntp", map[string]string{"extra": servers})
if 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, "Время: "+asString(res["output"])+". Синхронизация займёт до нескольких минут.")
})
// time-auto — bj сам подбирает доступный сервер точного времени: сперва российские серверы
// ВНИИФТРИ; если из сети они недоступны — просит указать свой (контроллер домена/маршрутизатор).
mux.HandleFunc("/admin/setup/time-auto", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if ok, _ := timeStatus(r.Context()); ok {
setupFlash(w, r, "Время уже синхронизируется.")
return
}
servers := pickRuTimeServers(r.Context())
if servers == "" {
setupFlash(w, r, ruTimeUnreachable)
return
}
if err := applyTimeServers(r.Context(), rc, servers); err != nil {
setupFlash(w, r, "Время: "+err.Error())
return
}
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 {
if s, ok := v.(string); ok {
return s
}
return ""
}
// wizTimeZones — часовые пояса России для выбора (время НРД — московское).
var wizTimeZones = []struct{ Zone, Title string }{
{"Europe/Kaliningrad", "Калининград (UTC+2)"},
{"Europe/Moscow", "Москва (UTC+3)"},
{"Europe/Samara", "Самара (UTC+4)"},
{"Asia/Yekaterinburg", "Екатеринбург (UTC+5)"},
{"Asia/Omsk", "Омск (UTC+6)"},
{"Asia/Novosibirsk", "Новосибирск (UTC+7)"},
{"Asia/Krasnoyarsk", "Красноярск (UTC+7)"},
{"Asia/Irkutsk", "Иркутск (UTC+8)"},
{"Asia/Yakutsk", "Якутск (UTC+9)"},
{"Asia/Vladivostok", "Владивосток (UTC+10)"},
{"Asia/Magadan", "Магадан (UTC+11)"},
{"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)
defer cancel()
if out, err := exec.CommandContext(ctx, "timedatectl", "show", "-p", "Timezone", "--value").Output(); err == nil {
if z := strings.TrimSpace(string(out)); z != "" {
return z
}
}
if b, err := os.ReadFile("/etc/timezone"); err == nil {
return strings.TrimSpace(string(b))
}
return time.Local.String()
}
func registerTimeZone(mux *http.ServeMux) {
mux.HandleFunc("/admin/setup/timezone", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
zone := strings.TrimSpace(r.FormValue("zone"))
known := false
for _, z := range wizTimeZones {
if z.Zone == zone {
known = true
}
}
if !known {
setupFlash(w, r, "Часовой пояс: выберите из списка")
return
}
if zone == serverTimeZone() {
setupFlash(w, r, "Часовой пояс уже "+zone)
return
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
res, err := runKeymediaOp(ctx, "system-tz", map[string]string{"extra": zone})
if err != nil || res["ok"] != true {
setupFlash(w, r, "Часовой пояс не изменён: "+pickStr(asString(res["error"]), errStr(err)))
return
}
// время процесса bj берётся при запуске — перезапускаем, чтобы журналы и сроки шли по новому поясу
go func() {
time.Sleep(2 * time.Second)
_, _ = restartUnit(context.Background(), "bj-server")
}()
setupFlash(w, r, asString(res["output"])+". bj перезапускается — обновите страницу через 15 секунд. Шлюз НРД перейдёт на новый пояс при своём следующем перезапуске.")
})
}
// ruTimeServers — государственные серверы точного времени РФ (ВНИИФТРИ) и запасные.
// Пробуем их, когда штатные/внутренние источники недоступны (закрытый контур).
var ruTimeServers = []string{
"ntp1.vniiftri.ru", "ntp2.vniiftri.ru", "ntp3.vniiftri.ru", "ntp4.vniiftri.ru",
"ntp1.niiftri.irkutsk.ru", "ntp2.niiftri.irkutsk.ru",
"vniiftri.khv.ru", "ntp.ix.ru",
}
// ntpReachable — отвечает ли сервер точного времени (мини-SNTP запрос по UDP/123). Только проверка
// доступности из сети сервера — часы не меняет.
func ntpReachable(ctx context.Context, host string) bool {
addr := host
if _, _, err := net.SplitHostPort(host); err != nil {
addr = net.JoinHostPort(host, "123")
}
d := net.Dialer{Timeout: 2 * time.Second}
conn, err := d.DialContext(ctx, "udp", addr)
if err != nil {
return false
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
req := make([]byte, 48)
req[0] = 0x23 // LI=0, VN=4, Mode=3 (клиент)
if _, err := conn.Write(req); err != nil {
return false
}
resp := make([]byte, 48)
n, err := conn.Read(resp)
if err != nil || n < 48 {
return false
}
mode := resp[0] & 0x07
txNonZero := false
for _, b := range resp[40:48] {
if b != 0 {
txNonZero = true
break
}
}
return mode == 4 && txNonZero // ответ сервера (mode=4) с ненулевой меткой времени
}
// customHostnames — доменные имена сервера, заданные администратором (для сертификата HTTPS).
func customHostnames() []string {
if globalRC == nil {
return nil
}
return globalRC.Snapshot().HTTPS.Hostnames
}
// validHostname — простая проверка доменного имени (метки a-z0-9-, без ведущего/замыкающего дефиса).
func validHostname(h string) bool {
if h == "" || len(h) > 253 {
return false
}
for _, label := range strings.Split(h, ".") {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
return false
}
}
}
return true
}
// registerHostname — POST /admin/setup/hostname: доменные имена сервера для сертификата HTTPS.
// bj вписывает их в самоподписанный сертификат (DNS-записи заводит администратор сети отдельно).
func registerHostname(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/setup/hostname", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
raw := strings.NewReplacer(",", " ", ";", " ", "\n", " ", "\r", " ").Replace(r.FormValue("hostnames"))
var names []string
for _, f := range strings.Fields(raw) {
f = strings.ToLower(strings.TrimSpace(f))
f = strings.TrimPrefix(f, "https://")
f = strings.TrimPrefix(f, "http://")
if i := strings.IndexByte(f, '/'); i >= 0 {
f = f[:i]
}
if h, _, err := net.SplitHostPort(f); err == nil {
f = h
}
if f == "" {
continue
}
if !validHostname(f) {
setupFlash(w, r, "Доменное имя «"+f+"» задано неверно. Пример: m2m.corp.example.ru")
return
}
names = append(names, f)
}
names = uniqStrings(names)
h := rc.Snapshot().HTTPS
h.Hostnames = names
if err := rc.UpdateHTTPS(h); err != nil {
setupFlash(w, r, "Имена сервера: "+err.Error())
return
}
if h.Custom {
setupFlash(w, r, "Имена сохранены. Сейчас используется загруженный сертификат организации — проверьте, что эти имена есть в нём.")
return
}
if err := ensureSelfSigned(true); err != nil {
setupFlash(w, r, "Имена сохранены, но сертификат не перевыпущен: "+err.Error())
return
}
if len(names) == 0 {
setupFlash(w, r, "Доменные имена убраны. Сертификат перевыпущен на имя хоста и IP.")
return
}
setupFlash(w, r, "Готово: "+strings.Join(names, ", ")+" вписаны в сертификат. Теперь на вашем DNS "+
"(контроллере домена) направьте эти имена на сервер bj — и интерфейс откроется по ним без предупреждения браузера.")
})
}