Кабинет: страницы собирает ядро, на хостинге — ретранслятор (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:
zuevav
2026-09-18 11:15:29 +03:00
parent f51414269d
commit 054222d154
19 changed files with 1403 additions and 97 deletions
+22 -24
View File
@@ -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