Кабинет: страницы собирает ядро, на хостинге — ретранслятор (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
+59 -10
View File
@@ -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)
}))
}