diff --git a/cmd/bj-lk/main.go b/cmd/bj-lk/main.go index 38570f3..560264c 100644 --- a/cmd/bj-lk/main.go +++ b/cmd/bj-lk/main.go @@ -9,6 +9,7 @@ // BJ_URL адрес bj-server (напр. https://bj.example.ru) // BJ_KEY ключ доступа модуля (выдаётся в админке bj-server) // BJ_MODE direct | agent (agent — ядро подключается к кабинету само) +// BJ_RENDER local | core (core — страницы собирает ядро, здесь ретранслятор) // LISTEN адрес прослушивания (по умолчанию :8081) // LK_TITLE название кабинета в шапке (по умолчанию «Личный кабинет») // @@ -33,7 +34,8 @@ var version = "0.1.0" func main() { cfg := lk.Config{BJURL: os.Getenv("BJ_URL"), BJKey: os.Getenv("BJ_KEY"), Title: os.Getenv("LK_TITLE"), - Agent: strings.EqualFold(strings.TrimSpace(os.Getenv("BJ_MODE")), "agent")} + Agent: strings.EqualFold(strings.TrimSpace(os.Getenv("BJ_MODE")), "agent"), + Relay: strings.EqualFold(strings.TrimSpace(os.Getenv("BJ_RENDER")), "core")} listen := os.Getenv("LISTEN") flag.BoolVar(&cfg.Agent, "agent", cfg.Agent, "режим ожидания: ядро подключается к кабинету само") flag.StringVar(&cfg.BJURL, "bj-url", cfg.BJURL, "адрес bj-server (https://...)") diff --git a/internal/lkgateway/lkagent.go b/internal/lkgateway/lkagent.go index f0fac20..c4075a5 100644 --- a/internal/lkgateway/lkagent.go +++ b/internal/lkgateway/lkagent.go @@ -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 diff --git a/internal/lkgateway/lkagent_test.go b/internal/lkgateway/lkagent_test.go index 6f93343..b9d8387 100644 --- a/internal/lkgateway/lkagent_test.go +++ b/internal/lkgateway/lkagent_test.go @@ -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) + } +} diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go index 6dd817d..7b03ef7 100644 --- a/internal/lkgateway/lkapi.go +++ b/internal/lkgateway/lkapi.go @@ -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 diff --git a/internal/lkgateway/lkcabinet.go b/internal/lkgateway/lkcabinet.go new file mode 100644 index 0000000..b05c9ee --- /dev/null +++ b/internal/lkgateway/lkcabinet.go @@ -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)) + }) +} diff --git a/internal/lkgateway/lkdist.go b/internal/lkgateway/lkdist.go index d87f368..02dc469 100644 --- a/internal/lkgateway/lkdist.go +++ b/internal/lkgateway/lkdist.go @@ -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 + } + } +} diff --git a/internal/lkgateway/lkphp_test.go b/internal/lkgateway/lkphp_test.go new file mode 100644 index 0000000..d34accd --- /dev/null +++ b/internal/lkgateway/lkphp_test.go @@ -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("\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) + } +} diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index fa906fb..5d49841 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -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) diff --git a/internal/lkgateway/web/templates/admin_help_lk.html b/internal/lkgateway/web/templates/admin_help_lk.html index 4f13c67..c1c9e3e 100644 --- a/internal/lkgateway/web/templates/admin_help_lk.html +++ b/internal/lkgateway/web/templates/admin_help_lk.html @@ -5,10 +5,21 @@
Речь о нашем модуле bj-lk — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.
| Где | Что скачать | Как работает |
|---|---|---|
| Обычный хостинг с PHP («папка сайта») | «Скачать для PHP-хостинга» — index.php и .htaccess | Страницы собирает ядро. На хостинге лежит ретранслятор: он передаёт запросы браузера ядру и отдаёт ответы. Ни базы, ни данных клиентов на хостинге нет. Нужны PHP 8.1+, curl, sqlite. |
| Свой сервер или VPS с Linux | «Скачать для своего сервера» — программа bj-lk | Установщик спросит, где собирать страницы: в ядре (программа — такой же ретранслятор) или на месте (программа рисует страницы сама из данных ядра). |
В обоих случаях ключа в архиве нет — его вводят в установщике, открыв адрес кабинета в браузере. Способы связи с ядром (кабинет → ядро или ядро → кабинет) одинаковы для всех вариантов.
+Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа (он показан на странице целиком — его нужно ввести в установщике) и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек службы, установщик и README. Ключа в архиве нет.
-Создайте в панели хостинга домен (например, lk.депозитарий.ru) с сертификатом, скопируйте index.php и .htaccess в его каталог и откройте адрес в браузере — запустится установщик (шаг 3). Настройки ретранслятор положит в каталог bj-lk-data рядом с сайтом, если хостинг разрешает туда писать, иначе — в data внутри сайта, закрытый правилами .htaccess. Если у хостинга nginx без .htaccess — правила из README архива.
tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
cd bj-lk
sudo ./install.sh # на своём сервере: заводит службу bj-lk
diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html
index 15a57d2..f0dc425 100644
--- a/internal/lkgateway/web/templates/admin_setup.html
+++ b/internal/lkgateway/web/templates/admin_setup.html
@@ -382,6 +382,20 @@
{{/* ============ ЭКСПОРТ БИРЖЕВОЙ ИНФОРМАЦИИ (QUIK) ============ */}}
{{.QuikExport.HTML}}
+
+ {{/* модуль НПР/УДС работает поверх базы экспорта QUIK — его настройки здесь же */}}
+
+ Модуль «НПР / УДС» (риск-монитор по данным QUIK)
+ Показывает НПР1 / НПР2 / УДС клиентов маржинальной торговли по базе экспорта QUIK (схема npr). Отдельно лицензируемый модуль (признак npr); при включении в меню появляется пункт «НПР / УДС». Подключение — логин только для чтения.
+ {{template "verline" (.Versions.Get "bj-npr")}}
+
+
+ Лицензия
{{/* ============ КРИПТОГРАФИЯ ============ */}}
@@ -642,7 +656,8 @@ function hbBind(inp,box,list,onPick){
- {{if .Settings.LK.APIKey}}Скачать дистрибутив кабинета{{end}}
+ {{if .Settings.LK.APIKey}}Скачать для своего сервера (Linux)
+ Скачать для PHP-хостинга{{end}}
Инструкция по размещению
@@ -658,7 +673,8 @@ function hbBind(inp,box,list,onPick){
{{end}}
- В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.
+ Где собираются страницы. Для PHP-хостинга — всегда в ядре: на хостинге лежит только ретранслятор (index.php), который передаёт запросы браузера ядру. Для своего сервера установщик спросит: собирать страницы в ядре (тогда программа на сервере — тоже ретранслятор, новые разделы появляются обновлением ядра) или на месте. Ключа ни в одном архиве нет.
+ В архиве для своего сервера: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.
@@ -1040,18 +1056,6 @@ function hbBind(inp,box,list,onPick){
{{/* ============ ЛИЦЕНЗИЯ ============ */}}
-
- Модуль «НПР / УДС» (риск-монитор по данным QUIK)
- Показывает НПР1 / НПР2 / УДС клиентов маржинальной торговли по базе экспорта QUIK (схема npr). Отдельно лицензируемый модуль (признак npr); при включении в меню появляется пункт «НПР / УДС». Подключение — логин только для чтения.
- {{template "verline" (.Versions.Get "bj-npr")}}
-
-
- Лицензия
{{if .License.Valid}}Активна
@@ -1081,7 +1085,7 @@ function hbBind(inp,box,list,onPick){
Введите код активации от поставщика (вида BJ-XXXX-XXXX-XXXX). bj зарегистрирует эту установку на сервере лицензий lic.zetit.ru и дальше сам продлевает ключ раз в сутки. Серверу bj нужен доступ по HTTPS к lic.zetit.ru.
Установка {{if .Settings.License.InstallID}}{{.Settings.License.InstallID}}{{else}}будет создана при первом обращении{{end}}{{if not .Settings.License.LastSync.IsZero}} · последнее продление {{.Settings.License.LastSync.Format "02.01.2006 15:04"}}{{end}}
{{if .Settings.License.LastMsg}}{{if .Settings.License.LastOK}}✓{{else}}✗{{end}} {{.Settings.License.LastMsg}} ({{.Settings.License.LastAttempt.Format "02.01.2006 15:04"}})
{{end}}
diff --git a/modules/lk/agent.go b/modules/lk/agent.go
index c433a37..7d163da 100644
--- a/modules/lk/agent.go
+++ b/modules/lk/agent.go
@@ -14,7 +14,6 @@ package lk
import (
"context"
- "encoding/base64"
"encoding/json"
"errors"
"net/http"
@@ -23,25 +22,11 @@ import (
"time"
)
-// agentTask — один запрос кабинета к ядру, ожидающий ответа.
+// agentTask — один запрос к ядру, ожидающий ответа: вызов API (старый кабинет) или
+// страница целиком в конверте (ретранслятор).
type agentTask struct {
- ID string `json:"id"`
- Method string `json:"method"`
- Path string `json:"path"`
- Body []byte `json:"body,omitempty"`
- Session string `json:"session,omitempty"`
- // Адрес и браузер посетителя: ядро привязывает к ним сессию и считает по адресу
- // попытки входа. В прямом режиме они идут заголовками, здесь — полями запроса.
- ClientIP string `json:"client_ip,omitempty"`
- ClientAgent string `json:"client_agent,omitempty"`
-
- done chan agentResult
-}
-
-// agentResult — ответ ядра.
-type agentResult struct {
- Status int `json:"status"`
- Body []byte `json:"body,omitempty"`
+ PageRequest
+ done chan PageResponse
}
// agentQueue — очередь запросов к ядру и ожидающие ответа.
@@ -66,14 +51,21 @@ func newAgentQueue() *agentQueue {
return &agentQueue{waiting: map[string]*agentTask{}, signal: make(chan struct{}, 1)}
}
-// push — поставить запрос в очередь и ждать ответа ядра.
-func (q *agentQueue) push(ctx context.Context, method, path string, body []byte, session string, wait time.Duration) (agentResult, error) {
+// push — вызов API через очередь (кабинет с отрисовкой на своём сервере).
+func (q *agentQueue) push(ctx context.Context, method, path string, body []byte, session string, wait time.Duration) (PageResponse, error) {
+ env := PageRequest{Method: method, Path: path, Body: body, Session: session}
+ if v, ok := ctx.Value(visitorKey{}).(visitor); ok {
+ env.ClientIP, env.ClientAgent = v.ip, v.agent
+ }
+ return q.pushEnvelope(ctx, env, wait)
+}
+
+// pushEnvelope — поставить конверт в очередь и ждать ответа ядра.
+func (q *agentQueue) pushEnvelope(ctx context.Context, env PageRequest, wait time.Duration) (PageResponse, error) {
q.mu.Lock()
q.seq++
- t := &agentTask{ID: idFromSeq(q.seq), Method: method, Path: path, Body: body, Session: session, done: make(chan agentResult, 1)}
- if v, ok := ctx.Value(visitorKey{}).(visitor); ok {
- t.ClientIP, t.ClientAgent = v.ip, v.agent
- }
+ env.ID = idFromSeq(q.seq)
+ t := &agentTask{PageRequest: env, done: make(chan PageResponse, 1)}
q.queue = append(q.queue, t)
q.waiting[t.ID] = t
q.mu.Unlock()
@@ -88,10 +80,10 @@ func (q *agentQueue) push(ctx context.Context, method, path string, body []byte,
return res, nil
case <-ctx.Done():
q.forget(t.ID)
- return agentResult{}, ctx.Err()
+ return PageResponse{}, ctx.Err()
case <-timer.C:
q.forget(t.ID)
- return agentResult{}, errors.New("ядро не ответило: проверьте, что bj-server подключён к кабинету")
+ return PageResponse{}, errors.New("ядро не ответило: проверьте, что bj-server подключён к кабинету")
}
}
@@ -126,7 +118,7 @@ func (q *agentQueue) next(ctx context.Context, wait time.Duration) *agentTask {
}
// reply — вернуть ответ ядра ожидающему запросу.
-func (q *agentQueue) reply(id string, res agentResult) bool {
+func (q *agentQueue) reply(id string, res PageResponse) bool {
q.mu.Lock()
t, ok := q.waiting[id]
delete(q.waiting, id)
@@ -178,28 +170,19 @@ func (s *Server) registerAgent(mux *http.ServeMux) {
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "id": t.ID, "method": t.Method, "path": t.Path, "session": t.Session,
- "client_ip": t.ClientIP, "client_agent": t.ClientAgent,
- "body": base64.StdEncoding.EncodeToString(t.Body),
- })
+ _ = json.NewEncoder(w).Encode(t.PageRequest)
})
// Ядро возвращает ответ на запрос.
mux.HandleFunc("/agent/result", func(w http.ResponseWriter, r *http.Request) {
if !keyOK(w, r) {
return
}
- var in struct {
- ID string `json:"id"`
- Status int `json:"status"`
- Body string `json:"body"`
- }
- if json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&in) != nil || in.ID == "" {
+ var in PageResponse
+ if json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<20)).Decode(&in) != nil || in.ID == "" {
http.Error(w, "не разобрали ответ", http.StatusBadRequest)
return
}
- body, _ := base64.StdEncoding.DecodeString(in.Body)
- if !s.agentQueue().reply(in.ID, agentResult{Status: in.Status, Body: body}) {
+ if !s.agentQueue().reply(in.ID, in) {
w.WriteHeader(http.StatusGone) // запрос уже не ждут: клиент ушёл или истёк срок
return
}
diff --git a/modules/lk/envelope.go b/modules/lk/envelope.go
new file mode 100644
index 0000000..0b1c33d
--- /dev/null
+++ b/modules/lk/envelope.go
@@ -0,0 +1,212 @@
+package lk
+
+// envelope.go — отрисовка страниц в ядре и тонкий ретранслятор.
+//
+// Страницы кабинета может собирать не программа на хостинге, а само ядро: у него все
+// данные и шаблоны. Тогда на хостинге остаётся ретранслятор — он упаковывает запрос
+// браузера в конверт (метод, путь, заголовки, тело), передаёт ядру и отдаёт браузеру
+// ответ из конверта (код, заголовки, тело). Ретранслятор ничего не разбирает и ничем
+// не управляет, поэтому его легко держать и на Go для своего сервера, и на PHP для
+// обычного хостинга — вся логика и защита живут в ядре в одном экземпляре.
+//
+// Этот же пакет, собранный внутрь ядра, разворачивает конверт обратно в http.Request и
+// прогоняет его через свой обычный обработчик страниц (ServeEnvelope). Данные пакет
+// берёт через тот же API /api/lk/*, что и снаружи, только вызовом внутри процесса.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// PageRequest — запрос браузера в конверте.
+type PageRequest struct {
+ ID string `json:"id,omitempty"`
+ Kind string `json:"kind"` // page — страница кабинета; пусто — вызов API (старый кабинет)
+ Method string `json:"method"`
+ Path string `json:"path"` // путь с параметрами запроса
+ Host string `json:"host,omitempty"`
+ Scheme string `json:"scheme,omitempty"` // http | https — как браузер открыл кабинет
+ Headers map[string][]string `json:"headers,omitempty"`
+ Body []byte `json:"body,omitempty"`
+ Session string `json:"session,omitempty"` // только для вызовов API
+ ClientIP string `json:"client_ip,omitempty"`
+ ClientAgent string `json:"client_agent,omitempty"`
+}
+
+// PageResponse — ответ ядра в конверте.
+type PageResponse struct {
+ ID string `json:"id,omitempty"`
+ Status int `json:"status"`
+ Headers map[string][]string `json:"headers,omitempty"`
+ Body []byte `json:"body,omitempty"`
+}
+
+// forwardHeaders — заголовки браузера, которые ретранслятор передаёт ядру.
+// Остальное (Referer, Origin, служебные заголовки хостинга) ядру не нужно.
+var forwardHeaders = []string{"Cookie", "Accept", "Accept-Language", "Content-Type", "If-None-Match", "If-Modified-Since"}
+
+// hopHeaders — заголовки соединения: их ретранслятор не переносит ни в одну сторону.
+var hopHeaders = map[string]bool{"Connection": true, "Keep-Alive": true, "Transfer-Encoding": true, "Upgrade": true,
+ "Proxy-Authenticate": true, "Proxy-Authorization": true, "Te": true, "Trailer": true, "Content-Length": true}
+
+// envelopeFrom — конверт из запроса браузера (ретранслятор).
+func envelopeFrom(r *http.Request, maxBody int64) (PageRequest, error) {
+ body, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
+ if err != nil {
+ return PageRequest{}, err
+ }
+ if int64(len(body)) > maxBody {
+ return PageRequest{}, errRequestTooLarge
+ }
+ scheme := "http"
+ if httpsRequest(r) {
+ scheme = "https"
+ }
+ env := PageRequest{Kind: "page", Method: r.Method, Path: r.URL.RequestURI(), Host: r.Host, Scheme: scheme,
+ Headers: map[string][]string{}, Body: body, ClientIP: clientIP(r), ClientAgent: r.UserAgent()}
+ for _, h := range forwardHeaders {
+ if v := r.Header.Values(h); len(v) > 0 {
+ env.Headers[h] = v
+ }
+ }
+ return env, nil
+}
+
+// writeEnvelope — ответ ядра браузеру (ретранслятор).
+func writeEnvelope(w http.ResponseWriter, res PageResponse) {
+ for k, vs := range res.Headers {
+ if hopHeaders[http.CanonicalHeaderKey(k)] {
+ continue
+ }
+ for _, v := range vs {
+ w.Header().Add(k, v)
+ }
+ }
+ if res.Status == 0 {
+ res.Status = http.StatusBadGateway
+ }
+ w.WriteHeader(res.Status)
+ _, _ = w.Write(res.Body)
+}
+
+// ServeEnvelope — развернуть конверт и выполнить запрос обычным обработчиком страниц (ядро).
+func (s *Server) ServeEnvelope(ctx context.Context, env PageRequest) PageResponse {
+ if env.Method == "" {
+ env.Method = http.MethodGet
+ }
+ u, err := url.ParseRequestURI(env.Path)
+ if err != nil || strings.HasPrefix(env.Path, "//") {
+ return PageResponse{ID: env.ID, Status: http.StatusBadRequest, Body: []byte("некорректный путь")}
+ }
+ cctx, cancel := context.WithTimeout(ctx, 40*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(cctx, env.Method, u.String(), bytes.NewReader(env.Body))
+ if err != nil {
+ return PageResponse{ID: env.ID, Status: http.StatusBadRequest, Body: []byte("некорректный запрос")}
+ }
+ for k, vs := range env.Headers {
+ ck := http.CanonicalHeaderKey(k)
+ if hopHeaders[ck] || strings.HasPrefix(ck, "X-Bj-") || strings.HasPrefix(ck, "X-Forwarded-") || ck == "X-Real-Ip" {
+ continue // служебные заголовки ставим сами, чужие не пропускаем
+ }
+ for _, v := range vs {
+ req.Header.Add(ck, v)
+ }
+ }
+ if env.Host != "" {
+ req.Host = env.Host
+ }
+ if env.Scheme == "https" {
+ req.Header.Set("X-Forwarded-Proto", "https") // куки Secure, HSTS — как при прямом TLS
+ }
+ if env.ClientIP != "" {
+ req.Header.Set("X-Real-IP", env.ClientIP)
+ req.RemoteAddr = env.ClientIP + ":0"
+ }
+ if env.ClientAgent != "" {
+ req.Header.Set("User-Agent", env.ClientAgent)
+ }
+ rec := &envelopeRecorder{status: http.StatusOK, hdr: http.Header{}}
+ s.Handler().ServeHTTP(rec, req)
+ return PageResponse{ID: env.ID, Status: rec.status, Headers: rec.hdr, Body: rec.buf.Bytes()}
+}
+
+type envelopeRecorder struct {
+ status int
+ hdr http.Header
+ buf bytes.Buffer
+ wrote bool
+}
+
+func (w *envelopeRecorder) Header() http.Header { return w.hdr }
+func (w *envelopeRecorder) Write(b []byte) (int, error) {
+ w.wrote = true
+ return w.buf.Write(b)
+}
+func (w *envelopeRecorder) WriteHeader(code int) {
+ if !w.wrote {
+ w.status = code
+ }
+}
+
+// relayMaxBody — сколько ретранслятор принимает от браузера (формы кабинета маленькие).
+const relayMaxBody = 1 << 20
+
+// relay — обработчик ретранслятора: конверт → ядро → ответ браузеру.
+func (s *Server) relay(w http.ResponseWriter, r *http.Request) {
+ env, err := envelopeFrom(r, relayMaxBody)
+ if err != nil {
+ http.Error(w, "Запрос слишком большой", http.StatusRequestEntityTooLarge)
+ return
+ }
+ res, err := s.bj().page(reqCtx(r), env)
+ if err != nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadGateway)
+ s.tpl.ExecuteTemplate(w, "relay_error", map[string]any{"Title": s.conf().Title, "Error": err.Error()}) //nolint:errcheck
+ return
+ }
+ writeEnvelope(w, res)
+}
+
+// page — доставить конверт ядру: напрямую (POST /api/lk/render) или через очередь режима ожидания.
+func (b *bjClient) page(ctx context.Context, env PageRequest) (PageResponse, error) {
+ if b.agent != nil {
+ res, err := b.agent.pushEnvelope(ctx, env, 40*time.Second)
+ if err != nil {
+ return PageResponse{}, err
+ }
+ return res, nil
+ }
+ raw, _ := json.Marshal(env)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.cfg.BJURL+"/api/lk/render", bytes.NewReader(raw))
+ if err != nil {
+ return PageResponse{}, err
+ }
+ req.Header.Set("X-BJ-Key", b.cfg.BJKey)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := b.hc.Do(req)
+ if err != nil {
+ return PageResponse{}, errNoCore(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ var e struct{ Error string }
+ _ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&e)
+ if e.Error == "" {
+ e.Error = "ядро ответило " + resp.Status
+ }
+ return PageResponse{}, errCore(e.Error)
+ }
+ var out PageResponse
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&out); err != nil {
+ return PageResponse{}, errCore("ответ ядра не разобран")
+ }
+ return out, nil
+}
diff --git a/modules/lk/install.go b/modules/lk/install.go
index c37bd9d..6a45fe2 100644
--- a/modules/lk/install.go
+++ b/modules/lk/install.go
@@ -29,10 +29,11 @@ const configFileName = "lk.json"
// fileConfig — что лежит в lk.json. Ключ — секрет, поэтому файл 0600.
type fileConfig struct {
- Mode string `json:"mode"` // direct | agent
- BJURL string `json:"bj_url,omitempty"`
- BJKey string `json:"bj_key"`
- Title string `json:"title,omitempty"`
+ Mode string `json:"mode"` // direct | agent — как связываемся с ядром
+ Render string `json:"render,omitempty"` // core | local — где собираются страницы
+ BJURL string `json:"bj_url,omitempty"`
+ BJKey string `json:"bj_key"`
+ Title string `json:"title,omitempty"`
}
// DefaultDataDir — каталог данных по умолчанию: data рядом с программой.
@@ -73,6 +74,9 @@ func LoadConfigFile(dataDir string, cfg Config) (Config, error) {
if !cfg.Agent && os.Getenv("BJ_MODE") == "" {
cfg.Agent = f.Mode == "agent"
}
+ if !cfg.Relay && os.Getenv("BJ_RENDER") == "" {
+ cfg.Relay = f.Render == "core"
+ }
return cfg, nil
}
@@ -81,10 +85,13 @@ func saveConfigFile(dataDir string, cfg Config) error {
if err := os.MkdirAll(dataDir, 0o700); err != nil {
return fmt.Errorf("каталог данных %s: %v", dataDir, err)
}
- f := fileConfig{Mode: "direct", BJURL: cfg.BJURL, BJKey: cfg.BJKey, Title: cfg.Title}
+ f := fileConfig{Mode: "direct", Render: "local", BJURL: cfg.BJURL, BJKey: cfg.BJKey, Title: cfg.Title}
if cfg.Agent {
f.Mode, f.BJURL = "agent", ""
}
+ if cfg.Relay {
+ f.Render = "core"
+ }
raw, _ := json.MarshalIndent(f, "", " ")
tmp := filepath.Join(dataDir, configFileName+".tmp")
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
@@ -155,6 +162,10 @@ type installData struct {
// registerInstall — страницы установщика.
func (s *Server) registerInstall(mux *http.ServeMux) {
mux.HandleFunc("/install", func(w http.ResponseWriter, r *http.Request) {
+ if s.conf().Local != nil {
+ http.NotFound(w, r) // внутри ядра установщика нет
+ return
+ }
// Уже настроено: повторный вход в установщик — только удалением файла настроек.
// Исключение — шаг «администратор», он идёт сразу после сохранения связи.
if s.Configured() && !(r.Method == http.MethodPost && r.FormValue("step") == "admin") {
@@ -199,6 +210,7 @@ func (s *Server) renderInstall(w http.ResponseWriter, r *http.Request, d install
func (s *Server) installConnect(w http.ResponseWriter, r *http.Request, d installData) {
cfg := d.Cfg
cfg.Agent = r.FormValue("mode") == "agent"
+ cfg.Relay = r.FormValue("render") == "core"
cfg.BJURL = strings.TrimRight(strings.TrimSpace(r.FormValue("bj_url")), "/")
cfg.BJKey = strings.TrimSpace(r.FormValue("bj_key"))
cfg.Title = strings.TrimSpace(r.FormValue("title"))
diff --git a/modules/lk/lk.go b/modules/lk/lk.go
index 58af8c6..fc3aed9 100644
--- a/modules/lk/lk.go
+++ b/modules/lk/lk.go
@@ -11,6 +11,7 @@ import (
"encoding/json"
"fmt"
"html/template"
+ "io"
"log"
"net/http"
"net/url"
@@ -34,6 +35,12 @@ type Config struct {
// Agent — режим ожидания: у ядра нет адреса, доступного кабинету, поэтому
// не кабинет ходит в ядро, а ядро само подключается сюда за запросами.
Agent bool
+ // Relay — ретранслятор: страницы рисует ядро, здесь только передача конвертов.
+ Relay bool
+ // Local — кабинет собран внутрь ядра: API вызывается в том же процессе, ключ
+ // берётся функцией LocalKey (он меняется в настройках без перезапуска).
+ Local http.Handler
+ LocalKey func() string
}
// Server — кабинет. Настройки живут за мьютексом: веб-установщик меняет их на ходу,
@@ -45,6 +52,9 @@ type Server struct {
agent *agentQueue
dataDir string // где лежит lk.json (веб-установщик)
tpl *template.Template
+
+ handlerOnce sync.Once
+ handler http.Handler
}
// New — кабинет с разобранными шаблонами.
@@ -124,7 +134,7 @@ func (s *Server) apply(cfg Config) {
if cfg.Agent {
s.agent = newAgentQueue()
}
- s.bjc = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: s.agent}
+ s.bjc = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: s.agent, local: cfg.Local, localKey: cfg.LocalKey}
}
// conf — текущие настройки (копия).
@@ -151,6 +161,9 @@ func (s *Server) agentQueue() *agentQueue {
func (s *Server) Configured() bool { return s.conf().configured() }
func (c Config) configured() bool {
+ if c.Local != nil {
+ return true // внутри ядра настраивать нечего
+ }
if c.BJKey == "" {
return false
}
@@ -175,9 +188,11 @@ func (c Config) insecureCore() bool {
// ---- клиент к bj-server ----
type bjClient struct {
- cfg Config
- hc *http.Client
- agent *agentQueue // задан в режиме ожидания
+ cfg Config
+ hc *http.Client
+ agent *agentQueue // задан в режиме ожидания
+ local http.Handler // задан внутри ядра: API вызывается без сети
+ localKey func() string
}
func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error {
@@ -189,11 +204,19 @@ func (b *bjClient) call(ctx context.Context, method, path, session string, body
return b.callAgent(ctx, method, path, session, raw, out)
}
rdr := bytes.NewReader(raw)
- req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr)
+ target := b.cfg.BJURL + path
+ if b.local != nil {
+ target = path
+ }
+ req, err := http.NewRequestWithContext(ctx, method, target, rdr)
if err != nil {
return err
}
- req.Header.Set("X-BJ-Key", b.cfg.BJKey)
+ key := b.cfg.BJKey
+ if b.localKey != nil {
+ key = b.localKey()
+ }
+ req.Header.Set("X-BJ-Key", key)
if session != "" {
req.Header.Set("X-BJ-Session", session)
}
@@ -206,9 +229,18 @@ func (b *bjClient) call(ctx context.Context, method, path, session string, body
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
- resp, err := b.hc.Do(req)
- if err != nil {
- return fmt.Errorf("нет связи с сервером: %w", err)
+ var resp *http.Response
+ if b.local != nil {
+ // Внутри ядра: тот же обработчик API, только без сети. Граница проверок та же.
+ rec := &envelopeRecorder{status: http.StatusOK, hdr: http.Header{}}
+ req.RemoteAddr = "127.0.0.1:0"
+ b.local.ServeHTTP(rec, req)
+ resp = &http.Response{StatusCode: rec.status, Header: rec.hdr, Body: io.NopCloser(&rec.buf)}
+ } else {
+ resp, err = b.hc.Do(req)
+ if err != nil {
+ return errNoCore(err)
+ }
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
@@ -225,6 +257,12 @@ func (b *bjClient) call(ctx context.Context, method, path, session string, body
return nil
}
+// errNoCore / errCore — ошибки связи с ядром одним текстом для страниц.
+func errNoCore(err error) error { return fmt.Errorf("нет связи с сервером: %w", err) }
+func errCore(msg string) error { return fmt.Errorf("%s", msg) }
+
+var errRequestTooLarge = fmt.Errorf("запрос слишком большой")
+
// callAgent — тот же запрос, но через очередь: его заберёт ядро, когда подключится.
func (b *bjClient) callAgent(ctx context.Context, method, path, session string, raw []byte, out any) error {
res, err := b.agent.push(ctx, method, path, raw, session, 40*time.Second)
@@ -512,6 +550,11 @@ func session(r *http.Request) string {
// Handler — все страницы кабинета. Пока кабинет не настроен, любая страница ведёт
// в веб-установщик (/install), как у обычных веб-приложений после копирования файлов.
func (s *Server) Handler() http.Handler {
+ s.handlerOnce.Do(func() { s.handler = s.buildHandler() })
+ return s.handler
+}
+
+func (s *Server) buildHandler() http.Handler {
mux := http.NewServeMux()
s.registerInstall(mux)
@@ -977,10 +1020,16 @@ func (s *Server) Handler() http.Handler {
s.registerAgent(mux)
mux.Handle("/assets/", assetsHandler())
return harden(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !s.Configured() && !strings.HasPrefix(r.URL.Path, "/install") && !strings.HasPrefix(r.URL.Path, "/assets/") && r.URL.Path != "/health" {
+ p := r.URL.Path
+ if !s.Configured() && !strings.HasPrefix(p, "/install") && !strings.HasPrefix(p, "/assets/") && p != "/health" {
http.Redirect(w, r, "/install", http.StatusSeeOther)
return
}
+ // Ретранслятор: всё, кроме своих служебных страниц, уезжает в ядро конвертом.
+ if s.conf().Relay && !strings.HasPrefix(p, "/install") && !strings.HasPrefix(p, "/agent/") && p != "/health" {
+ s.relay(w, r)
+ return
+ }
mux.ServeHTTP(w, r)
}))
}
diff --git a/modules/lk/relay-php/.htaccess b/modules/lk/relay-php/.htaccess
new file mode 100644
index 0000000..5dec5a9
--- /dev/null
+++ b/modules/lk/relay-php/.htaccess
@@ -0,0 +1,12 @@
+# Кабинет клиента депозитария — ретранслятор (PHP). Все адреса ведут в index.php.
+Options -Indexes
+DirectoryIndex index.php
+
+ RewriteEngine On
+ RewriteCond %{REQUEST_FILENAME} !-f
+ RewriteRule ^ index.php [L,QSA]
+
+# Служебные файлы наружу не отдаём.
+
+ Require all denied
+
diff --git a/modules/lk/relay-php/README.txt b/modules/lk/relay-php/README.txt
new file mode 100644
index 0000000..bd44226
--- /dev/null
+++ b/modules/lk/relay-php/README.txt
@@ -0,0 +1,36 @@
+Личный кабинет клиента депозитария — версия для обычного хостинга (PHP)
+=========================================================================
+
+Что это. Кабинет ничего не рисует и не хранит сам: страницы собирает ядро bj-server
+депозитария, а этот файл только передаёт запросы браузера ядру и ответы обратно.
+Поэтому на хостинге нет ни базы данных, ни данных клиентов — только способ связи с ядром.
+
+Что нужно от хостинга: PHP 8.1 или новее, расширения curl и sqlite (pdo_sqlite),
+возможность создать домен/поддомен с сертификатом (Let's Encrypt в панели хостинга).
+
+Установка:
+ 1. Создайте в панели хостинга домен, например lk.депозитарий.ru, и включите https.
+ 2. Скопируйте в каталог домена файлы index.php и .htaccess (например, по FTP или через
+ файловый менеджер панели).
+ 3. Откройте https://lk.депозитарий.ru — запустится установщик: проверит сервер, спросит
+ способ связи с ядром и ключ доступа, проверит связь и предложит завести
+ администратора кабинета.
+
+Способы связи с ядром:
+ • Кабинет обращается к ядру — если у ядра есть адрес, доступный с хостинга (https).
+ • Ядро подключается к кабинету — если у ядра нет адреса в интернете. Тогда в настройках
+ ядра («Личный кабинет») выберите «Ядро подключается к кабинету» и укажите адрес сайта.
+ Ядро само дозвонится и заберёт запросы; входящих портов не нужно. Нужно расширение
+ sqlite — очередь запросов лежит в файле.
+
+Где лежат настройки: каталог bj-lk-data рядом с каталогом сайта (если хостинг разрешает
+писать туда), иначе подкаталог data внутри сайта, закрытый правилами .htaccess. Файл
+config.php содержит ключ доступа; чтобы пройти установку заново — удалите его.
+
+Если у хостинга nginx без поддержки .htaccess: направьте все адреса на index.php
+(try_files $uri /index.php?$query_string;) и закройте каталог data (location /data { deny all; }).
+Лучше создать каталог bj-lk-data рядом с сайтом — тогда внутри сайта данных не будет.
+
+Безопасность: заголовки защиты, куки и правила форм выставляет ядро — ретранслятор
+передаёт их без изменений и ничего не пишет в журнал. Клиентам публикуйте кабинет только
+по https.
diff --git a/modules/lk/relay-php/index.php b/modules/lk/relay-php/index.php
new file mode 100644
index 0000000..9ab473f
--- /dev/null
+++ b/modules/lk/relay-php/index.php
@@ -0,0 +1,629 @@
+\n" . json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+ if (file_put_contents($f . '.tmp', $raw, LOCK_EX) === false || !rename($f . '.tmp', $f)) {
+ throw new RuntimeException('не удалось записать настройки в ' . dataDir());
+ }
+ @chmod($f, 0600);
+}
+
+function configured(array $cfg): bool
+{
+ if (empty($cfg['bj_key'])) {
+ return false;
+ }
+ return ($cfg['mode'] ?? '') === 'agent' || !empty($cfg['bj_url']);
+}
+
+function isHttps(): bool
+{
+ if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
+ return true;
+ }
+ $xf = strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')[0]));
+ return $xf === 'https';
+}
+
+function isLocal(): bool
+{
+ $h = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
+ return $h === 'localhost' || $h === '127.0.0.1' || $h === '::1';
+}
+
+function clientIP(): string
+{
+ foreach (['HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR'] as $k) {
+ if (!empty($_SERVER[$k])) {
+ return trim(explode(',', $_SERVER[$k])[0]);
+ }
+ }
+ return $_SERVER['REMOTE_ADDR'] ?? '';
+}
+
+function keyOK(string $key): bool
+{
+ $got = $_SERVER['HTTP_X_BJ_KEY'] ?? '';
+ return $key !== '' && $got !== '' && hash_equals($key, $got);
+}
+
+function jsonOut(int $code, array $data): never
+{
+ http_response_code($code);
+ header('Content-Type: application/json; charset=utf-8');
+ header('Cache-Control: no-store');
+ echo json_encode($data, JSON_UNESCAPED_UNICODE);
+ exit;
+}
+
+function securityHeaders(): void
+{
+ header('X-Frame-Options: DENY');
+ header('X-Content-Type-Options: nosniff');
+ header('Referrer-Policy: same-origin');
+ header('Cache-Control: no-store, no-cache, must-revalidate, private');
+ if (isHttps()) {
+ header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
+ }
+}
+
+// ---------- вызовы ядра (прямой режим) ----------
+
+function coreCall(array $cfg, string $method, string $path, ?array $body, int $timeout = 15): array
+{
+ $ch = curl_init(rtrim((string)$cfg['bj_url'], '/') . $path);
+ $headers = ['X-BJ-Key: ' . $cfg['bj_key'], 'Accept: application/json'];
+ $opts = [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CUSTOMREQUEST => $method,
+ CURLOPT_TIMEOUT => $timeout,
+ CURLOPT_CONNECTTIMEOUT => 8,
+ CURLOPT_FOLLOWLOCATION => false,
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_SSL_VERIFYHOST => 2,
+ ];
+ if ($body !== null) {
+ $opts[CURLOPT_POSTFIELDS] = json_encode($body, JSON_UNESCAPED_UNICODE);
+ $headers[] = 'Content-Type: application/json';
+ }
+ $opts[CURLOPT_HTTPHEADER] = $headers;
+ curl_setopt_array($ch, $opts);
+ $raw = curl_exec($ch);
+ if ($raw === false) {
+ $err = curl_error($ch);
+ curl_close($ch);
+ return [0, null, 'нет связи с ядром: ' . $err];
+ }
+ $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+ $data = json_decode((string)$raw, true);
+ if ($code >= 400) {
+ $msg = is_array($data) && !empty($data['error']) ? (string)$data['error'] : 'ядро ответило ' . $code;
+ return [$code, $data, $msg];
+ }
+ return [$code, is_array($data) ? $data : [], ''];
+}
+
+// ---------- очередь режима ожидания (SQLite) ----------
+
+function queue(): PDO
+{
+ static $pdo = null;
+ if ($pdo instanceof PDO) {
+ return $pdo;
+ }
+ // Несколько процессов PHP открывают файл одновременно (ждущий браузер и подключения
+ // ядра): сначала ждём занятую базу, и только потом трогаем режим журнала.
+ $pdo = new PDO('sqlite:' . dataDir() . '/queue.sqlite', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]);
+ $pdo->exec('PRAGMA busy_timeout=5000');
+ try {
+ $pdo->exec('PRAGMA journal_mode=WAL');
+ } catch (Throwable) {
+ // режим журнала уже выставлен другим процессом — не страшно
+ }
+ $pdo->exec('CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, created REAL NOT NULL, taken INTEGER NOT NULL DEFAULT 0, request TEXT NOT NULL, response TEXT)');
+ $pdo->exec('CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT)');
+ @chmod(dataDir() . '/queue.sqlite', 0600);
+ return $pdo;
+}
+
+function queueCleanup(PDO $q): void
+{
+ $q->prepare('DELETE FROM tasks WHERE created < ?')->execute([microtime(true) - 300]);
+}
+
+function metaSet(string $k, string $v): void
+{
+ queue()->prepare('INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v')->execute([$k, $v]);
+}
+
+function metaGet(string $k): string
+{
+ try {
+ $st = queue()->prepare('SELECT v FROM meta WHERE k = ?');
+ $st->execute([$k]);
+ return (string)($st->fetchColumn() ?: '');
+ } catch (Throwable) {
+ return '';
+ }
+}
+
+function coreSeen(): bool
+{
+ $t = (float)metaGet('last_fetch');
+ return $t > 0 && microtime(true) - $t < 60;
+}
+
+/** Поставить конверт в очередь и дождаться ответа ядра. */
+function queueRoundTrip(array $env): array
+{
+ $q = queue();
+ queueCleanup($q);
+ $env['id'] = bin2hex(random_bytes(8));
+ $q->prepare('INSERT INTO tasks (id, created, request) VALUES (?, ?, ?)')
+ ->execute([$env['id'], microtime(true), json_encode($env, JSON_UNESCAPED_UNICODE)]);
+ $deadline = microtime(true) + RELAY_WAIT_SEC;
+ $st = $q->prepare('SELECT response FROM tasks WHERE id = ?');
+ while (microtime(true) < $deadline) {
+ usleep(150000);
+ $st->execute([$env['id']]);
+ $resp = $st->fetchColumn();
+ if (is_string($resp) && $resp !== '') {
+ $q->prepare('DELETE FROM tasks WHERE id = ?')->execute([$env['id']]);
+ $data = json_decode($resp, true);
+ return is_array($data) ? $data : ['status' => 502, 'body' => ''];
+ }
+ }
+ $q->prepare('DELETE FROM tasks WHERE id = ?')->execute([$env['id']]);
+ throw new RuntimeException('ядро не ответило: проверьте, что bj-server подключён к кабинету');
+}
+
+/** GET /agent/tasks — ядро забирает конверт; пусто — 204 через несколько секунд. */
+function agentTasks(array $cfg): never
+{
+ if (!keyOK((string)$cfg['bj_key'])) {
+ jsonOut(403, ['error' => 'неверный ключ модуля']);
+ }
+ $q = queue();
+ metaSet('last_fetch', (string)microtime(true));
+ $deadline = microtime(true) + AGENT_POLL_SEC;
+ while (true) {
+ $row = null;
+ try {
+ $q->exec('BEGIN IMMEDIATE'); // сразу берём запись на письмо: два подключения ядра не заберут один конверт
+ $st = $q->query('SELECT id, request FROM tasks WHERE taken = 0 AND response IS NULL ORDER BY created LIMIT 1');
+ $row = $st->fetch(PDO::FETCH_ASSOC);
+ if ($row) {
+ $q->prepare('UPDATE tasks SET taken = 1 WHERE id = ?')->execute([$row['id']]);
+ }
+ $q->exec('COMMIT');
+ } catch (Throwable) {
+ try {
+ $q->exec('ROLLBACK');
+ } catch (Throwable) {
+ }
+ $row = null; // база занята другим процессом — попробуем на следующем круге
+ }
+ if ($row) {
+ http_response_code(200);
+ header('Content-Type: application/json; charset=utf-8');
+ echo $row['request'];
+ exit;
+ }
+ if (microtime(true) >= $deadline) {
+ http_response_code(204);
+ exit;
+ }
+ usleep(200000);
+ }
+}
+
+/** POST /agent/result — ядро возвращает ответ на конверт. */
+function agentResult(array $cfg): never
+{
+ if (!keyOK((string)$cfg['bj_key'])) {
+ jsonOut(403, ['error' => 'неверный ключ модуля']);
+ }
+ $raw = (string)file_get_contents('php://input');
+ $data = json_decode($raw, true);
+ if (!is_array($data) || empty($data['id'])) {
+ jsonOut(400, ['error' => 'не разобрали ответ']);
+ }
+ $st = queue()->prepare('UPDATE tasks SET response = ? WHERE id = ? AND response IS NULL');
+ $st->execute([$raw, (string)$data['id']]);
+ http_response_code($st->rowCount() ? 204 : 410);
+ exit;
+}
+
+// ---------- ретрансляция ----------
+
+function envelopeFromRequest(): array
+{
+ $body = (string)file_get_contents('php://input');
+ if (strlen($body) > RELAY_MAX_BODY) {
+ throw new LengthException('Запрос слишком большой');
+ }
+ $headers = [];
+ foreach (['Cookie' => 'HTTP_COOKIE', 'Accept' => 'HTTP_ACCEPT', 'Accept-Language' => 'HTTP_ACCEPT_LANGUAGE',
+ 'Content-Type' => 'CONTENT_TYPE', 'If-None-Match' => 'HTTP_IF_NONE_MATCH', 'If-Modified-Since' => 'HTTP_IF_MODIFIED_SINCE'] as $name => $key) {
+ if (!empty($_SERVER[$key])) {
+ $headers[$name] = [(string)$_SERVER[$key]];
+ }
+ }
+ return [
+ 'kind' => 'page',
+ 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
+ 'path' => $_SERVER['REQUEST_URI'] ?? '/',
+ 'host' => $_SERVER['HTTP_HOST'] ?? '',
+ 'scheme' => isHttps() ? 'https' : 'http',
+ 'headers' => (object)$headers, // объект, а не [] — иначе ядро не разберёт пустой список
+ 'body' => base64_encode($body),
+ 'client_ip' => clientIP(),
+ 'client_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
+ ];
+}
+
+function writeEnvelope(array $res): never
+{
+ $hop = ['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'content-length', 'te', 'trailer'];
+ http_response_code((int)($res['status'] ?? 502) ?: 502);
+ foreach (($res['headers'] ?? []) as $name => $values) {
+ if (in_array(strtolower((string)$name), $hop, true)) {
+ continue;
+ }
+ foreach ((array)$values as $v) {
+ header($name . ': ' . $v, false);
+ }
+ }
+ echo base64_decode((string)($res['body'] ?? ''));
+ exit;
+}
+
+function relay(array $cfg): never
+{
+ try {
+ $env = envelopeFromRequest();
+ } catch (LengthException $e) {
+ http_response_code(413);
+ exit($e->getMessage());
+ }
+ try {
+ if (($cfg['mode'] ?? '') === 'agent') {
+ $res = queueRoundTrip($env);
+ } else {
+ [$code, $res, $err] = coreCall($cfg, 'POST', '/api/lk/render', $env, 40);
+ if ($err !== '') {
+ throw new RuntimeException($err);
+ }
+ }
+ } catch (Throwable $e) {
+ securityHeaders();
+ http_response_code(502);
+ page('Кабинет временно недоступен', '' . h($e->getMessage()) . ''
+ . 'Страницы кабинета собирает учётная система депозитария; сейчас связи с ней нет. Попробуйте обновить страницу через минуту.
'
+ . 'Обновить', 'Страницы собирает учётная система депозитария; сейчас связи с ней нет.');
+ }
+ writeEnvelope($res);
+}
+
+// ---------- установщик ----------
+
+function h(string $s): string
+{
+ return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+}
+
+function csrfToken(): string
+{
+ $c = $_COOKIE['bj_lk_csrf'] ?? '';
+ if (strlen($c) >= 32) {
+ return $c;
+ }
+ $t = bin2hex(random_bytes(24));
+ setcookie('bj_lk_csrf', $t, ['path' => '/', 'httponly' => true, 'secure' => isHttps() || !isLocal(), 'samesite' => 'Strict']);
+ $_COOKIE['bj_lk_csrf'] = $t;
+ return $t;
+}
+
+function csrfOK(): bool
+{
+ $c = $_COOKIE['bj_lk_csrf'] ?? '';
+ $f = $_POST['csrf'] ?? '';
+ return $c !== '' && $f !== '' && hash_equals($c, $f);
+}
+
+/** Страница установщика в оформлении кабинета: тёмная панель слева, форма справа. */
+function page(string $title, string $formHtml, string $side, array $steps = []): never
+{
+ $stepsHtml = '';
+ foreach ($steps as $i => [$name, $on]) {
+ $stepsHtml .= '0' . ($i + 1) . '' . h($name) . '';
+ }
+ echo '' . h($title) . '
+Установка кабинета' . h($title) . '
' . $stepsHtml . '' . h($side) . '
' . $formHtml . ' ';
+ exit;
+}
+
+function installChecks(): array
+{
+ $out = [];
+ $dir = dataDir();
+ $probe = @tempnam($dir, '.probe');
+ if ($probe && str_starts_with($probe, $dir)) {
+ @unlink($probe);
+ $note = $dir . ' — доступен для записи' . (dataInsideWebroot() ? '. Каталог внутри сайта: закрыт правилами .htaccess; если у хостинга nginx без .htaccess — создайте каталог bj-lk-data рядом с сайтом, ретранслятор перейдёт в него сам.' : ' (вне сайта — хорошо)');
+ $out[] = ['Каталог данных', $note, dataInsideWebroot() ? 'warn' : 'ok'];
+ } else {
+ $out[] = ['Каталог данных', $dir . ': нельзя записать файл. Дайте PHP право писать в каталог сайта или создайте bj-lk-data рядом с ним.', 'bad'];
+ }
+ $out[] = ['PHP ' . PHP_VERSION, version_compare(PHP_VERSION, '8.1.0', '>=') ? 'подходит' : 'нужен PHP 8.1 или новее', version_compare(PHP_VERSION, '8.1.0', '>=') ? 'ok' : 'bad'];
+ $out[] = ['Расширение curl', extension_loaded('curl') ? 'есть' : 'нет — без него нельзя обратиться к ядру', extension_loaded('curl') ? 'ok' : 'bad'];
+ $sq = extension_loaded('pdo_sqlite');
+ $out[] = ['Расширение sqlite', $sq ? 'есть' : 'нет — режим «ядро подключается к кабинету» недоступен, только прямой', $sq ? 'ok' : 'warn'];
+ if (isHttps()) {
+ $out[] = ['Защищённое соединение', 'кабинет открыт по https', 'ok'];
+ } elseif (isLocal()) {
+ $out[] = ['Защищённое соединение', 'открыто с этого же сервера — для проверки достаточно; клиентам публикуйте только по https', 'warn'];
+ } else {
+ $out[] = ['Защищённое соединение', 'кабинет открыт по http без TLS. Так публиковать нельзя: пароли клиентов пойдут по сети открытым текстом. Включите сертификат на хостинге.', 'warn'];
+ }
+ $out[] = ['Адрес кабинета', $_SERVER['HTTP_HOST'] ?? '', 'ok'];
+ return $out;
+}
+
+function checksHtml(): string
+{
+ $html = '';
+ foreach (installChecks() as [$name, $note, $state]) {
+ $mark = $state === 'bad' ? '✗' : '✓';
+ $html .= '' . $mark . '' . h($name) . '. ' . h($note) . '';
+ }
+ return $html . '';
+}
+
+function installer(array $cfg): never
+{
+ securityHeaders();
+ $steps = [['Проверка сервера', true], ['Связь с ядром', false], ['Администратор кабинета', false]];
+ $side = 'Кабинет ничего не хранит у себя: пользователи, пароли и данные клиентов живут в ядре. Здесь сохраняется только способ связи с ним.';
+ $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+ $step = $_POST['step'] ?? '';
+ if (configured($cfg) && !($method === 'POST' && $step === 'admin')) {
+ page('Кабинет уже настроен', 'Установщик закрыт: настройки сохранены в ' . h(dataDir()) . '/config.php. Чтобы изменить их, удалите этот файл и откройте страницу снова.
Перейти ко входу', $side);
+ }
+ $csrf = csrfToken();
+ $error = '';
+ $notice = '';
+ $vals = ['title' => $cfg['title'] ?? '', 'mode' => $cfg['mode'] ?? 'direct', 'bj_url' => $cfg['bj_url'] ?? '', 'bj_key' => $cfg['bj_key'] ?? ''];
+
+ if ($method === 'POST' && !csrfOK()) {
+ $error = 'Форма устарела, обновите страницу.';
+ } elseif ($method === 'POST' && $step === 'connect') {
+ foreach ($vals as $k => $_) {
+ $vals[$k] = trim((string)($_POST[$k] ?? ''));
+ }
+ $vals['bj_url'] = rtrim($vals['bj_url'], '/');
+ $new = ['mode' => $vals['mode'] === 'agent' ? 'agent' : 'direct', 'bj_url' => $vals['bj_url'], 'bj_key' => $vals['bj_key'], 'title' => $vals['title']];
+ if ($new['bj_key'] === '') {
+ $error = 'Укажите ключ доступа модуля — он создаётся в настройках ядра, раздел «Личный кабинет».';
+ } elseif ($new['mode'] === 'direct' && $new['bj_url'] === '') {
+ $error = 'Укажите адрес ядра или выберите режим «ядро подключается к кабинету».';
+ } elseif ($new['mode'] === 'direct' && !str_starts_with($new['bj_url'], 'https://') && !preg_match('~^http://(127\.0\.0\.1|localhost)~', $new['bj_url'])) {
+ $error = 'Адрес ядра должен начинаться с https:// — по этому каналу идут ключ и сессии клиентов.';
+ } elseif ($new['mode'] === 'agent' && !extension_loaded('pdo_sqlite')) {
+ $error = 'Для режима «ядро подключается к кабинету» нужно расширение sqlite — включите его в панели хостинга или выберите прямой режим.';
+ } else {
+ if ($new['mode'] === 'direct') {
+ [$code, $data, $err] = coreCall($new, 'GET', '/api/lk/ping', null);
+ if ($err !== '') {
+ $error = 'Связь с ядром не установлена: ' . $err;
+ }
+ }
+ if ($error === '') {
+ try {
+ saveConfig($new);
+ if ($new['mode'] === 'agent') {
+ header('Location: /install/wait', true, 303);
+ exit;
+ }
+ $steps = [['Проверка сервера', true], ['Связь с ядром', true], ['Администратор кабинета', true]];
+ $notice = 'Связь с ядром установлена, настройки сохранены. Осталось завести администратора кабинета.';
+ adminForm($csrf, $notice, '', $steps, $side);
+ } catch (Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+ }
+ } elseif ($method === 'POST' && $step === 'admin') {
+ $steps = [['Проверка сервера', true], ['Связь с ядром', true], ['Администратор кабинета', true]];
+ if (!empty($_POST['skip'])) {
+ done($side);
+ }
+ $login = trim((string)($_POST['login'] ?? ''));
+ $pass = (string)($_POST['password'] ?? '');
+ if ($login === '' || $pass === '') {
+ adminForm($csrf, '', 'Укажите логин и пароль администратора.', $steps, $side);
+ }
+ if ($pass !== (string)($_POST['password2'] ?? '')) {
+ adminForm($csrf, '', 'Пароли не совпадают.', $steps, $side);
+ }
+ $body = ['login' => $login, 'password' => $pass, 'full_name' => trim((string)($_POST['full_name'] ?? '')), 'email' => trim((string)($_POST['email'] ?? ''))];
+ try {
+ if (($cfg['mode'] ?? '') === 'agent') {
+ // В режиме ожидания к API идём тем же конвертом, что и страницы, но без kind=page.
+ $res = queueRoundTrip(['method' => 'POST', 'path' => '/api/lk/admin/bootstrap', 'body' => base64_encode(json_encode($body, JSON_UNESCAPED_UNICODE)), 'client_ip' => clientIP(), 'client_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '']);
+ if ((int)($res['status'] ?? 500) >= 400) {
+ $data = json_decode(base64_decode((string)($res['body'] ?? '')), true);
+ throw new RuntimeException(is_array($data) && !empty($data['error']) ? (string)$data['error'] : 'ядро ответило ' . ($res['status'] ?? '?'));
+ }
+ } else {
+ [$code, $data, $err] = coreCall($cfg, 'POST', '/api/lk/admin/bootstrap', $body);
+ if ($err !== '') {
+ throw new RuntimeException($err);
+ }
+ }
+ } catch (Throwable $e) {
+ adminForm($csrf, '', $e->getMessage(), $steps, $side);
+ }
+ done($side);
+ }
+
+ $f = '';
+ page('Три шага — и кабинет работает', $f, $side, $steps);
+}
+
+function adminForm(string $csrf, string $notice, string $error, array $steps, string $side): never
+{
+ $f = '';
+ page('Три шага — и кабинет работает', $f, $side, $steps);
+}
+
+function done(string $side): never
+{
+ page('Кабинет установлен', 'Готово
Клиенты входят по логину и паролю, которые выдаёт депозитарий. Учётные записи заводятся в настройках ядра, раздел «Личный кабинет». Настройки ретранслятора — в ' . h(dataDir()) . '/config.php; чтобы пройти установку заново, удалите его.
Перейти ко входу', $side);
+}
+
+function installWait(array $cfg): never
+{
+ securityHeaders();
+ $side = 'В настройках ядра (раздел «Личный кабинет») выберите «Ядро подключается к кабинету» и укажите адрес этого сайта. Ключ должен совпадать.';
+ if (coreSeen()) {
+ adminForm(csrfToken(), 'Ядро подключилось к кабинету. Осталось завести администратора.', '', [['Проверка сервера', true], ['Связь с ядром', true], ['Администратор кабинета', true]], $side);
+ }
+ header('Refresh: 5');
+ page('Ждём подключения ядра', 'Настройки сохранены. Ждём, когда ядро подключится к кабинету по адресу ' . h($_SERVER['HTTP_HOST'] ?? '') . '… Страница обновляется сама.', $side, [['Проверка сервера', true], ['Связь с ядром', true], ['Администратор кабинета', false]]);
+}
+
+// ---------- маршрутизация ----------
+
+$cfg = loadConfig();
+$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
+
+if ($path === '/health') {
+ if (!configured($cfg)) {
+ jsonOut(502, ['ok' => false]);
+ }
+ if (($cfg['mode'] ?? '') === 'agent') {
+ $ok = coreSeen();
+ jsonOut($ok ? 200 : 502, keyOK((string)$cfg['bj_key']) ? ['ok' => $ok, 'mode' => 'agent', 'relay' => 'php ' . LK_VERSION] : ['ok' => $ok]);
+ }
+ [$code, $data, $err] = coreCall($cfg, 'GET', '/api/lk/ping', null, 8);
+ jsonOut($err === '' ? 200 : 502, keyOK((string)$cfg['bj_key']) ? ['ok' => $err === '', 'mode' => 'direct', 'relay' => 'php ' . LK_VERSION, 'bj' => $data, 'error' => $err] : ['ok' => $err === '']);
+}
+if ($path === '/install') {
+ installer($cfg);
+}
+if ($path === '/install/wait') {
+ installWait($cfg);
+}
+if (!configured($cfg)) {
+ header('Location: /install', true, 303);
+ exit;
+}
+if (($cfg['mode'] ?? '') === 'agent') {
+ if ($path === '/agent/tasks') {
+ agentTasks($cfg);
+ }
+ if ($path === '/agent/result') {
+ agentResult($cfg);
+ }
+}
+relay($cfg);
diff --git a/modules/lk/relayphp.go b/modules/lk/relayphp.go
new file mode 100644
index 0000000..a9b6cf1
--- /dev/null
+++ b/modules/lk/relayphp.go
@@ -0,0 +1,20 @@
+package lk
+
+// relayphp.go — ретранслятор для обычного PHP-хостинга, вшитый в программу: ядро отдаёт
+// его администратору архивом со страницы настроек.
+
+import "embed"
+
+//go:embed relay-php/index.php relay-php/.htaccess relay-php/README.txt
+var relayPHP embed.FS
+
+// PHPRelayFiles — файлы ретранслятора: имя → содержимое.
+func PHPRelayFiles() map[string][]byte {
+ out := map[string][]byte{}
+ for _, name := range []string{"index.php", ".htaccess", "README.txt"} {
+ if b, err := relayPHP.ReadFile("relay-php/" + name); err == nil {
+ out[name] = b
+ }
+ }
+ return out
+}
diff --git a/modules/lk/templates.go b/modules/lk/templates.go
index b0ced13..e23ea0c 100644
--- a/modules/lk/templates.go
+++ b/modules/lk/templates.go
@@ -344,6 +344,11 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
+
+
+
+
+
@@ -385,6 +390,11 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}