From 1952f020a1c1ba29d609a84ceaa1f4122fd0eaea Mon Sep 17 00:00:00 2001 From: fontvielle Date: Thu, 22 Oct 2026 13:22:34 +0300 Subject: [PATCH] =?UTF-8?q?HTTPS:=20http://=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D1=81=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B0=20https://;=20=D0=B8=D0=BC=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20=E2=80=94=20=D1=81=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=BE=D0=B9=20DNS;?= =?UTF-8?q?=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B=20DEPO=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Порт HTTPS принимает и http://: 308 на https:// того же имени и порта (было «Client sent an HTTP request to an HTTPS server»). Доменное имя сервера сохраняется, только если оно есть в DNS и указывает на этот сервер; иначе — что добавить/исправить (запись A → IP сервера). Сообщение об успехе — с адресом https://имя:порт/ и честно про самоподписанный сертификат. Обновление bj с новыми процедурами DEPO: отпечаток установленного набора (depo_procs_applied) не совпадает со встроенным — на главной и в настройках плашка «требуются повышенные права на базу данных» с вводом логина/пароля администратора SQL (не сохраняются) и «Применить скрипты». Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn --- internal/lkgateway/admin.go | 32 ++++--- internal/lkgateway/depo_procs.go | 25 ++++++ internal/lkgateway/runtimeconfig.go | 4 + internal/lkgateway/timesync.go | 31 ++++++- internal/lkgateway/tlsserve.go | 86 +++++++++++++++++++ internal/lkgateway/tlsserve_sniff_test.go | 45 ++++++++++ .../lkgateway/web/templates/admin_home.html | 1 + .../lkgateway/web/templates/admin_setup.html | 3 +- internal/lkgateway/web/templates/layout.html | 11 +++ .../lkgateway/web/templates/part_fancy.html | 2 +- 10 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 internal/lkgateway/tlsserve_sniff_test.go diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index d7a032a..2bf06ed 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -140,20 +140,24 @@ 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, - "nsdSegPop": nsdSegPop, + "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() }, + "depoProcsPending": depoProcsPending, + "depoProcOrder": func() string { return depoProcOrderText("→") }, + "nsdSegPop": nsdSegPop, // participantOK / participantBanner — анкета участника M2M принята НРД; плашка наверху раздела НРД, пока нет. "participantOK": participantAccepted, "participantBanner": participantBanner, diff --git a/internal/lkgateway/depo_procs.go b/internal/lkgateway/depo_procs.go index 77d8662..8f8374c 100644 --- a/internal/lkgateway/depo_procs.go +++ b/internal/lkgateway/depo_procs.go @@ -10,8 +10,10 @@ package lkgateway import ( "context" + "crypto/sha256" "database/sql" "embed" + "encoding/hex" "fmt" "log" "net/http" @@ -85,6 +87,28 @@ func depoProcOrderText(sep string) string { return strings.Join(parts, sep) } +// depoProcsHash — отпечаток набора процедур, который bj установит (с учётом правки под клиента). +func depoProcsHash(f FancyMSSQLSettings) string { + h := sha256.New() + for _, p := range depoProcInstallOrder { + h.Write([]byte(p.Name + "\n" + strings.TrimSpace(depoProcEffective(f, p.Name)) + "\n")) + } + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// depoProcsPending — обновление bj принесло процедуры DEPO, которые ещё не применены в базе: bj работает с +// DEPO (подключение есть, обработка включена), а отпечаток установленного набора не совпадает со встроенным. +func depoProcsPending() bool { + if globalRC == nil { + return false + } + f := globalRC.Snapshot().Fancy + if strings.TrimSpace(f.Server) == "" || (!f.DepoJobRun && f.DepoJobMode == "") { + return false + } + return f.DepoProcsApplied != depoProcsHash(f) +} + // depoProcList — порядок процедур для шаблона (редактор текстов). func depoProcList() []struct{ Name, File string } { return depoProcInstallOrder } @@ -315,6 +339,7 @@ func (h *setupHandlers) installFancyDepoProcs(w http.ResponseWriter, r *http.Req pmsg, pok := depoProcCheck(ctx, h.rc) s = h.rc.Snapshot().Fancy s.DepoProcCheck, s.DepoProcCheckOK, s.DepoProcCheckAt = pmsg, pok, time.Now() + s.DepoProcsApplied, s.DepoProcsAppliedAt = depoProcsHash(s), time.Now() // применено — плашка обновления уходит _ = h.rc.UpdateFancy(s) setupFlash(w, r, "Процедуры DEPO: "+msg+". Проверка обработки: "+pmsg) } diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index ba6e632..711abba 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -217,6 +217,10 @@ type FancyMSSQLSettings struct { // AccountPrefix — начало номера торгового счёта клиента в OD_CLIENTS (у Фонтвьель «ВТНКЦ»): по нему bj // находит счёт инвестора при сверке переводов, в цикле QUIK и при ручном вводе. Пусто — «ВТНКЦ». AccountPrefix string `json:"account_prefix,omitempty"` + // DepoProcsApplied — отпечаток набора процедур bj2depo, установленного в DEPO из bj (depo_procs.go): + // отличается от встроенного — обновление bj принесло новые процедуры, их нужно применить правами администратора БД. + DepoProcsApplied string `json:"depo_procs_applied,omitempty"` + DepoProcsAppliedAt time.Time `json:"depo_procs_applied_at,omitempty"` // Обработка трансферных таблиц DEPO силами bj (вместо джобы SQL Agent bj2depo): // bj сам вызывает процедуру DepoJobProc каждые DepoJobEverySec секунд. diff --git a/internal/lkgateway/timesync.go b/internal/lkgateway/timesync.go index f869c8b..139b416 100644 --- a/internal/lkgateway/timesync.go +++ b/internal/lkgateway/timesync.go @@ -398,6 +398,32 @@ func registerHostname(mux *http.ServeMux, rc *RuntimeConfig) { 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 { @@ -416,7 +442,8 @@ func registerHostname(mux *http.ServeMux, rc *RuntimeConfig) { setupFlash(w, r, "Доменные имена убраны. Сертификат перевыпущен на имя хоста и IP.") return } - setupFlash(w, r, "Готово: "+strings.Join(names, ", ")+" вписаны в сертификат. Теперь на вашем DNS "+ - "(контроллере домена) направьте эти имена на сервер bj — и интерфейс откроется по ним без предупреждения браузера.") + setupFlash(w, r, "Готово: "+strings.Join(resolved, "; ")+" — имя есть в DNS и вписано в сертификат. Адрес bj: https://"+names[0]+":"+ + strconv.Itoa(h.port())+"/ (http:// перебросит на https:// сам). Сертификат самоподписанный — браузер один раз попросит подтвердить исключение; "+ + "без предупреждения — после загрузки сертификата организации («Сертификат организации» ниже).") }) } diff --git a/internal/lkgateway/tlsserve.go b/internal/lkgateway/tlsserve.go index 78124d7..6228cc9 100644 --- a/internal/lkgateway/tlsserve.go +++ b/internal/lkgateway/tlsserve.go @@ -7,6 +7,7 @@ package lkgateway // ходят по HTTP на localhost и не перенаправляются. import ( + "bufio" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -245,6 +246,7 @@ func startHTTPS(rc *RuntimeConfig, handler http.Handler) *http.Server { return nil } httpsUp.Store(true) + ln = newHTTPSSniffListener(ln, rc.Snapshot().HTTPS.port()) go func() { log.Printf("lk-gateway: listen https %s", srv.Addr) if err := srv.ServeTLS(ln, "", ""); err != nil && err != http.ErrServerClosed { @@ -326,3 +328,87 @@ func registerHTTPS(mux *http.ServeMux, rc *RuntimeConfig) { setupFlash(w, r, "HTTPS: создан новый самоподписанный сертификат на имя сервера и его адреса") }) } + +// httpsSniffListener — порт HTTPS принимает и обычный http://: такой запрос перенаправляется (308) на +// https:// того же имени и порта, вместо ответа Go «Client sent an HTTP request to an HTTPS server». +// TLS-соединение узнаётся по первому байту (0x16 — handshake) и идёт дальше без изменений. +type httpsSniffListener struct { + net.Listener + conns chan net.Conn + errs chan error + port int +} + +func newHTTPSSniffListener(ln net.Listener, port int) net.Listener { + l := &httpsSniffListener{Listener: ln, conns: make(chan net.Conn, 16), errs: make(chan error, 1), port: port} + go func() { + for { + c, err := ln.Accept() + if err != nil { + l.errs <- err + return + } + go l.sniff(c) + } + }() + return l +} + +func (l *httpsSniffListener) Accept() (net.Conn, error) { + select { + case c := <-l.conns: + return c, nil + case err := <-l.errs: + return nil, err + } +} + +func (l *httpsSniffListener) sniff(c net.Conn) { + _ = c.SetReadDeadline(time.Now().Add(10 * time.Second)) + br := bufio.NewReader(c) + b, err := br.Peek(1) + if err != nil { + _ = c.Close() + return + } + _ = c.SetReadDeadline(time.Time{}) + if b[0] == 0x16 { + l.conns <- &peekedConn{Conn: c, r: br} + return + } + defer c.Close() + _ = c.SetReadDeadline(time.Now().Add(10 * time.Second)) + req, err := http.ReadRequest(br) + if err != nil { + return + } + host := req.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + if net.ParseIP(host) == nil && !validHostname(strings.ToLower(host)) { + host = "localhost" + } + target := "https://" + net.JoinHostPort(host, strconv.Itoa(l.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")) +} + +// peekedConn — соединение, у которого уже прочитан (подсмотрен) первый байт. +type peekedConn struct { + net.Conn + r *bufio.Reader +} + +func (p *peekedConn) Read(b []byte) (int, error) { return p.r.Read(b) } + +// serverIPs — адреса этого сервера (для проверки, куда указывает доменное имя). +func serverIPs() []string { + var out []string + addrs, _ := net.InterfaceAddrs() + for _, a := range addrs { + if n, ok := a.(*net.IPNet); ok && !n.IP.IsLoopback() && !n.IP.IsLinkLocalUnicast() { + out = append(out, n.IP.String()) + } + } + return out +} diff --git a/internal/lkgateway/tlsserve_sniff_test.go b/internal/lkgateway/tlsserve_sniff_test.go new file mode 100644 index 0000000..e002726 --- /dev/null +++ b/internal/lkgateway/tlsserve_sniff_test.go @@ -0,0 +1,45 @@ +package lkgateway + +import ( + "bufio" + "net" + "net/http" + "strings" + "testing" +) + +// http:// на порт HTTPS — 308 на https:// того же имени и порта; первый байт TLS проходит дальше. +func TestHTTPSSniffRedirect(t *testing.T) { + raw, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + ln := newHTTPSSniffListener(raw, 8443) + defer ln.Close() + go func() { // TLS-соединения отдаются Accept + c, err := ln.Accept() + if err == nil { + b := make([]byte, 1) + _, _ = c.Read(b) + _, _ = c.Write(b) + c.Close() + } + }() + c, err := net.Dial("tcp", raw.Addr().String()) + if err != nil { + t.Fatal(err) + } + _, _ = c.Write([]byte("GET /admin/setup?x=1 HTTP/1.1\r\nHost: m2m.corp.fontvielle.ru:8443\r\n\r\n")) + resp, err := http.ReadResponse(bufio.NewReader(c), nil) + c.Close() + if err != nil || resp.StatusCode != 308 || resp.Header.Get("Location") != "https://m2m.corp.fontvielle.ru:8443/admin/setup?x=1" { + t.Fatalf("ответ: %v %v %q", err, resp, resp.Header.Get("Location")) + } + c2, _ := net.Dial("tcp", raw.Addr().String()) + _, _ = c2.Write([]byte{0x16}) + b := make([]byte, 1) + if _, err := c2.Read(b); err != nil || b[0] != 0x16 || !strings.Contains(raw.Addr().String(), "127.0.0.1") { + t.Fatalf("TLS-байт не прошёл: %v %x", err, b) + } + c2.Close() +} diff --git a/internal/lkgateway/web/templates/admin_home.html b/internal/lkgateway/web/templates/admin_home.html index 7fad5ee..c958803 100644 --- a/internal/lkgateway/web/templates/admin_home.html +++ b/internal/lkgateway/web/templates/admin_home.html @@ -1,4 +1,5 @@ {{define "content"}} +{{template "depoProcsBanner" .}} {{/* ===== Оператор-дашборд (Apple-стиль): приветствие → статус → плитки задач → сводка ===== */}}
diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 3ffea13..80448b4 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -1,4 +1,5 @@ {{define "content"}} +{{template "depoProcsBanner" .}} {{if .Flash}}
{{.Flash}}
{{end}} @@ -939,7 +940,7 @@
-

bj впишет имя в свой сертификат — интерфейс будет открываться по нему без предупреждения о несовпадении имени. DNS-запись заводит администратор сети.

+

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

Сертификат организации
diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html index c9108cd..f3073a6 100644 --- a/internal/lkgateway/web/templates/layout.html +++ b/internal/lkgateway/web/templates/layout.html @@ -1,3 +1,14 @@ +{{define "depoProcsBanner"}}{{if and (can "settings") depoProcsPending}}
+

Обновление bj требует применить скрипты в базе DEPO

+

В этой версии bj обновлены процедуры обработки M2M в DEPO ({{depoProcOrder}}). Для их установки нужны повышенные права на базу данных: введите логин и пароль администратора SQL (sysadmin, обычно sa). Они используются один раз и не сохраняются.

+ + + + + + или скачать скрипт для администратора БД + +
{{end}}{{end}} {{define "layout"}} diff --git a/internal/lkgateway/web/templates/part_fancy.html b/internal/lkgateway/web/templates/part_fancy.html index 4b484dd..287a015 100644 --- a/internal/lkgateway/web/templates/part_fancy.html +++ b/internal/lkgateway/web/templates/part_fancy.html @@ -116,7 +116,7 @@

Процедуры обработки bj2depo

-

Процедуры FTV_M2M_PROCESS_* и FTV_M2M_JOB встроены в bj. Сначала «Проверить возможности» (ничего не меняет), затем «Установить» — bj выполнит их в базе DEPO в порядке WIRING→OPERATION→EVENTS→CREATE→JOB и выдаст логину bj право запуска.

+

Процедуры FTV_M2M_* встроены в bj. Сначала «Проверить возможности» (ничего не меняет), затем «Установить» — bj выполнит их в базе DEPO в порядке {{depoProcOrder}} и выдаст логину bj право запуска.