From 76e6f82369c9780fa2515a8803152953b4cb59fa Mon Sep 17 00:00:00 2001 From: fontvielle Date: Thu, 22 Oct 2026 13:33:42 +0300 Subject: [PATCH] =?UTF-8?q?HTTPS:=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=B0=20=E2=80=94=20443=20=D0=B8=2080?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «Сервер: 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 Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn --- deploy/linux/sbin/bj-keymedia-worker | 2 +- deploy/linux/sbin/bj-system-admin | 13 ++ internal/lkgateway/admin.go | 38 +++-- internal/lkgateway/timesync.go | 3 +- internal/lkgateway/tlsserve.go | 135 +++++++++++++++++- .../lkgateway/web/templates/admin_setup.html | 8 +- 6 files changed, 177 insertions(+), 22 deletions(-) diff --git a/deploy/linux/sbin/bj-keymedia-worker b/deploy/linux/sbin/bj-keymedia-worker index a474518..32746b7 100755 --- a/deploy/linux/sbin/bj-keymedia-worker +++ b/deploy/linux/sbin/bj-keymedia-worker @@ -16,7 +16,7 @@ process_one(){ # датчик случайных чисел Валидаты: проверка и сеанс «двигайте мышью» (движения — из мастера bj) rng-status|rng-start|rng-cancel) out=$(/usr/local/sbin/bj-validata-rng "${action#rng-}" 2>&1) ;; # системные настройки сервера: серверы времени (NTP) - system-ntp|system-tz|system-sync) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-system-admin "${action#system-}" 2>&1) ;; + system-ntp|system-tz|system-sync|system-ports) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-system-admin "${action#system-}" 2>&1) ;; # резервное копирование и восстановление (настройки bj → bj-backup-admin); параметры — JSON в extra backup-config|backup-test|backup-run|backup-list|backup-verify|backup-restore) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-backup-admin "${action#backup-}" 2>&1) ;; # Перезапуск службы модуля из админки. Список закрытый: чужую службу diff --git a/deploy/linux/sbin/bj-system-admin b/deploy/linux/sbin/bj-system-admin index 8e2c5cd..bd11d25 100755 --- a/deploy/linux/sbin/bj-system-admin +++ b/deploy/linux/sbin/bj-system-admin @@ -4,12 +4,25 @@ # ntp — серверы времени (BJ_EXTRA: "ntp1.example.ru ntp2.example.ru"): ntpd, chrony или systemd-timesyncd — # что стоит на сервере. Время важно для подписей и обмена с НРД. # sync — поставить часы сейчас по серверам службы времени (не ждать её плавной подстройки) +# ports — адрес bj без номера порта (BJ_EXTRA: on|off): службе bj-server право слушать 443/80 +# (drop-in AmbientCapabilities=CAP_NET_BIND_SERVICE), перезапуск bj через 3 с set -uo pipefail q() { python3 -c "import json,sys;print(json.dumps(sys.argv[1],ensure_ascii=False))" "$1"; } ok() { echo "{\"ok\":true,\"output\":$(q "$1")}"; } err() { echo "{\"ok\":false,\"error\":$(q "$1")}"; exit 0; } cmd=${1:-} case "$cmd" in + ports) + d=/etc/systemd/system/bj-server.service.d; f=$d/50-bj-std-ports.conf + case "${BJ_EXTRA:-on}" in + on) mkdir -p "$d" && printf '%s\n' '[Service]' '# bj слушает 443 (HTTPS) и 80 (переход на https://) — адрес без номера порта' 'AmbientCapabilities=CAP_NET_BIND_SERVICE' > "$f" || err "не удалось записать $f" ;; + off) rm -f "$f" ;; + *) err "ports: on или off" ;; + esac + systemctl daemon-reload || err "systemctl daemon-reload не выполнился" + systemd-run --quiet --on-active=3 /bin/systemctl restart bj-server.service >/dev/null 2>&1 || { (sleep 3; systemctl restart bj-server.service) >/dev/null 2>&1 & } + ok "порты 443/80: ${BJ_EXTRA:-on}; bj перезапустится через 3 с" + ;; ntp) servers=$(printf '%s' "${BJ_EXTRA:-}" | tr ',;' ' ' | xargs) [ -n "$servers" ] || err "не указан сервер времени" diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 2bf06ed..064da01 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "path" + "strconv" "strings" "time" @@ -140,21 +141,28 @@ var templateFuncs = template.FuncMap{ } return "IP-сервера" }, - "timeSynced": func() bool { ok, _ := timeStatus(context.Background()); return ok }, - "serverTimeZone": serverTimeZone, - "letter": letterView, - "mailtoLink": mailtoLink, - "handbookDepos": handbookDepos, - "timeZones": func() any { return wizTimeZones }, - "timeZoneKnown": timeZoneKnown, - "clientVer": clientVersion, - "verTitle": versionTitle, - "contourRu": contourRu, - "looksErr": looksErr, - "intList": func(v ...int) []int { return v }, - "nsdSegTip": nsdSegTip, - "serverIPs": serverIPs, - "httpsPort": func() int { return globalRC.Snapshot().HTTPS.port() }, + "timeSynced": func() bool { ok, _ := timeStatus(context.Background()); return ok }, + "serverTimeZone": serverTimeZone, + "letter": letterView, + "mailtoLink": mailtoLink, + "handbookDepos": handbookDepos, + "timeZones": func() any { return wizTimeZones }, + "timeZoneKnown": timeZoneKnown, + "clientVer": clientVersion, + "verTitle": versionTitle, + "contourRu": contourRu, + "looksErr": looksErr, + "intList": func(v ...int) []int { return v }, + "nsdSegTip": nsdSegTip, + "serverIPs": serverIPs, + "httpsPortSuffix": func() string { + if p := globalRC.Snapshot().HTTPS.publicPort(); p != 443 { + return ":" + strconv.Itoa(p) + } + return "" + }, + "httpsOwnPort": func() int { return globalRC.Snapshot().HTTPS.port() }, + "stdPortsState": stdPortsState, "depoProcsPending": depoProcsPending, "depoProcOrder": func() string { return depoProcOrderText("→") }, "nsdSegPop": nsdSegPop, diff --git a/internal/lkgateway/timesync.go b/internal/lkgateway/timesync.go index 139b416..7cf93b6 100644 --- a/internal/lkgateway/timesync.go +++ b/internal/lkgateway/timesync.go @@ -442,8 +442,7 @@ func registerHostname(mux *http.ServeMux, rc *RuntimeConfig) { setupFlash(w, r, "Доменные имена убраны. Сертификат перевыпущен на имя хоста и IP.") return } - setupFlash(w, r, "Готово: "+strings.Join(resolved, "; ")+" — имя есть в DNS и вписано в сертификат. Адрес bj: https://"+names[0]+":"+ - strconv.Itoa(h.port())+"/ (http:// перебросит на https:// сам). Сертификат самоподписанный — браузер один раз попросит подтвердить исключение; "+ + setupFlash(w, r, "Готово: "+strings.Join(resolved, "; ")+" — имя есть в DNS и вписано в сертификат. Адрес bj: https://"+httpsHostPort(names[0], h.publicPort())+"/ (http:// перебросит на https:// сам). Сертификат самоподписанный — браузер один раз попросит подтвердить исключение; "+ "без предупреждения — после загрузки сертификата организации («Сертификат организации» ниже).") }) } diff --git a/internal/lkgateway/tlsserve.go b/internal/lkgateway/tlsserve.go index 6228cc9..04c5815 100644 --- a/internal/lkgateway/tlsserve.go +++ b/internal/lkgateway/tlsserve.go @@ -8,6 +8,7 @@ package lkgateway import ( "bufio" + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -41,6 +42,33 @@ type HTTPSSettings struct { // (контроллер домена) направляет их на bj, а bj вписывает их в самоподписанный сертификат, // чтобы браузер не ругался на имя. Пусто — только имя хоста и его IP. Hostnames []string `json:"hostnames,omitempty"` + // StdPorts — bj слушает и стандартные порты: 443 (HTTPS) и 80 (переход на https://), адрес без «:8443». + StdPorts bool `json:"std_ports,omitempty"` +} + +// https443Up — bj слушает 443; std443Err — почему не слушает (нет права — включить ещё раз). +var ( + https443Up atomic.Bool + std443Err atomic.Value // string +) + +// publicPort — порт HTTPS в адресах и переходах: 443, если bj его слушает, иначе свой (8443). +func (h HTTPSSettings) publicPort() int { + if h.StdPorts && https443Up.Load() { + return 443 + } + return h.port() +} + +// httpsHostPort — «имя» для 443 и «имя:порт» для остальных. +func httpsHostPort(host string, port int) string { + if port == 443 { + if net.ParseIP(host) != nil && strings.Contains(host, ":") { + return "[" + host + "]" + } + return host + } + return net.JoinHostPort(host, strconv.Itoa(port)) } // RedirectOn — браузер с HTTP перенаправляется на HTTPS. @@ -173,7 +201,7 @@ func httpsRedirect(rc *RuntimeConfig, next http.Handler) http.Handler { if hh, _, err := net.SplitHostPort(hn); err == nil { hn = hh } - http.Redirect(w, r, "https://"+net.JoinHostPort(hn, strconv.Itoa(h.port()))+r.URL.RequestURI(), http.StatusFound) + http.Redirect(w, r, "https://"+httpsHostPort(hn, h.publicPort())+r.URL.RequestURI(), http.StatusFound) return } } @@ -254,6 +282,9 @@ func startHTTPS(rc *RuntimeConfig, handler http.Handler) *http.Server { log.Printf("https: %v", err) } }() + if rc.Snapshot().HTTPS.StdPorts { + startStdPorts(srv) + } return srv } @@ -271,10 +302,49 @@ func registerHTTPS(mux *http.ServeMux, rc *RuntimeConfig) { } msg := "HTTPS: перенаправление с HTTP выключено" if h.RedirectOn() { - msg = "HTTPS: браузеры теперь перенаправляются на https://…:" + strconv.Itoa(h.port()) + msg = "HTTPS: браузеры теперь перенаправляются на https://" + httpsHostPort("…", h.publicPort()) } setupFlash(w, r, msg) }) + // адрес без номера порта: 443 и 80 (право на порты — через root-помощника, bj перезапускается) + mux.HandleFunc("/admin/setup/https/stdports", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + on := r.FormValue("std_ports") != "" + h := rc.Snapshot().HTTPS + h.StdPorts = on + if err := rc.UpdateHTTPS(h); err != nil { + setupFlash(w, r, "HTTPS: "+err.Error()) + return + } + mode := "off" + if on { + mode = "on" + } + ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) + res, err := runKeymediaOp(ctx, "system-ports", map[string]string{"extra": mode}) + cancel() + if err == nil { + if ok, _ := res["ok"].(bool); !ok { + err = fmt.Errorf("%v", res["error"]) + } + } + if err != nil { + setupFlash(w, r, "Адрес без номера порта: настройка сохранена, но право на порты не выдано — "+err.Error()) + return + } + name := "имя-сервера" + if len(h.Hostnames) > 0 { + name = h.Hostnames[0] + } + if on { + setupFlash(w, r, "Готово: bj перезапускается (несколько секунд) и будет открываться по https://"+name+"/ — без :"+strconv.Itoa(h.port())+"; http://"+name+"/ перейдёт на https:// сам. Обновите страницу через 10 секунд.") + } else { + setupFlash(w, r, "Адрес без номера порта выключен: bj перезапускается и открывается по https://"+name+":"+strconv.Itoa(h.port())+"/.") + } + }) // свой сертификат организации (PEM: сертификат с цепочкой и закрытый ключ) mux.HandleFunc("/admin/setup/https/cert", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -389,7 +459,11 @@ func (l *httpsSniffListener) sniff(c net.Conn) { if net.ParseIP(host) == nil && !validHostname(strings.ToLower(host)) { host = "localhost" } - target := "https://" + net.JoinHostPort(host, strconv.Itoa(l.port)) + req.URL.RequestURI() + port := l.port + if globalRC != nil && globalRC.Snapshot().HTTPS.publicPort() == 443 { + port = 443 + } + target := "https://" + httpsHostPort(host, port) + req.URL.RequestURI() _, _ = c.Write([]byte("HTTP/1.1 308 Permanent Redirect\r\nLocation: " + target + "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) } @@ -412,3 +486,58 @@ func serverIPs() []string { } return out } + +// startStdPorts — 443 (тот же HTTPS) и 80 (переход на https://имя/). Нет права на порты <1024 — bj +// работает по 8443, а в настройках видно, что нужно включить ещё раз (выдать право через помощника). +func startStdPorts(srv *http.Server) { + ln, err := net.Listen("tcp", ":443") + if err != nil { + std443Err.Store(err.Error()) + log.Printf("https: порт 443: %v — адрес без номера порта недоступен", err) + return + } + std443Err.Store("") + https443Up.Store(true) + go func() { + log.Printf("lk-gateway: listen https :443") + if err := srv.ServeTLS(newHTTPSSniffListener(ln, 443), "", ""); err != nil && err != http.ErrServerClosed { + https443Up.Store(false) + log.Printf("https :443: %v", err) + } + }() + ln80, err := net.Listen("tcp", ":80") + if err != nil { + log.Printf("http: порт 80: %v — http://имя/ без порта не откроется (https://имя/ работает)", err) + return + } + redir := &http.Server{ReadHeaderTimeout: 5 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + if net.ParseIP(host) == nil && !validHostname(strings.ToLower(host)) { + http.Error(w, "bad host", http.StatusBadRequest) + return + } + http.Redirect(w, r, "https://"+httpsHostPort(host, 443)+r.URL.RequestURI(), http.StatusMovedPermanently) + })} + go func() { + log.Printf("lk-gateway: listen http :80 (переход на https://)") + _ = redir.Serve(ln80) + }() +} + +// stdPortsState — для настроек: включено ли и работает ли. +func stdPortsState() string { + h := globalRC.Snapshot().HTTPS + switch { + case !h.StdPorts: + return "" + case https443Up.Load(): + return "ok" + } + if e, _ := std443Err.Load().(string); e != "" { + return e + } + return "ожидает перезапуска bj" +} diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 80448b4..6198335 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -940,7 +940,13 @@ -

Сначала администратор сети заводит в DNS запись A на этот сервер ({{range $i, $ip := serverIPs}}{{if $i}}, {{end}}{{$ip}}{{end}}); bj проверит, что имя находится и указывает сюда, и впишет его в сертификат. Адрес bj — https://имя:{{httpsPort}}/.

+

Сначала администратор сети заводит в DNS запись A на этот сервер ({{range $i, $ip := serverIPs}}{{if $i}}, {{end}}{{$ip}}{{end}}); bj проверит, что имя находится и указывает сюда, и впишет его в сертификат. Адрес bj — https://имя{{httpsPortSuffix}}/.

+ +
+ + + {{with stdPortsState}}

{{if eq . "ok"}}✓ работает: порты 443 и 80{{else}}⚠ {{.}} — нажмите «Сохранить» ещё раз{{end}}

{{end}} +
Сертификат организации