Мастер: авто-синхронизация времени (ВНИИФТРИ) и доменные имена сервера
Шаг «Время и сеть»: - Кнопка «Синхронизировать»: 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
This commit is contained in:
@@ -223,6 +223,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
|||||||
registerHTTPS(mux, rc) // HTTPS веб-интерфейса: перенаправление, свой сертификат
|
registerHTTPS(mux, rc) // HTTPS веб-интерфейса: перенаправление, свой сертификат
|
||||||
registerTime(mux, rc) // сервер времени (NTP)
|
registerTime(mux, rc) // сервер времени (NTP)
|
||||||
registerTimeZone(mux) // часовой пояс сервера
|
registerTimeZone(mux) // часовой пояс сервера
|
||||||
|
registerHostname(mux, rc) // доменные имена сервера (сертификат HTTPS)
|
||||||
registerLetters(mux, rc) // готовые письма: НРД, ARQA, администратор DEPO
|
registerLetters(mux, rc) // готовые письма: НРД, ARQA, администратор DEPO
|
||||||
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
|
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
|
||||||
registerHealth(mux)
|
registerHealth(mux)
|
||||||
@@ -235,6 +236,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
|||||||
registerChainTest(mux, rc, igwClient != nil) // «Проверить всю цепочку» (мастер, глава «Запуск»)
|
registerChainTest(mux, rc, igwClient != nil) // «Проверить всю цепочку» (мастер, глава «Запуск»)
|
||||||
registerParticipantForm(mux, rc, igwClient, igwChannel) // анкета участника M2M (регистрация в НРД, participant_form.go)
|
registerParticipantForm(mux, rc, igwClient, igwChannel) // анкета участника M2M (регистрация в НРД, participant_form.go)
|
||||||
registerClientProfile(mux, rc) // профиль клиента: выгрузка эталона и предзаполнение (client_profile.go)
|
registerClientProfile(mux, rc) // профиль клиента: выгрузка эталона и предзаполнение (client_profile.go)
|
||||||
|
registerTempPresentation(mux) // ВРЕМЕННО: скачивание презентации продукта (temp_download.go)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package lkgateway
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -52,6 +53,50 @@ func registerTime(mux *http.ServeMux, rc *RuntimeConfig) {
|
|||||||
_ = rc.UpdateTime(TimeSettings{Servers: servers, SavedAt: time.Now()})
|
_ = rc.UpdateTime(TimeSettings{Servers: servers, SavedAt: time.Now()})
|
||||||
setupFlash(w, r, "Время: "+asString(res["output"])+". Синхронизация займёт до нескольких минут.")
|
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 {
|
func asString(v any) string {
|
||||||
@@ -128,3 +173,127 @@ func registerTimeZone(mux *http.ServeMux) {
|
|||||||
setupFlash(w, r, asString(res["output"])+". bj перезапускается — обновите страницу через 15 секунд. Шлюз НРД перейдёт на новый пояс при своём следующем перезапуске.")
|
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 — и интерфейс откроется по ним без предупреждения браузера.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ type HTTPSSettings struct {
|
|||||||
// (самоподписанный сертификат bj создаёт сам): пароли не должны идти по сети открытым текстом.
|
// (самоподписанный сертификат bj создаёт сам): пароли не должны идти по сети открытым текстом.
|
||||||
RedirectOff bool `json:"redirect_off,omitempty"`
|
RedirectOff bool `json:"redirect_off,omitempty"`
|
||||||
Custom bool `json:"custom,omitempty"` // загружен сертификат организации (не самоподписанный)
|
Custom bool `json:"custom,omitempty"` // загружен сертификат организации (не самоподписанный)
|
||||||
|
// Hostnames — доменные имена сервера (например, m2m.corp.example.ru). DNS организации
|
||||||
|
// (контроллер домена) направляет их на bj, а bj вписывает их в самоподписанный сертификат,
|
||||||
|
// чтобы браузер не ругался на имя. Пусто — только имя хоста и его IP.
|
||||||
|
Hostnames []string `json:"hostnames,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RedirectOn — браузер с HTTP перенаправляется на HTTPS.
|
// RedirectOn — браузер с HTTP перенаправляется на HTTPS.
|
||||||
@@ -85,6 +89,7 @@ func ensureSelfSigned(force bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
host, ips := localAddrs()
|
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))
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 62))
|
||||||
tpl := &x509.Certificate{
|
tpl := &x509.Certificate{
|
||||||
SerialNumber: serial,
|
SerialNumber: serial,
|
||||||
@@ -93,7 +98,7 @@ func ensureSelfSigned(force bool) error {
|
|||||||
NotAfter: time.Now().AddDate(5, 0, 0),
|
NotAfter: time.Now().AddDate(5, 0, 0),
|
||||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
DNSNames: []string{pickStr(host, "bj-server"), "localhost", pickStr(host, "bj-server") + ".local"},
|
DNSNames: names,
|
||||||
IPAddresses: ips,
|
IPAddresses: ips,
|
||||||
}
|
}
|
||||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
|
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
|
||||||
@@ -206,6 +211,15 @@ func selfSignedStale() bool {
|
|||||||
return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -560,9 +560,14 @@
|
|||||||
|
|
||||||
<div class="wiz-row" style="margin-top:18px">
|
<div class="wiz-row" style="margin-top:18px">
|
||||||
<span class="wiz-row-l">Точное время</span>
|
<span class="wiz-row-l">Точное время</span>
|
||||||
{{if .Done.Server}}<span style="color:var(--ok)">✓ синхронизируется</span>{{else}}<span style="color:var(--warn)">⚠ не синхронизируется</span>{{end}}
|
{{if timeSynced}}<span style="color:var(--ok)">✓ синхронизируется</span>{{else}}<span style="color:var(--warn)">⚠ не синхронизируется</span>
|
||||||
|
<form method="post" action="/admin/setup/time-auto" style="display:inline;margin-left:8px">
|
||||||
|
<input type="hidden" name="return_to" value="/admin/wizard?step=12">
|
||||||
|
<button type="submit" class="btn" style="padding:5px 12px;font-size:13px">Синхронизировать</button>
|
||||||
|
</form>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<details style="margin-top:6px" {{if not .Done.Server}}open{{end}}><summary style="cursor:pointer;color:var(--accent);font-size:13px">{{if .Done.Server}}Свой сервер времени (обычно не нужно){{else}}Укажите сервер времени вашей сети — обычно контроллер домена или маршрутизатор{{end}}</summary>
|
<p class="muted" style="font-size:12.5px;margin:4px 0 0">bj сам подберёт сервер точного времени: сначала российские серверы ВНИИФТРИ, и только если из вашей сети они недоступны — попросит указать свой (контроллер домена или маршрутизатор сети).</p>
|
||||||
|
<details style="margin-top:6px" {{if not timeSynced}}open{{end}}><summary style="cursor:pointer;color:var(--accent);font-size:13px">Указать свой сервер времени вручную</summary>
|
||||||
<form method="post" action="/admin/setup/time" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
<form method="post" action="/admin/setup/time" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
||||||
<input type="hidden" name="return_to" value="/admin/wizard?step=12">
|
<input type="hidden" name="return_to" value="/admin/wizard?step=12">
|
||||||
<input name="servers" value="{{.Settings.Time.Servers}}" placeholder="например, 10.0.0.1" aria-label="Сервер времени" style="min-width:260px">
|
<input name="servers" value="{{.Settings.Time.Servers}}" placeholder="например, 10.0.0.1" aria-label="Сервер времени" style="min-width:260px">
|
||||||
@@ -580,6 +585,15 @@
|
|||||||
<a href="https://{{serverIP}}:8443/admin/">https://{{serverIP}}:8443/</a>
|
<a href="https://{{serverIP}}:8443/admin/">https://{{serverIP}}:8443/</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" style="font-size:12.5px;margin:4px 0 0">Чтобы адрес не менялся, задайте постоянный: на консоли сервера — <code>sudo bj-network</code>.</p>
|
<p class="muted" style="font-size:12.5px;margin:4px 0 0">Чтобы адрес не менялся, задайте постоянный: на консоли сервера — <code>sudo bj-network</code>.</p>
|
||||||
|
<form method="post" action="/admin/setup/hostname" style="margin-top:12px">
|
||||||
|
<input type="hidden" name="return_to" value="/admin/wizard?step=12">
|
||||||
|
<label for="wiz-hostnames" class="wiz-row-l" style="display:block;margin-bottom:4px">Доменное имя сервера</label>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<input id="wiz-hostnames" name="hostnames" value="{{range $i, $h := .Settings.HTTPS.Hostnames}}{{if $i}} {{end}}{{$h}}{{end}}" placeholder="например, m2m.corp.example.ru" aria-label="Доменное имя сервера" style="min-width:300px">
|
||||||
|
<button type="submit" class="btn">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="font-size:12.5px;margin:4px 0 0">Если для сервера в вашей сети заведено доменное имя — впишите его, и bj вставит имя в сертификат: интерфейс будет открываться по нему без предупреждения браузера. Саму запись имени (A-запись → IP сервера) заводит администратор на вашем DNS / контроллере домена. Несколько имён — через пробел.</p>
|
||||||
|
</form>
|
||||||
{{if .AutoDone}}<p class="muted" style="font-size:12.5px;margin:16px 0 0;padding-top:12px;border-top:1px solid var(--border)">Уже сделано установщиком: {{range $i, $a := .AutoDone}}{{if $i}} · {{end}}✓ {{$a}}{{end}}{{if and .Done.Postgres (pgLocal .Settings.Postgres.DSN)}}. <a href="/admin/wizard?step=2">Базу bj можно держать и на своём сервере PostgreSQL →</a>{{end}}</p>{{end}}
|
{{if .AutoDone}}<p class="muted" style="font-size:12.5px;margin:16px 0 0;padding-top:12px;border-top:1px solid var(--border)">Уже сделано установщиком: {{range $i, $a := .AutoDone}}{{if $i}} · {{end}}✓ {{$a}}{{end}}{{if and .Done.Postgres (pgLocal .Settings.Postgres.DSN)}}. <a href="/admin/wizard?step=2">Базу bj можно держать и на своём сервере PostgreSQL →</a>{{end}}</p>{{end}}
|
||||||
<script>
|
<script>
|
||||||
(function(){ // пояс компьютера пользователя — предложить одной кнопкой (не менять молча: сервер может стоять в другом городе)
|
(function(){ // пояс компьютера пользователя — предложить одной кнопкой (не менять молча: сервер может стоять в другом городе)
|
||||||
|
|||||||
Reference in New Issue
Block a user