Files
Bridge-and-Join-s/internal/lkgateway/timesync.go
T
fontvielle 76e6f82369 HTTPS: адрес без номера порта — 443 и 80
«Сервер: HTTPS» → «Адрес без номера порта»: bj слушает 443 (тот же HTTPS) и
80 (переход на https://имя/), переходы и адреса — без «:8443». Право на
порты <1024 — drop-in systemd AmbientCapabilities=CAP_NET_BIND_SERVICE
через root-помощника (bj-system-admin ports on|off, bj перезапускается).
Нет права/порт занят — bj работает по 8443, в настройках видна причина.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-22 13:33:42 +03:00

449 lines
18 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)
// имя должно быть в 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:// сам). Сертификат самоподписанный — браузер один раз попросит подтвердить исключение; "+
"без предупреждения — после загрузки сертификата организации («Сертификат организации» ниже).")
})
}