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