Files
Bridge-and-Join-s/modules/lk/envelope.go
zuevav 054222d154 Кабинет: страницы собирает ядро, на хостинге — ретранслятор (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
2026-09-18 11:15:29 +03:00

213 lines
8.8 KiB
Go

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
}