Кабинет клиента на этом сервере для просмотра: включается в настройках, отдельный порт (:8098), куки без Secure только для этого слушателя

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
zuevav
2026-09-18 14:50:19 +03:00
parent e3e85fdf09
commit 3760c1f1dd
6 changed files with 180 additions and 2 deletions
+149
View File
@@ -0,0 +1,149 @@
package lkgateway
// lkpreview.go — кабинет клиента на этом же сервере, для просмотра.
//
// Чтобы посмотреть, как выглядит и работает кабинет, не обязательно ставить его на хостинг:
// администратор включает его прямо здесь, на отдельном порту ядра. Это тот же встроенный
// кабинет, что отдаёт страницы ретрансляторам, только с собственным слушателем. Порт
// открыт по http, поэтому куки выставляются без признака Secure — для просмотра из
// локальной сети это допустимо, для клиентов из интернета — нет: им кабинет публикуется
// через хостинг или nginx с сертификатом.
import (
"context"
"errors"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
)
const lkPreviewDefaultListen = ":8098"
var lkPreview struct {
mu sync.Mutex
srv *http.Server
listen string
err string
}
// lkPreviewApply — привести слушатель в соответствие с настройками: включить, выключить
// или перезапустить на другом порту.
func lkPreviewApply(rc *RuntimeConfig) {
s := rc.Snapshot().LK
listen := strings.TrimSpace(s.PreviewListen)
if listen == "" {
listen = lkPreviewDefaultListen
}
lkPreview.mu.Lock()
defer lkPreview.mu.Unlock()
if !s.PreviewOn || listen != lkPreview.listen {
if lkPreview.srv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
_ = lkPreview.srv.Shutdown(ctx)
cancel()
lkPreview.srv, lkPreview.listen = nil, ""
log.Printf("кабинет: просмотр на этом сервере выключен")
}
}
if !s.PreviewOn || lkPreview.srv != nil {
lkPreview.err = ""
return
}
if globalLKCabinet == nil {
lkPreview.err = "встроенный кабинет ещё не запущен"
return
}
cab, err := globalLKCabinet.server()
if err != nil {
lkPreview.err = err.Error()
return
}
ln, err := net.Listen("tcp", listen)
if err != nil {
lkPreview.err = "порт занят или недоступен: " + err.Error()
log.Printf("кабинет: просмотр на %s не запущен: %v", listen, err)
return
}
h := cab.Handler()
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Header.Set("X-BJ-Preview", "1") // куки без Secure: просмотр по http из локальной сети
h.ServeHTTP(w, r)
}),
ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
}
lkPreview.srv, lkPreview.listen, lkPreview.err = srv, listen, ""
go func() {
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("кабинет: просмотр на %s остановлен: %v", listen, err)
lkPreview.mu.Lock()
if lkPreview.srv == srv {
lkPreview.srv, lkPreview.listen, lkPreview.err = nil, "", err.Error()
}
lkPreview.mu.Unlock()
}
}()
log.Printf("кабинет: просмотр на этом сервере включён, %s", listen)
}
// LKPreviewStatus — для страницы настроек.
type LKPreviewStatus struct {
On bool
Listen string
URL string // адрес для администратора, по имени, с которого он открыл настройки
Err string
}
func lkPreviewStatus(rc *RuntimeConfig, r *http.Request) LKPreviewStatus {
s := rc.Snapshot().LK
listen := strings.TrimSpace(s.PreviewListen)
if listen == "" {
listen = lkPreviewDefaultListen
}
lkPreview.mu.Lock()
st := LKPreviewStatus{On: lkPreview.srv != nil, Listen: listen, Err: lkPreview.err}
lkPreview.mu.Unlock()
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
_, port, err := net.SplitHostPort(listen)
if err != nil {
port = strings.TrimPrefix(listen, ":")
}
st.URL = "http://" + host + ":" + port + "/"
return st
}
// lkPreviewSave — POST /admin/setup/lk/preview: включить или выключить просмотр.
func (h *setupHandlers) lkPreviewSave(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot().LK
s.PreviewOn = r.FormValue("preview_on") == "1"
if v := strings.TrimSpace(r.FormValue("preview_listen")); v != "" {
if !strings.Contains(v, ":") {
v = ":" + v
}
s.PreviewListen = v
}
if err := h.rc.UpdateLK(s); err != nil {
setupFlash(w, r, "Кабинет: ошибка сохранения: "+err.Error())
return
}
lkPreviewApply(h.rc)
st := lkPreviewStatus(h.rc, r)
switch {
case st.Err != "":
setupFlash(w, r, "Кабинет на этом сервере не запущен: "+st.Err)
case st.On:
setupFlash(w, r, "Кабинет запущен на этом сервере: "+st.URL+" — вход по учётным записям из карточки «Пользователи кабинета».")
default:
setupFlash(w, r, "Кабинет на этом сервере выключен.")
}
}
+4
View File
@@ -255,6 +255,10 @@ type LKSettings struct {
AgentMode bool `json:"lk_agent_mode,omitempty"` AgentMode bool `json:"lk_agent_mode,omitempty"`
// CabinetURL — адрес кабинета на хостинге, куда подключается ядро в режиме ожидания. // CabinetURL — адрес кабинета на хостинге, куда подключается ядро в режиме ожидания.
CabinetURL string `json:"lk_cabinet_url,omitempty"` CabinetURL string `json:"lk_cabinet_url,omitempty"`
// PreviewOn — кабинет запущен на этом же сервере на отдельном порту (просмотр из сети
// депозитария без размещения на хостинге). PreviewListen — адрес:порт, по умолчанию :8098.
PreviewOn bool `json:"lk_preview_on,omitempty"`
PreviewListen string `json:"lk_preview_listen,omitempty"`
// BindNetwork — привязывать сессию клиента не только к браузеру, но и к сети /24. // BindNetwork — привязывать сессию клиента не только к браузеру, но и к сети /24.
// Строже, но мобильных клиентов будет выбрасывать при смене адреса оператором. // Строже, но мобильных клиентов будет выбрасывать при смене адреса оператором.
BindNetwork bool `json:"lk_bind_network,omitempty"` BindNetwork bool `json:"lk_bind_network,omitempty"`
+1
View File
@@ -295,6 +295,7 @@ func (s *Server) Run(ctx context.Context) error {
// Кабинет клиента в режиме ожидания: ядро само подключается к кабинету на // Кабинет клиента в режиме ожидания: ядро само подключается к кабинету на
// хостинге и обслуживает запросы клиентов через исходящее соединение. // хостинге и обслуживает запросы клиентов через исходящее соединение.
go superviseLoop(ctx, "lk-agent", s.rc, s.lkAgentLoop) go superviseLoop(ctx, "lk-agent", s.rc, s.lkAgentLoop)
lkPreviewApply(s.rc) // кабинет на этом сервере для просмотра, если включён в настройках
// Поллер заявок ЛК ЕСИА Финанс: раз в минуту забирает новые depo_m2m и // Поллер заявок ЛК ЕСИА Финанс: раз в минуту забирает новые depo_m2m и
// отправляет в НРД (или имитатору). Интервал 1 мин — время ответов регламентировано. // отправляет в НРД (или имитатору). Интервал 1 мин — время ответов регламентировано.
+3
View File
@@ -91,6 +91,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
mux.HandleFunc("/admin/setup/lk/dist.tar.gz", h.lkDistDownload) mux.HandleFunc("/admin/setup/lk/dist.tar.gz", h.lkDistDownload)
mux.HandleFunc("/admin/setup/lk/users", h.lkUsersSave) mux.HandleFunc("/admin/setup/lk/users", h.lkUsersSave)
mux.HandleFunc("/admin/setup/lk/relay-php.zip", h.lkPHPRelayDownload) mux.HandleFunc("/admin/setup/lk/relay-php.zip", h.lkPHPRelayDownload)
mux.HandleFunc("/admin/setup/lk/preview", h.lkPreviewSave)
mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration) mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration)
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn) mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate) mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate)
@@ -320,6 +321,7 @@ type SetupData struct {
LKKeyMask string // ключ кабинета в маскированном виде LKKeyMask string // ключ кабинета в маскированном виде
LKUsers []LKUser // учётные записи кабинета LKUsers []LKUser // учётные записи кабинета
LKUsersError string LKUsersError string
LKPreview LKPreviewStatus // кабинет на этом сервере для просмотра
Versions SystemVersions // версии компонентов bj и внешних систем Versions SystemVersions // версии компонентов bj и внешних систем
Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак) Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак)
Features []LicenseFeatureView // признаки лицензии для карточки Features []LicenseFeatureView // признаки лицензии для карточки
@@ -385,6 +387,7 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
DepoTables: depoTablesView(s.Fancy), DepoTables: depoTablesView(s.Fancy),
QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"), QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"),
LKAgent: lkAgentStatus(s), LKAgent: lkAgentStatus(s),
LKPreview: lkPreviewStatus(h.rc, req),
LKKeyMask: lkKeyForPage(s.LK.APIKey), LKKeyMask: lkKeyForPage(s.LK.APIKey),
Versions: systemVersions(h.rc), Versions: systemVersions(h.rc),
Modules: modulesAllowed(h.rc), Modules: modulesAllowed(h.rc),
@@ -681,6 +681,20 @@ function hbBind(inp,box,list,onPick){
<p class="muted" style="font-size:12px;margin-top:6px">В архиве для своего сервера: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p> <p class="muted" style="font-size:12px;margin-top:6px">В архиве для своего сервера: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
</div> </div>
<div class="card" id="sec-lk-preview">
<h2><span class="dot {{if .LKPreview.On}}ok{{else}}warn{{end}}"></span>Кабинет на этом сервере — для просмотра</h2>
<p class="muted">Посмотреть кабинет глазами клиента можно, не размещая его на хостинге: ядро поднимает тот же кабинет на отдельном порту. Открывается по http из сети депозитария, поэтому подходит для проверки, но не для клиентов из интернета — им кабинет публикуется через хостинг или nginx с сертификатом.</p>
<form method="post" action="/admin/setup/lk/preview" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-top:10px">
<input type="hidden" name="section" value="sec-lk-preview">
<label style="display:flex;gap:8px;align-items:center;font-weight:600;margin:0"><input type="checkbox" name="preview_on" value="1" {{if .Settings.LK.PreviewOn}}checked{{end}}> Запустить кабинет на этом сервере</label>
<div><label>Порт</label><input type="text" name="preview_listen" value="{{.LKPreview.Listen}}" style="width:120px;margin-top:4px"></div>
<button type="submit" class="btn btn-ok">Применить</button>
{{if .LKPreview.On}}<a class="btn" href="{{.LKPreview.URL}}" target="_blank" rel="noopener">Открыть кабинет →</a>{{end}}
</form>
{{if .LKPreview.Err}}<p class="muted" style="color:var(--err);font-size:12px;margin-top:8px">Не запущен: {{.LKPreview.Err}}</p>{{end}}
<p class="muted" style="font-size:12px;margin-top:8px">Вход — по учётным записям из карточки «Пользователи кабинета» ниже; сотрудник видит список клиентов, клиент — свой портфель. Данные те же, что получит кабинет на хостинге.</p>
</div>
<div class="card" id="sec-lk-users"> <div class="card" id="sec-lk-users">
<h2><span class="dot {{if .LKUsers}}ok{{else}}warn{{end}}"></span>Пользователи кабинета</h2> <h2><span class="dot {{if .LKUsers}}ok{{else}}warn{{end}}"></span>Пользователи кабинета</h2>
<p class="muted">Учётные записи кабинета живут здесь, в базе ядра: кабинет на хостинге своей базы не имеет. Клиент привязан к номеру договора депозитария — по нему кабинет показывает портфель. Сотрудник («администратор кабинета») видит список клиентов и открывает их кабинеты на просмотр. <b>Письма не отправляются</b>: почта только хранится, пароль сообщите лично.</p> <p class="muted">Учётные записи кабинета живут здесь, в базе ядра: кабинет на хостинге своей базы не имеет. Клиент привязан к номеру договора депозитария — по нему кабинет показывает портфель. Сотрудник («администратор кабинета») видит список клиентов и открывает их кабинеты на просмотр. <b>Письма не отправляются</b>: почта только хранится, пароль сообщите лично.</p>
+9 -2
View File
@@ -46,8 +46,15 @@ func localRequest(r *http.Request) bool {
return ip != nil && ip.IsLoopback() return ip != nil && ip.IsLoopback()
} }
// secureCookies — ставить ли признак Secure на куки этого запроса. // secureCookies — ставить ли признак Secure на куки этого запроса. Заголовок X-BJ-Preview
func secureCookies(r *http.Request) bool { return httpsRequest(r) || !localRequest(r) } // ставит само ядро, когда кабинет запущен на его порту для просмотра по http из локальной
// сети; в конвертах ретрансляторов такой заголовок отбрасывается.
func secureCookies(r *http.Request) bool {
if r.Header.Get("X-BJ-Preview") == "1" && !httpsRequest(r) {
return false
}
return httpsRequest(r) || !localRequest(r)
}
// clientIP — адрес посетителя кабинета (за nginx — из X-Real-IP / X-Forwarded-For). // clientIP — адрес посетителя кабинета (за nginx — из X-Real-IP / X-Forwarded-For).
func clientIP(r *http.Request) string { func clientIP(r *http.Request) string {