Кабинет клиента на этом сервере для просмотра: включается в настройках, отдельный порт (: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:
@@ -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, "Кабинет на этом сервере выключен.")
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,10 @@ type LKSettings struct {
|
||||
AgentMode bool `json:"lk_agent_mode,omitempty"`
|
||||
// CabinetURL — адрес кабинета на хостинге, куда подключается ядро в режиме ожидания.
|
||||
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 bool `json:"lk_bind_network,omitempty"`
|
||||
|
||||
@@ -295,6 +295,7 @@ func (s *Server) Run(ctx context.Context) error {
|
||||
// Кабинет клиента в режиме ожидания: ядро само подключается к кабинету на
|
||||
// хостинге и обслуживает запросы клиентов через исходящее соединение.
|
||||
go superviseLoop(ctx, "lk-agent", s.rc, s.lkAgentLoop)
|
||||
lkPreviewApply(s.rc) // кабинет на этом сервере для просмотра, если включён в настройках
|
||||
|
||||
// Поллер заявок ЛК ЕСИА Финанс: раз в минуту забирает новые depo_m2m и
|
||||
// отправляет в НРД (или имитатору). Интервал 1 мин — время ответов регламентировано.
|
||||
|
||||
@@ -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/users", h.lkUsersSave)
|
||||
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-check", h.checkLKConn)
|
||||
mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate)
|
||||
@@ -320,6 +321,7 @@ type SetupData struct {
|
||||
LKKeyMask string // ключ кабинета в маскированном виде
|
||||
LKUsers []LKUser // учётные записи кабинета
|
||||
LKUsersError string
|
||||
LKPreview LKPreviewStatus // кабинет на этом сервере для просмотра
|
||||
Versions SystemVersions // версии компонентов bj и внешних систем
|
||||
Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак)
|
||||
Features []LicenseFeatureView // признаки лицензии для карточки
|
||||
@@ -385,6 +387,7 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
|
||||
DepoTables: depoTablesView(s.Fancy),
|
||||
QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"),
|
||||
LKAgent: lkAgentStatus(s),
|
||||
LKPreview: lkPreviewStatus(h.rc, req),
|
||||
LKKeyMask: lkKeyForPage(s.LK.APIKey),
|
||||
Versions: systemVersions(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>
|
||||
</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">
|
||||
<h2><span class="dot {{if .LKUsers}}ok{{else}}warn{{end}}"></span>Пользователи кабинета</h2>
|
||||
<p class="muted">Учётные записи кабинета живут здесь, в базе ядра: кабинет на хостинге своей базы не имеет. Клиент привязан к номеру договора депозитария — по нему кабинет показывает портфель. Сотрудник («администратор кабинета») видит список клиентов и открывает их кабинеты на просмотр. <b>Письма не отправляются</b>: почта только хранится, пароль сообщите лично.</p>
|
||||
|
||||
@@ -46,8 +46,15 @@ func localRequest(r *http.Request) bool {
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// secureCookies — ставить ли признак Secure на куки этого запроса.
|
||||
func secureCookies(r *http.Request) bool { return httpsRequest(r) || !localRequest(r) }
|
||||
// secureCookies — ставить ли признак Secure на куки этого запроса. Заголовок X-BJ-Preview
|
||||
// ставит само ядро, когда кабинет запущен на его порту для просмотра по 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).
|
||||
func clientIP(r *http.Request) string {
|
||||
|
||||
Reference in New Issue
Block a user