diff --git a/internal/lkgateway/lkpreview.go b/internal/lkgateway/lkpreview.go new file mode 100644 index 0000000..9c0eec4 --- /dev/null +++ b/internal/lkgateway/lkpreview.go @@ -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, "Кабинет на этом сервере выключен.") + } +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index bb8a67c..11005ea 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -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"` diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 777b135..a0627c8 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -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 мин — время ответов регламентировано. diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index b769ad3..e024e34 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -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), diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 95a0c51..257a0cd 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -681,6 +681,20 @@ function hbBind(inp,box,list,onPick){
В архиве для своего сервера: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.
+Посмотреть кабинет глазами клиента можно, не размещая его на хостинге: ядро поднимает тот же кабинет на отдельном порту. Открывается по http из сети депозитария, поэтому подходит для проверки, но не для клиентов из интернета — им кабинет публикуется через хостинг или nginx с сертификатом.
+ + {{if .LKPreview.Err}}Не запущен: {{.LKPreview.Err}}
{{end}} +Вход — по учётным записям из карточки «Пользователи кабинета» ниже; сотрудник видит список клиентов, клиент — свой портфель. Данные те же, что получит кабинет на хостинге.
+Учётные записи кабинета живут здесь, в базе ядра: кабинет на хостинге своей базы не имеет. Клиент привязан к номеру договора депозитария — по нему кабинет показывает портфель. Сотрудник («администратор кабинета») видит список клиентов и открывает их кабинеты на просмотр. Письма не отправляются: почта только хранится, пароль сообщите лично.
diff --git a/modules/lk/security.go b/modules/lk/security.go index 10f59a2..d543383 100644 --- a/modules/lk/security.go +++ b/modules/lk/security.go @@ -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 {