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) // имя должно быть в DNS и указывать на этот сервер — иначе браузер всё равно не откроет bj по нему mine := map[string]bool{} for _, ip := range serverIPs() { mine[ip] = true } var resolved []string for _, n := range names { ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) addrs, err := net.DefaultResolver.LookupHost(ctx, n) cancel() if err != nil || len(addrs) == 0 { setupFlash(w, r, "Имя «"+n+"» не найдено в DNS. Попросите администратора сети добавить на DNS-сервере (контроллере домена) запись A: "+ n+" → "+strings.Join(serverIPs(), " или ")+" — и нажмите «Сохранить» ещё раз.") return } ok := false for _, a := range addrs { ok = ok || mine[a] } if !ok && len(mine) > 0 { setupFlash(w, r, "Имя «"+n+"» в DNS указывает на "+strings.Join(addrs, ", ")+", а у сервера bj адрес "+strings.Join(serverIPs(), ", ")+ ". Исправьте запись A на DNS-сервере и нажмите «Сохранить» ещё раз.") return } resolved = append(resolved, n+" → "+strings.Join(addrs, ", ")) } 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(resolved, "; ")+" — имя есть в DNS и вписано в сертификат. Адрес bj: https://"+httpsHostPort(names[0], h.publicPort())+"/ (http:// перебросит на https:// сам). Сертификат самоподписанный — браузер один раз попросит подтвердить исключение; "+ "без предупреждения — после загрузки сертификата организации («Сертификат организации» ниже).") }) }