HTTPS: http:// перебрасывается на https://; имя сервера — с проверкой DNS; скрипты DEPO при обновлении

Порт 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
This commit is contained in:
fontvielle
2026-10-22 13:22:34 +03:00
parent b652baba63
commit 1952f020a1
10 changed files with 222 additions and 18 deletions
+18 -14
View File
@@ -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,
+25
View File
@@ -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)
}
+4
View File
@@ -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 секунд.
+29 -2
View File
@@ -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:// сам). Сертификат самоподписанный — браузер один раз попросит подтвердить исключение; "+
"без предупреждения — после загрузки сертификата организации («Сертификат организации» ниже).")
})
}
+86
View File
@@ -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
}
+45
View File
@@ -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()
}
@@ -1,4 +1,5 @@
{{define "content"}}
{{template "depoProcsBanner" .}}
{{/* ===== Оператор-дашборд (Apple-стиль): приветствие → статус → плитки задач → сводка ===== */}}
<div class="hero">
@@ -1,4 +1,5 @@
{{define "content"}}
{{template "depoProcsBanner" .}}
<script>if(/^#(sec-)?(tests|units)$/.test(location.hash)) location.replace('/admin/status#diag');</script>
{{if .Flash}}<div style="padding:12px 16px;background:var(--ok-weak);background:var(--ok-weak);border-radius:8px;margin-bottom:16px">{{.Flash}}</div>{{end}}
@@ -939,7 +940,7 @@
<input id="set-hostnames" name="hostnames" value="{{range $i, $n := .Settings.HTTPS.Hostnames}}{{if $i}} {{end}}{{$n}}{{end}}" placeholder="например, m2m.corp.example.ru (несколько — через пробел)" style="min-width:320px;flex:1">
<button type="submit" class="btn">Сохранить</button>
</div>
<p class="muted" style="font-size:12.5px;margin:4px 0 0">bj впишет имя в свой сертификат — интерфейс будет открываться по нему без предупреждения о несовпадении имени. DNS-запись заводит администратор сети.</p>
<p class="muted" style="font-size:12.5px;margin:4px 0 0">Сначала администратор сети заводит в DNS запись A на этот сервер ({{range $i, $ip := serverIPs}}{{if $i}}, {{end}}{{$ip}}{{end}}); bj проверит, что имя находится и указывает сюда, и впишет его в сертификат. Адрес bj — <code>https://имя:{{httpsPort}}/</code>.</p>
</form>
<details style="margin-top:8px"><summary style="cursor:pointer;color:var(--accent)">Сертификат организации</summary>
<form method="post" action="/admin/setup/https/cert" enctype="multipart/form-data" style="display:grid;gap:8px;max-width:520px;margin-top:8px">
@@ -1,3 +1,14 @@
{{define "depoProcsBanner"}}{{if and (can "settings") depoProcsPending}}<div class="card" id="depo-procs-pending" style="border-color:var(--warn);background:var(--warn-weak)">
<h2 style="margin:0 0 6px">Обновление bj требует применить скрипты в базе DEPO</h2>
<p style="margin:0 0 8px">В этой версии bj обновлены процедуры обработки M2M в DEPO ({{depoProcOrder}}). Для их установки нужны <b>повышенные права на базу данных</b>: введите логин и пароль администратора SQL (sysadmin, обычно sa). Они используются один раз и не сохраняются.</p>
<form method="post" action="/admin/setup/fancy/depo-procs/install" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end" onsubmit="return confirm('Применить обновлённые процедуры в базе DEPO? CREATE OR ALTER — данные не меняются.')">
<input type="hidden" name="section" value="sec-fancy-depo-procs">
<label style="display:flex;flex-direction:column;gap:3px">Логин администратора SQL<input type="text" name="admin_user" autocomplete="off" placeholder="sa" required></label>
<label style="display:flex;flex-direction:column;gap:3px">Пароль<input type="password" name="admin_password" autocomplete="new-password" required></label>
<button type="submit" class="btn">Применить скрипты</button>
<a href="/admin/setup/fancy/depo-procs/script.sql" class="muted" style="font-size:12.5px">или скачать скрипт для администратора БД</a>
</form>
</div>{{end}}{{end}}
{{define "layout"}}<!DOCTYPE html>
<html lang="ru" data-theme="light">
<head>
@@ -116,7 +116,7 @@
<div class="card" id="sec-fancy-depo-procs">
<h2><span class="dot {{if .Settings.Fancy.DepoProcCheckOK}}ok{{else}}warn{{end}}"></span>Процедуры обработки bj2depo</h2>
<p class="muted">Процедуры <code>FTV_M2M_PROCESS_*</code> и <code>FTV_M2M_JOB</code> встроены в bj. Сначала «Проверить возможности» (ничего не меняет), затем «Установить» — bj выполнит их в базе DEPO в порядке WIRING&rarr;OPERATION&rarr;EVENTS&rarr;CREATE&rarr;JOB и выдаст логину bj право запуска.</p>
<p class="muted">Процедуры <code>FTV_M2M_*</code> встроены в bj. Сначала «Проверить возможности» (ничего не меняет), затем «Установить» — bj выполнит их в базе DEPO в порядке {{depoProcOrder}} и выдаст логину bj право запуска.</p>
<form method="post" style="display:grid;gap:10px;max-width:720px">
<input type="hidden" name="section" value="sec-fancy-depo-procs">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px">