Files
Bridge-and-Join-s/internal/lkgateway/timesync.go
T
fontvielle 2df7b0f54a Мастер: авто-синхронизация времени (ВНИИФТРИ) и доменные имена сервера
Шаг «Время и сеть»:
- Кнопка «Синхронизировать»: bj сам пробует российские серверы точного
  времени ВНИИФТРИ (мини-SNTP зонд по UDP/123), и только если из сети они
  недоступны — просит указать свой сервер (контроллер домена/маршрутизатор).
  Статус времени берётся из timedatectl (timeSynced), а не из флага шага.
- Поле «Доменное имя сервера»: имена вписываются в самоподписанный сертификат
  (SAN), чтобы интерфейс открывался по имени без предупреждения браузера.
  Сертификат перевыпускается на лету; A-запись заводит админ сети на DNS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
2026-10-16 15:15:09 +03:00

300 lines
12 KiB
Go

package lkgateway
// Сервер времени (NTP) — из веб-интерфейса (05.10.2026). В закрытом контуре внешние серверы времени
// недоступны и часы уплывают, а время важно для подписей и обмена с НРД. Меняет root-скрипт
// bj-system-admin (ntpd, chrony или systemd-timesyncd — что стоит на сервере).
import (
"context"
"net"
"net/http"
"os"
"os/exec"
"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
}
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, "Из вашей сети российские серверы точного времени (ВНИИФТРИ) недоступны. "+
"Укажите сервер времени вашей сети — обычно контроллер домена или маршрутизатор.")
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 {
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+
". Синхронизирую — займёт до нескольких минут.")
})
}
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)"},
}
// 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 — и интерфейс откроется по ним без предупреждения браузера.")
})
}