diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 803890c..4a8dfd7 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -223,6 +223,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerHTTPS(mux, rc) // HTTPS веб-интерфейса: перенаправление, свой сертификат registerTime(mux, rc) // сервер времени (NTP) registerTimeZone(mux) // часовой пояс сервера + registerHostname(mux, rc) // доменные имена сервера (сертификат HTTPS) registerLetters(mux, rc) // готовые письма: НРД, ARQA, администратор DEPO registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerHealth(mux) @@ -235,6 +236,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerChainTest(mux, rc, igwClient != nil) // «Проверить всю цепочку» (мастер, глава «Запуск») registerParticipantForm(mux, rc, igwClient, igwChannel) // анкета участника M2M (регистрация в НРД, participant_form.go) registerClientProfile(mux, rc) // профиль клиента: выгрузка эталона и предзаполнение (client_profile.go) + registerTempPresentation(mux) // ВРЕМЕННО: скачивание презентации продукта (temp_download.go) return &Server{ cfg: cfg, diff --git a/internal/lkgateway/timesync.go b/internal/lkgateway/timesync.go index 99c1568..bc8d2cc 100644 --- a/internal/lkgateway/timesync.go +++ b/internal/lkgateway/timesync.go @@ -6,6 +6,7 @@ package lkgateway import ( "context" + "net" "net/http" "os" "os/exec" @@ -52,6 +53,50 @@ func registerTime(mux *http.ServeMux, rc *RuntimeConfig) { _ = 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 { @@ -128,3 +173,127 @@ func registerTimeZone(mux *http.ServeMux) { 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 — и интерфейс откроется по ним без предупреждения браузера.") + }) +} diff --git a/internal/lkgateway/tlsserve.go b/internal/lkgateway/tlsserve.go index afbeb5b..78124d7 100644 --- a/internal/lkgateway/tlsserve.go +++ b/internal/lkgateway/tlsserve.go @@ -36,6 +36,10 @@ type HTTPSSettings struct { // (самоподписанный сертификат bj создаёт сам): пароли не должны идти по сети открытым текстом. RedirectOff bool `json:"redirect_off,omitempty"` Custom bool `json:"custom,omitempty"` // загружен сертификат организации (не самоподписанный) + // Hostnames — доменные имена сервера (например, m2m.corp.example.ru). DNS организации + // (контроллер домена) направляет их на bj, а bj вписывает их в самоподписанный сертификат, + // чтобы браузер не ругался на имя. Пусто — только имя хоста и его IP. + Hostnames []string `json:"hostnames,omitempty"` } // RedirectOn — браузер с HTTP перенаправляется на HTTPS. @@ -85,6 +89,7 @@ func ensureSelfSigned(force bool) error { return err } host, ips := localAddrs() + names := uniqStrings(append([]string{pickStr(host, "bj-server"), "localhost", pickStr(host, "bj-server") + ".local"}, customHostnames()...)) serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 62)) tpl := &x509.Certificate{ SerialNumber: serial, @@ -93,7 +98,7 @@ func ensureSelfSigned(force bool) error { NotAfter: time.Now().AddDate(5, 0, 0), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - DNSNames: []string{pickStr(host, "bj-server"), "localhost", pickStr(host, "bj-server") + ".local"}, + DNSNames: names, IPAddresses: ips, } der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key) @@ -206,6 +211,15 @@ func selfSignedStale() bool { return true } } + have := map[string]bool{} + for _, n := range c.DNSNames { + have[strings.ToLower(n)] = true + } + for _, n := range customHostnames() { + if n = strings.ToLower(strings.TrimSpace(n)); n != "" && !have[n] { + return true + } + } return false } diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index eead00b..fa4fa63 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -560,9 +560,14 @@
bj сам подберёт сервер точного времени: сначала российские серверы ВНИИФТРИ, и только если из вашей сети они недоступны — попросит указать свой (контроллер домена или маршрутизатор сети).
+Уже сделано установщиком: {{range $i, $a := .AutoDone}}{{if $i}} · {{end}}✓ {{$a}}{{end}}{{if and .Done.Postgres (pgLocal .Settings.Postgres.DSN)}}. Базу bj можно держать и на своём сервере PostgreSQL →{{end}}
{{end}}