054222d154
- 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
213 lines
8.8 KiB
Go
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
|
|
}
|