Кабинет: страницы собирает ядро, на хостинге — ретранслятор (Go и PHP)
- modules/lk: конверт запроса/ответа (PageRequest/PageResponse), ServeEnvelope — тот же обработчик страниц внутри ядра; клиент к API внутри процесса (Config.Local/LocalKey); режим ретранслятора (Config.Relay, BJ_RENDER=core, выбор в установщике и lk.json) - ядро: встроенный кабинет (lkcabinet.go), POST /api/lk/render для прямого режима, цикл режима ожидания выполняет конверты страниц; API через ретранслятор недостижимо - PHP-ретранслятор для обычного хостинга (modules/lk/relay-php: index.php, .htaccess, README): установщик в браузере, прямой режим через curl, режим ожидания через очередь в SQLite (BEGIN IMMEDIATE, busy_timeout), конфиг в bj-lk-data рядом с сайтом или в data под .htaccess; архив со страницы настроек (/admin/setup/lk/relay-php.zip) - настройки: карточка НПР/УДС перенесена из раздела «Лицензия» в «Экспорт QUIK»; кнопка «Обновить с сервера лицензий» видна при любом ключе - инструкция /admin/help/lk: два варианта размещения, шаги для PHP-хостинга - тесты: TestLKRelayRendersInCore (оба режима), TestLKPHPRelayAgentMode (настоящий PHP против цикла ядра, PHP_BIN), проверка безопасности через PHP-ретранслятор 34/34 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
@@ -15,7 +15,6 @@ package lkgateway
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -25,6 +24,8 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk"
|
||||
)
|
||||
|
||||
// lkAgentState — состояние канала для страницы настроек.
|
||||
@@ -125,8 +126,8 @@ func (s *Server) lkAgentWorker(ctx context.Context) {
|
||||
if task == nil {
|
||||
continue // запросов не было, кабинет отпустил соединение — подключаемся заново
|
||||
}
|
||||
status, body := s.lkAgentServe(ctx, task)
|
||||
if err := lkAgentReply(ctx, client, cabinet, cfg.APIKey, task.ID, status, body); err != nil {
|
||||
res := s.lkAgentServe(ctx, task)
|
||||
if err := lkAgentReply(ctx, client, cabinet, cfg.APIKey, res); err != nil {
|
||||
log.Printf("кабинет: ответ на запрос %s не доставлен: %v", task.ID, err)
|
||||
continue
|
||||
}
|
||||
@@ -134,17 +135,8 @@ func (s *Server) lkAgentWorker(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// lkAgentTask — запрос, ожидающий в кабинете.
|
||||
type lkAgentTask struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Session string `json:"session"`
|
||||
Body string `json:"body"` // base64
|
||||
// Адрес и браузер посетителя кабинета — для привязки сессии и лимита попыток входа.
|
||||
ClientIP string `json:"client_ip"`
|
||||
ClientAgent string `json:"client_agent"`
|
||||
}
|
||||
// lkAgentTask — запрос, ожидающий в кабинете: вызов API или страница в конверте.
|
||||
type lkAgentTask = lk.PageRequest
|
||||
|
||||
// lkAgentFetch — забрать очередной запрос. nil без ошибки — запросов не было.
|
||||
func lkAgentFetch(ctx context.Context, c *http.Client, cabinet, key string) (*lkAgentTask, error) {
|
||||
@@ -166,7 +158,7 @@ func lkAgentFetch(ctx context.Context, c *http.Client, cabinet, key string) (*lk
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.ID == "" || !strings.HasPrefix(t.Path, "/api/lk/") {
|
||||
if t.ID == "" || (t.Kind != "page" && !strings.HasPrefix(t.Path, "/api/lk/")) {
|
||||
return nil, fmt.Errorf("кабинет прислал неизвестный запрос")
|
||||
}
|
||||
return &t, nil
|
||||
@@ -177,14 +169,21 @@ func lkAgentFetch(ctx context.Context, c *http.Client, cabinet, key string) (*lk
|
||||
}
|
||||
}
|
||||
|
||||
// lkAgentServe — выполнить запрос кабинета у себя: тот же обработчик /api/lk/*.
|
||||
func (s *Server) lkAgentServe(ctx context.Context, t *lkAgentTask) (int, []byte) {
|
||||
body, _ := base64.StdEncoding.DecodeString(t.Body)
|
||||
// lkAgentServe — выполнить запрос кабинета у себя: страницу — встроенным кабинетом,
|
||||
// вызов API — тем же обработчиком /api/lk/*, что и по сети.
|
||||
func (s *Server) lkAgentServe(ctx context.Context, t *lkAgentTask) lk.PageResponse {
|
||||
if t.Kind == "page" {
|
||||
if globalLKCabinet == nil {
|
||||
return lk.PageResponse{ID: t.ID, Status: http.StatusServiceUnavailable, Body: []byte("кабинет в ядре не запущен")}
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
|
||||
return globalLKCabinet.serve(req, *t)
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, t.Method, t.Path, bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(cctx, t.Method, t.Path, bytes.NewReader(t.Body))
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, []byte(`{"error":"некорректный запрос кабинета"}`)
|
||||
return lk.PageResponse{ID: t.ID, Status: http.StatusBadRequest, Body: []byte(`{"error":"некорректный запрос кабинета"}`)}
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-BJ-Key", s.rc.Snapshot().LK.APIKey) // запрос пришёл по ключу, он же идёт дальше
|
||||
@@ -200,7 +199,7 @@ func (s *Server) lkAgentServe(ctx context.Context, t *lkAgentTask) (int, []byte)
|
||||
req.RemoteAddr = "127.0.0.1:0" // обращение пришло по нашему исходящему каналу
|
||||
rec := &lkAgentRecorder{status: http.StatusOK}
|
||||
s.mux.ServeHTTP(rec, req)
|
||||
return rec.status, rec.buf.Bytes()
|
||||
return lk.PageResponse{ID: t.ID, Status: rec.status, Body: rec.buf.Bytes()}
|
||||
}
|
||||
|
||||
// lkAgentRecorder — приёмник ответа обработчика: пишем в память, а не в сеть.
|
||||
@@ -220,9 +219,8 @@ func (w *lkAgentRecorder) Write(b []byte) (int, error) { return w.buf.Write(b) }
|
||||
func (w *lkAgentRecorder) WriteHeader(code int) { w.status = code }
|
||||
|
||||
// lkAgentReply — вернуть кабинету ответ на его запрос.
|
||||
func lkAgentReply(ctx context.Context, c *http.Client, cabinet, key, id string, status int, body []byte) error {
|
||||
payload, _ := json.Marshal(map[string]any{"id": id, "status": status,
|
||||
"body": base64.StdEncoding.EncodeToString(body)})
|
||||
func lkAgentReply(ctx context.Context, c *http.Client, cabinet, key string, res lk.PageResponse) error {
|
||||
payload, _ := json.Marshal(res)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cabinet+"/agent/result", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,8 +3,10 @@ package lkgateway
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -88,3 +90,94 @@ func TestLKAgentRejectsForeignPath(t *testing.T) {
|
||||
t.Fatalf("чужой путь принят: %+v", task)
|
||||
}
|
||||
}
|
||||
|
||||
// Ретранслятор: страницы собирает ядро. Прямой режим — конверт через POST /api/lk/render,
|
||||
// режим ожидания — ядро забирает конверт из очереди ретранслятора. В обоих случаях браузер
|
||||
// получает страницу входа с защитными заголовками и кукой, выставленными ядром.
|
||||
func TestLKRelayRendersInCore(t *testing.T) {
|
||||
const key = "lk-relay-test-key"
|
||||
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lkCfg := rc.Snapshot().LK
|
||||
lkCfg.APIKey = key
|
||||
if err := rc.UpdateLK(lkCfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
registerLKAPI(mux, rc, nil)
|
||||
coreSrv := httptest.NewServer(mux)
|
||||
defer coreSrv.Close()
|
||||
|
||||
check := func(name string, relay *lk.Server) {
|
||||
t.Helper()
|
||||
rs := httptest.NewServer(relay.Handler())
|
||||
defer rs.Close()
|
||||
client := &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
req, _ := http.NewRequest(http.MethodGet, rs.URL+"/login", nil)
|
||||
req.Header.Set("User-Agent", "Проверочный браузер")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Логин или номер договора") {
|
||||
t.Fatalf("%s: код %d, тело %.200s", name, resp.StatusCode, body)
|
||||
}
|
||||
if resp.Header.Get("Content-Security-Policy") == "" || resp.Header.Get("X-Frame-Options") != "DENY" {
|
||||
t.Errorf("%s: защитные заголовки ядра не дошли до браузера", name)
|
||||
}
|
||||
var csrf bool
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == "bj_lk_csrf" && c.HttpOnly {
|
||||
csrf = true
|
||||
}
|
||||
}
|
||||
if !csrf {
|
||||
t.Errorf("%s: кука формы от ядра не дошла", name)
|
||||
}
|
||||
// API ядра через ретранслятор недостижимо: ядро считает это страницей и отвечает 404
|
||||
resp2, err := client.Get(rs.URL + "/api/lk/ping")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s: /api/lk/ping через ретранслятор: код %d, ожидали 404", name, resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
direct, err := lk.New(lk.Config{BJKey: key, BJURL: coreSrv.URL, Relay: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("прямой режим", direct)
|
||||
|
||||
agent, err := lk.New(lk.Config{BJKey: key, Agent: true, Relay: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agentSrv := httptest.NewServer(agent.Handler())
|
||||
defer agentSrv.Close()
|
||||
lkCfg.AgentMode, lkCfg.CabinetURL = true, agentSrv.URL
|
||||
if err := rc.UpdateLK(lkCfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core := &Server{rc: rc, mux: mux}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go core.lkAgentLoop(ctx)
|
||||
// у режима ожидания ретранслятор уже запущен как agentSrv — проверяем через него
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Get(agentSrv.URL + "/login")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Логин или номер договора") {
|
||||
t.Fatalf("режим ожидания: код %d, тело %.200s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
sess := newLKSessions()
|
||||
sess.bindNetwork = func() bool { return rc.Snapshot().LK.BindNetwork }
|
||||
globalLKSessions = sess
|
||||
globalLKCabinet = newLKCabinet(mux, rc) // кабинет внутри ядра: страницы для ретранслятора
|
||||
|
||||
// проверка ключа модуля
|
||||
auth := func(w http.ResponseWriter, r *http.Request) bool {
|
||||
@@ -59,6 +60,8 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
return u, true
|
||||
}
|
||||
|
||||
globalLKCabinet.registerRender(mux, auth)
|
||||
|
||||
mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package lkgateway
|
||||
|
||||
// lkcabinet.go — кабинет клиента, собранный внутрь ядра.
|
||||
//
|
||||
// Страницы кабинета рисует ядро: у него все данные и шаблоны (пакет modules/lk — тот же,
|
||||
// что собирается в самостоятельный bj-lk). На хостинге остаётся ретранслятор, который
|
||||
// передаёт запросы браузера конвертами: в прямом режиме — POST /api/lk/render, в режиме
|
||||
// ожидания — ядро само забирает конверты из очереди ретранслятора (lkagent.go).
|
||||
//
|
||||
// Данные встроенный кабинет берёт через тот же API /api/lk/*, вызывая обработчик внутри
|
||||
// процесса: граница проверок одна и та же, где бы кабинет ни работал.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk"
|
||||
)
|
||||
|
||||
// globalLKCabinet — встроенный кабинет; им пользуется и цикл режима ожидания.
|
||||
var globalLKCabinet *lkCabinet
|
||||
|
||||
// lkCabinet — встроенный кабинет; пересобирается, если сменился заголовок.
|
||||
type lkCabinet struct {
|
||||
mu sync.Mutex
|
||||
api http.Handler
|
||||
rc *RuntimeConfig
|
||||
srv *lk.Server
|
||||
title string
|
||||
}
|
||||
|
||||
func newLKCabinet(api http.Handler, rc *RuntimeConfig) *lkCabinet {
|
||||
return &lkCabinet{api: api, rc: rc}
|
||||
}
|
||||
|
||||
// server — кабинет с актуальным заголовком организации.
|
||||
func (c *lkCabinet) server() (*lk.Server, error) {
|
||||
s := c.rc.Snapshot()
|
||||
title := strings.TrimSpace(orgName(s))
|
||||
if title == "" {
|
||||
title = "Личный кабинет"
|
||||
} else {
|
||||
title += " · личный кабинет"
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.srv != nil && c.title == title {
|
||||
return c.srv, nil
|
||||
}
|
||||
srv, err := lk.New(lk.Config{Title: title, Local: c.api, LocalKey: func() string { return c.rc.Snapshot().LK.APIKey }})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.srv, c.title = srv, title
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// serve — выполнить конверт страницы.
|
||||
func (c *lkCabinet) serve(r *http.Request, env lk.PageRequest) lk.PageResponse {
|
||||
srv, err := c.server()
|
||||
if err != nil {
|
||||
log.Printf("кабинет: не собран: %v", err)
|
||||
return lk.PageResponse{ID: env.ID, Status: http.StatusInternalServerError, Body: []byte("кабинет не собран")}
|
||||
}
|
||||
return srv.ServeEnvelope(r.Context(), env)
|
||||
}
|
||||
|
||||
// registerRender — POST /api/lk/render: ретранслятор в прямом режиме присылает конверт
|
||||
// запроса браузера, получает конверт ответа. Только по ключу модуля.
|
||||
func (c *lkCabinet) registerRender(mux *http.ServeMux, auth func(http.ResponseWriter, *http.Request) bool) {
|
||||
mux.HandleFunc("/api/lk/render", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
lkJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "нужен POST"})
|
||||
return
|
||||
}
|
||||
var env lk.PageRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 2<<20)).Decode(&env); err != nil {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "конверт не разобран"})
|
||||
return
|
||||
}
|
||||
if env.Kind != "page" {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "через render идут только страницы"})
|
||||
return
|
||||
}
|
||||
lkJSON(w, http.StatusOK, c.serve(r, env))
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ package lkgateway
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@@ -17,6 +18,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk"
|
||||
)
|
||||
|
||||
// lkDistBinary — где лежит исполняемый файл кабинета на этом сервере (переменная — для тестов).
|
||||
@@ -332,3 +335,26 @@ func cabinetHint(s Settings) string {
|
||||
}
|
||||
return "https://lk.example.ru"
|
||||
}
|
||||
|
||||
// lkPHPRelayDownload — GET /admin/setup/lk/relay-php.zip: ретранслятор для обычного
|
||||
// PHP-хостинга. Ключа в архиве нет — его спрашивает установщик в браузере.
|
||||
func (h *setupHandlers) lkPHPRelayDownload(w http.ResponseWriter, r *http.Request) {
|
||||
files := lk.PHPRelayFiles()
|
||||
if len(files) == 0 {
|
||||
setupFlash(w, r, "Ретранслятор PHP не собран в эту версию ядра")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="bj-lk-php-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
for _, name := range []string{"index.php", ".htaccess", "README.txt"} {
|
||||
fw, err := zw.CreateHeader(&zip.FileHeader{Name: "bj-lk-php/" + name, Method: zip.Deflate, Modified: time.Now()})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := fw.Write(files[name]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PHP-ретранслятор в режиме ожидания против настоящего цикла ядра. Нужен интерпретатор
|
||||
// PHP: путь в переменной PHP_BIN, иначе тест пропускается.
|
||||
func TestLKPHPRelayAgentMode(t *testing.T) {
|
||||
phpBin := os.Getenv("PHP_BIN")
|
||||
if phpBin == "" {
|
||||
t.Skip("PHP_BIN не задан")
|
||||
}
|
||||
const key = "lk-php-agent-key"
|
||||
site := filepath.Join(t.TempDir(), "site")
|
||||
if err := os.MkdirAll(site, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src, err := os.ReadFile("../../modules/lk/relay-php/index.php")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(site, "index.php"), src, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// настройки сразу в файле — установщик проверен отдельно
|
||||
data := filepath.Join(filepath.Dir(site), "bj-lk-data")
|
||||
_ = os.MkdirAll(data, 0o700)
|
||||
if err := os.WriteFile(filepath.Join(data, "config.php"), []byte("<?php exit; ?>\n{\"mode\":\"agent\",\"bj_key\":\""+key+"\",\"title\":\"PHP\"}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const addr = "127.0.0.1:18097"
|
||||
cmd := exec.Command(phpBin, "-S", addr, "index.php")
|
||||
cmd.Dir = site
|
||||
// Встроенный сервер PHP однопоточный, а очередь требует двух одновременных запросов
|
||||
// (ждущий браузер и подключение ядра). На хостинге это обеспечивает php-fpm/Apache.
|
||||
cmd.Env = append(os.Environ(), "PHP_CLI_SERVER_WORKERS=4")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }()
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lkCfg := rc.Snapshot().LK
|
||||
lkCfg.APIKey, lkCfg.AgentMode, lkCfg.CabinetURL = key, true, "http://"+addr
|
||||
if err := rc.UpdateLK(lkCfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
registerLKAPI(mux, rc, nil)
|
||||
core := &Server{rc: rc, mux: mux}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go core.lkAgentLoop(ctx)
|
||||
|
||||
client := &http.Client{Timeout: 40 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
resp, err := client.Get("http://" + addr + "/login")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Логин или номер договора") {
|
||||
t.Fatalf("страница через PHP-очередь: код %d, тело %.300s", resp.StatusCode, body)
|
||||
}
|
||||
if resp.Header.Get("X-Frame-Options") != "DENY" || resp.Header.Get("Content-Security-Policy") == "" {
|
||||
t.Error("защитные заголовки ядра не дошли через PHP")
|
||||
}
|
||||
var csrf bool
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == "bj_lk_csrf" && c.HttpOnly {
|
||||
csrf = true
|
||||
}
|
||||
}
|
||||
if !csrf {
|
||||
t.Error("кука формы не дошла через PHP")
|
||||
}
|
||||
// без ключа очередь закрыта
|
||||
resp2, err := client.Get("http://" + addr + "/agent/tasks")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("/agent/tasks без ключа: код %d", resp2.StatusCode)
|
||||
}
|
||||
// состояние связи для администратора — по ключу
|
||||
req, _ := http.NewRequest(http.MethodGet, "http://"+addr+"/health", nil)
|
||||
req.Header.Set("X-BJ-Key", key)
|
||||
resp3, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, _ := io.ReadAll(resp3.Body)
|
||||
resp3.Body.Close()
|
||||
if resp3.StatusCode != http.StatusOK || !strings.Contains(string(h), `"mode":"agent"`) {
|
||||
t.Errorf("/health по ключу: %d %s", resp3.StatusCode, h)
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
mux.HandleFunc("/admin/setup/lk/dist", h.lkSaveDist)
|
||||
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-integration", h.saveLKIntegration)
|
||||
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
|
||||
mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate)
|
||||
|
||||
@@ -5,10 +5,21 @@
|
||||
<h2>Как разместить кабинет клиента на хостинге</h2>
|
||||
<p class="muted">Речь о нашем модуле <code>bj-lk</code> — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.</p>
|
||||
|
||||
<h3>Два варианта размещения</h3>
|
||||
<table>
|
||||
<tr><th>Где</th><th>Что скачать</th><th>Как работает</th></tr>
|
||||
<tr><td>Обычный хостинг с PHP («папка сайта»)</td><td>«Скачать для PHP-хостинга» — <code>index.php</code> и <code>.htaccess</code></td><td>Страницы собирает ядро. На хостинге лежит ретранслятор: он передаёт запросы браузера ядру и отдаёт ответы. Ни базы, ни данных клиентов на хостинге нет. Нужны PHP 8.1+, curl, sqlite.</td></tr>
|
||||
<tr><td>Свой сервер или VPS с Linux</td><td>«Скачать для своего сервера» — программа <code>bj-lk</code></td><td>Установщик спросит, где собирать страницы: в ядре (программа — такой же ретранслятор) или на месте (программа рисует страницы сама из данных ядра).</td></tr>
|
||||
</table>
|
||||
<p>В обоих случаях ключа в архиве нет — его вводят в установщике, открыв адрес кабинета в браузере. Способы связи с ядром (кабинет → ядро или ядро → кабинет) одинаковы для всех вариантов.</p>
|
||||
|
||||
<h3>Шаг 1. Ключ и архив</h3>
|
||||
<p>Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа (он показан на странице целиком — его нужно ввести в установщике) и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек службы, установщик и README. Ключа в архиве нет.</p>
|
||||
|
||||
<h3>Шаг 2. Файлы на сервер и запуск</h3>
|
||||
<h3>Шаг 2а. PHP-хостинг: файлы в каталог сайта</h3>
|
||||
<p>Создайте в панели хостинга домен (например, <code>lk.депозитарий.ru</code>) с сертификатом, скопируйте <code>index.php</code> и <code>.htaccess</code> в его каталог и откройте адрес в браузере — запустится установщик (шаг 3). Настройки ретранслятор положит в каталог <code>bj-lk-data</code> рядом с сайтом, если хостинг разрешает туда писать, иначе — в <code>data</code> внутри сайта, закрытый правилами <code>.htaccess</code>. Если у хостинга nginx без <code>.htaccess</code> — правила из README архива.</p>
|
||||
|
||||
<h3>Шаг 2б. Свой сервер: файлы на сервер и запуск</h3>
|
||||
<pre>tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
|
||||
cd bj-lk
|
||||
sudo ./install.sh # на своём сервере: заводит службу bj-lk
|
||||
|
||||
@@ -382,6 +382,20 @@
|
||||
{{/* ============ ЭКСПОРТ БИРЖЕВОЙ ИНФОРМАЦИИ (QUIK) ============ */}}
|
||||
<section class="settings-section" id="sec-quikexport">
|
||||
{{.QuikExport.HTML}}
|
||||
|
||||
{{/* модуль НПР/УДС работает поверх базы экспорта QUIK — его настройки здесь же */}}
|
||||
<div class="card" id="sec-npr">
|
||||
<h2><span class="dot {{if .Settings.NPR.Enabled}}ok{{else}}warn{{end}}"></span>Модуль «НПР / УДС» (риск-монитор по данным QUIK)</h2>
|
||||
<p class="muted">Показывает НПР1 / НПР2 / УДС клиентов маржинальной торговли по базе экспорта QUIK (схема <code>npr</code>). Отдельно лицензируемый модуль (признак <code>npr</code>); при включении в меню появляется пункт «НПР / УДС». Подключение — логин только для чтения.</p>
|
||||
{{template "verline" (.Versions.Get "bj-npr")}}
|
||||
<form method="post" action="/admin/setup/npr" style="margin-top:10px;display:grid;gap:10px;max-width:640px">
|
||||
<input type="hidden" name="section" value="sec-npr">
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:600"><input type="checkbox" name="enabled" value="1" {{if .Settings.NPR.Enabled}}checked{{end}}> Включить модуль</label>
|
||||
<label>Строка подключения к базе экспорта QUIK <input type="text" name="dsn" value="{{.Settings.NPR.DSN}}" placeholder="postgres://quik_ro:пароль@localhost:5432/quik_export" style="width:100%;font-family:ui-monospace,monospace;font-size:12px"></label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Сохранить</button><button type="submit" formaction="/admin/setup/npr/test" class="btn">Проверить подключение</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<h1>Лицензия</h1>
|
||||
</section>
|
||||
|
||||
{{/* ============ КРИПТОГРАФИЯ ============ */}}
|
||||
@@ -642,7 +656,8 @@ function hbBind(inp,box,list,onPick){
|
||||
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button type="submit" class="btn btn-ok">Сохранить</button>
|
||||
{{if .Settings.LK.APIKey}}<a class="btn" href="/admin/setup/lk/dist.tar.gz">Скачать дистрибутив кабинета</a>{{end}}
|
||||
{{if .Settings.LK.APIKey}}<a class="btn" href="/admin/setup/lk/dist.tar.gz">Скачать для своего сервера (Linux)</a>
|
||||
<a class="btn" href="/admin/setup/lk/relay-php.zip">Скачать для PHP-хостинга</a>{{end}}
|
||||
<a class="btn btn-secondary" href="/admin/help/lk">Инструкция по размещению</a>
|
||||
</div>
|
||||
</form>
|
||||
@@ -658,7 +673,8 @@ function hbBind(inp,box,list,onPick){
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="muted" style="font-size:12px;margin-top:10px">В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
|
||||
<p class="muted" style="font-size:12px;margin-top:10px"><b>Где собираются страницы.</b> Для PHP-хостинга — всегда в ядре: на хостинге лежит только ретранслятор (index.php), который передаёт запросы браузера ядру. Для своего сервера установщик спросит: собирать страницы в ядре (тогда программа на сервере — тоже ретранслятор, новые разделы появляются обновлением ядра) или на месте. Ключа ни в одном архиве нет.</p>
|
||||
<p class="muted" style="font-size:12px;margin-top:6px">В архиве для своего сервера: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="sec-lk-users">
|
||||
@@ -1040,18 +1056,6 @@ function hbBind(inp,box,list,onPick){
|
||||
|
||||
{{/* ============ ЛИЦЕНЗИЯ ============ */}}
|
||||
<section class="settings-section" id="sec-license">
|
||||
<div class="card" id="sec-npr">
|
||||
<h2><span class="dot {{if .Settings.NPR.Enabled}}ok{{else}}warn{{end}}"></span>Модуль «НПР / УДС» (риск-монитор по данным QUIK)</h2>
|
||||
<p class="muted">Показывает НПР1 / НПР2 / УДС клиентов маржинальной торговли по базе экспорта QUIK (схема <code>npr</code>). Отдельно лицензируемый модуль (признак <code>npr</code>); при включении в меню появляется пункт «НПР / УДС». Подключение — логин только для чтения.</p>
|
||||
{{template "verline" (.Versions.Get "bj-npr")}}
|
||||
<form method="post" action="/admin/setup/npr" style="margin-top:10px;display:grid;gap:10px;max-width:640px">
|
||||
<input type="hidden" name="section" value="sec-npr">
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:600"><input type="checkbox" name="enabled" value="1" {{if .Settings.NPR.Enabled}}checked{{end}}> Включить модуль</label>
|
||||
<label>Строка подключения к базе экспорта QUIK <input type="text" name="dsn" value="{{.Settings.NPR.DSN}}" placeholder="postgres://quik_ro:пароль@localhost:5432/quik_export" style="width:100%;font-family:ui-monospace,monospace;font-size:12px"></label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Сохранить</button><button type="submit" formaction="/admin/setup/npr/test" class="btn">Проверить подключение</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<h1>Лицензия</h1>
|
||||
<div class="card">
|
||||
<h2>
|
||||
{{if .License.Valid}}<span class="dot ok"></span>Активна
|
||||
@@ -1081,7 +1085,7 @@ function hbBind(inp,box,list,onPick){
|
||||
<p class="muted">Введите код активации от поставщика (вида <code>BJ-XXXX-XXXX-XXXX</code>). bj зарегистрирует эту установку на сервере лицензий <code>lic.zetit.ru</code> и дальше сам продлевает ключ раз в сутки. Серверу bj нужен доступ по HTTPS к lic.zetit.ru.</p>
|
||||
<form method="post" action="/admin/setup/license/activate" style="margin-top:12px;display:grid;gap:10px;max-width:520px">
|
||||
<label>Код активации <input type="text" name="code" placeholder="BJ-XXXX-XXXX-XXXX" autocomplete="off" spellcheck="false" style="width:100%;font-family:ui-monospace,monospace;letter-spacing:.04em"></label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Активировать</button>{{if .License.Online}}<button type="submit" formaction="/admin/setup/license/renew" class="btn">Продлить сейчас</button>{{end}}</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Активировать</button>{{if .License.Present}}<button type="submit" formaction="/admin/setup/license/renew" class="btn" title="Запросить у сервера лицензий свежий ключ: срок, модули, режим разработки">Обновить с сервера лицензий</button>{{end}}</div>
|
||||
</form>
|
||||
<p class="muted" style="font-size:12px;margin-top:12px">Установка <code>{{if .Settings.License.InstallID}}{{.Settings.License.InstallID}}{{else}}будет создана при первом обращении{{end}}</code>{{if not .Settings.License.LastSync.IsZero}} · последнее продление {{.Settings.License.LastSync.Format "02.01.2006 15:04"}}{{end}}</p>
|
||||
{{if .Settings.License.LastMsg}}<p style="font-size:13px;margin-top:6px;{{if .Settings.License.LastOK}}color:var(--ok){{else}}color:var(--err){{end}}">{{if .Settings.License.LastOK}}✓{{else}}✗{{end}} {{.Settings.License.LastMsg}} <span class="muted">({{.Settings.License.LastAttempt.Format "02.01.2006 15:04"}})</span></p>{{end}}
|
||||
|
||||
Reference in New Issue
Block a user