Кабинет: страницы собирает ядро, на хостинге — ретранслятор (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:
+3
-1
@@ -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://...)")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("<?php exit; ?>\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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -5,10 +5,21 @@
|
||||
<h2>Как разместить кабинет клиента на хостинге</h2>
|
||||
<p class="muted">Речь о нашем модуле <code>bj-lk</code> — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.</p>
|
||||
|
||||
<h3>Два варианта размещения</h3>
|
||||
<table>
|
||||
<tr><th>Где</th><th>Что скачать</th><th>Как работает</th></tr>
|
||||
<tr><td>Обычный хостинг с PHP («папка сайта»)</td><td>«Скачать для PHP-хостинга» — <code>index.php</code> и <code>.htaccess</code></td><td>Страницы собирает ядро. На хостинге лежит ретранслятор: он передаёт запросы браузера ядру и отдаёт ответы. Ни базы, ни данных клиентов на хостинге нет. Нужны PHP 8.1+, curl, sqlite.</td></tr>
|
||||
<tr><td>Свой сервер или VPS с Linux</td><td>«Скачать для своего сервера» — программа <code>bj-lk</code></td><td>Установщик спросит, где собирать страницы: в ядре (программа — такой же ретранслятор) или на месте (программа рисует страницы сама из данных ядра).</td></tr>
|
||||
</table>
|
||||
<p>В обоих случаях ключа в архиве нет — его вводят в установщике, открыв адрес кабинета в браузере. Способы связи с ядром (кабинет → ядро или ядро → кабинет) одинаковы для всех вариантов.</p>
|
||||
|
||||
<h3>Шаг 1. Ключ и архив</h3>
|
||||
<p>Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа (он показан на странице целиком — его нужно ввести в установщике) и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек службы, установщик и README. Ключа в архиве нет.</p>
|
||||
|
||||
<h3>Шаг 2. Файлы на сервер и запуск</h3>
|
||||
<h3>Шаг 2а. PHP-хостинг: файлы в каталог сайта</h3>
|
||||
<p>Создайте в панели хостинга домен (например, <code>lk.депозитарий.ru</code>) с сертификатом, скопируйте <code>index.php</code> и <code>.htaccess</code> в его каталог и откройте адрес в браузере — запустится установщик (шаг 3). Настройки ретранслятор положит в каталог <code>bj-lk-data</code> рядом с сайтом, если хостинг разрешает туда писать, иначе — в <code>data</code> внутри сайта, закрытый правилами <code>.htaccess</code>. Если у хостинга nginx без <code>.htaccess</code> — правила из README архива.</p>
|
||||
|
||||
<h3>Шаг 2б. Свой сервер: файлы на сервер и запуск</h3>
|
||||
<pre>tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
|
||||
cd bj-lk
|
||||
sudo ./install.sh # на своём сервере: заводит службу bj-lk
|
||||
|
||||
@@ -382,6 +382,20 @@
|
||||
{{/* ============ ЭКСПОРТ БИРЖЕВОЙ ИНФОРМАЦИИ (QUIK) ============ */}}
|
||||
<section class="settings-section" id="sec-quikexport">
|
||||
{{.QuikExport.HTML}}
|
||||
|
||||
{{/* модуль НПР/УДС работает поверх базы экспорта QUIK — его настройки здесь же */}}
|
||||
<div class="card" id="sec-npr">
|
||||
<h2><span class="dot {{if .Settings.NPR.Enabled}}ok{{else}}warn{{end}}"></span>Модуль «НПР / УДС» (риск-монитор по данным QUIK)</h2>
|
||||
<p class="muted">Показывает НПР1 / НПР2 / УДС клиентов маржинальной торговли по базе экспорта QUIK (схема <code>npr</code>). Отдельно лицензируемый модуль (признак <code>npr</code>); при включении в меню появляется пункт «НПР / УДС». Подключение — логин только для чтения.</p>
|
||||
{{template "verline" (.Versions.Get "bj-npr")}}
|
||||
<form method="post" action="/admin/setup/npr" style="margin-top:10px;display:grid;gap:10px;max-width:640px">
|
||||
<input type="hidden" name="section" value="sec-npr">
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:600"><input type="checkbox" name="enabled" value="1" {{if .Settings.NPR.Enabled}}checked{{end}}> Включить модуль</label>
|
||||
<label>Строка подключения к базе экспорта QUIK <input type="text" name="dsn" value="{{.Settings.NPR.DSN}}" placeholder="postgres://quik_ro:пароль@localhost:5432/quik_export" style="width:100%;font-family:ui-monospace,monospace;font-size:12px"></label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Сохранить</button><button type="submit" formaction="/admin/setup/npr/test" class="btn">Проверить подключение</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<h1>Лицензия</h1>
|
||||
</section>
|
||||
|
||||
{{/* ============ КРИПТОГРАФИЯ ============ */}}
|
||||
@@ -642,7 +656,8 @@ function hbBind(inp,box,list,onPick){
|
||||
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button type="submit" class="btn btn-ok">Сохранить</button>
|
||||
{{if .Settings.LK.APIKey}}<a class="btn" href="/admin/setup/lk/dist.tar.gz">Скачать дистрибутив кабинета</a>{{end}}
|
||||
{{if .Settings.LK.APIKey}}<a class="btn" href="/admin/setup/lk/dist.tar.gz">Скачать для своего сервера (Linux)</a>
|
||||
<a class="btn" href="/admin/setup/lk/relay-php.zip">Скачать для PHP-хостинга</a>{{end}}
|
||||
<a class="btn btn-secondary" href="/admin/help/lk">Инструкция по размещению</a>
|
||||
</div>
|
||||
</form>
|
||||
@@ -658,7 +673,8 @@ function hbBind(inp,box,list,onPick){
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="muted" style="font-size:12px;margin-top:10px">В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
|
||||
<p class="muted" style="font-size:12px;margin-top:10px"><b>Где собираются страницы.</b> Для PHP-хостинга — всегда в ядре: на хостинге лежит только ретранслятор (index.php), который передаёт запросы браузера ядру. Для своего сервера установщик спросит: собирать страницы в ядре (тогда программа на сервере — тоже ретранслятор, новые разделы появляются обновлением ядра) или на месте. Ключа ни в одном архиве нет.</p>
|
||||
<p class="muted" style="font-size:12px;margin-top:6px">В архиве для своего сервера: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="sec-lk-users">
|
||||
@@ -1040,18 +1056,6 @@ function hbBind(inp,box,list,onPick){
|
||||
|
||||
{{/* ============ ЛИЦЕНЗИЯ ============ */}}
|
||||
<section class="settings-section" id="sec-license">
|
||||
<div class="card" id="sec-npr">
|
||||
<h2><span class="dot {{if .Settings.NPR.Enabled}}ok{{else}}warn{{end}}"></span>Модуль «НПР / УДС» (риск-монитор по данным QUIK)</h2>
|
||||
<p class="muted">Показывает НПР1 / НПР2 / УДС клиентов маржинальной торговли по базе экспорта QUIK (схема <code>npr</code>). Отдельно лицензируемый модуль (признак <code>npr</code>); при включении в меню появляется пункт «НПР / УДС». Подключение — логин только для чтения.</p>
|
||||
{{template "verline" (.Versions.Get "bj-npr")}}
|
||||
<form method="post" action="/admin/setup/npr" style="margin-top:10px;display:grid;gap:10px;max-width:640px">
|
||||
<input type="hidden" name="section" value="sec-npr">
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:600"><input type="checkbox" name="enabled" value="1" {{if .Settings.NPR.Enabled}}checked{{end}}> Включить модуль</label>
|
||||
<label>Строка подключения к базе экспорта QUIK <input type="text" name="dsn" value="{{.Settings.NPR.DSN}}" placeholder="postgres://quik_ro:пароль@localhost:5432/quik_export" style="width:100%;font-family:ui-monospace,monospace;font-size:12px"></label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Сохранить</button><button type="submit" formaction="/admin/setup/npr/test" class="btn">Проверить подключение</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<h1>Лицензия</h1>
|
||||
<div class="card">
|
||||
<h2>
|
||||
{{if .License.Valid}}<span class="dot ok"></span>Активна
|
||||
@@ -1081,7 +1085,7 @@ function hbBind(inp,box,list,onPick){
|
||||
<p class="muted">Введите код активации от поставщика (вида <code>BJ-XXXX-XXXX-XXXX</code>). bj зарегистрирует эту установку на сервере лицензий <code>lic.zetit.ru</code> и дальше сам продлевает ключ раз в сутки. Серверу bj нужен доступ по HTTPS к lic.zetit.ru.</p>
|
||||
<form method="post" action="/admin/setup/license/activate" style="margin-top:12px;display:grid;gap:10px;max-width:520px">
|
||||
<label>Код активации <input type="text" name="code" placeholder="BJ-XXXX-XXXX-XXXX" autocomplete="off" spellcheck="false" style="width:100%;font-family:ui-monospace,monospace;letter-spacing:.04em"></label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Активировать</button>{{if .License.Online}}<button type="submit" formaction="/admin/setup/license/renew" class="btn">Продлить сейчас</button>{{end}}</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Активировать</button>{{if .License.Present}}<button type="submit" formaction="/admin/setup/license/renew" class="btn" title="Запросить у сервера лицензий свежий ключ: срок, модули, режим разработки">Обновить с сервера лицензий</button>{{end}}</div>
|
||||
</form>
|
||||
<p class="muted" style="font-size:12px;margin-top:12px">Установка <code>{{if .Settings.License.InstallID}}{{.Settings.License.InstallID}}{{else}}будет создана при первом обращении{{end}}</code>{{if not .Settings.License.LastSync.IsZero}} · последнее продление {{.Settings.License.LastSync.Format "02.01.2006 15:04"}}{{end}}</p>
|
||||
{{if .Settings.License.LastMsg}}<p style="font-size:13px;margin-top:6px;{{if .Settings.License.LastOK}}color:var(--ok){{else}}color:var(--err){{end}}">{{if .Settings.License.LastOK}}✓{{else}}✗{{end}} {{.Settings.License.LastMsg}} <span class="muted">({{.Settings.License.LastAttempt.Format "02.01.2006 15:04"}})</span></p>{{end}}
|
||||
|
||||
+24
-41
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+17
-5
@@ -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"))
|
||||
|
||||
+59
-10
@@ -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)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Кабинет клиента депозитария — ретранслятор (PHP). Все адреса ведут в index.php.
|
||||
Options -Indexes
|
||||
DirectoryIndex index.php
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L,QSA]
|
||||
</IfModule>
|
||||
# Служебные файлы наружу не отдаём.
|
||||
<FilesMatch "\.(sqlite|sqlite-wal|sqlite-shm|json|md|txt)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
@@ -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.
|
||||
@@ -0,0 +1,629 @@
|
||||
<?php
|
||||
/*
|
||||
* Кабинет клиента депозитария — ретранслятор для обычного хостинга (PHP 8.1+).
|
||||
*
|
||||
* Страницы кабинета собирает ядро (bj-server). Этот файл ничего не рисует и ничего не
|
||||
* хранит о клиентах: он упаковывает запрос браузера в конверт, передаёт ядру и отдаёт
|
||||
* браузеру ответ. Два способа связи:
|
||||
* direct — хостинг сам обращается к ядру (POST /api/lk/render);
|
||||
* agent — у ядра нет адреса в интернете: конверт ждёт в очереди (SQLite), ядро само
|
||||
* подключается сюда, забирает его и присылает ответ.
|
||||
* Установка — в браузере: откройте адрес кабинета, запустится установщик.
|
||||
*
|
||||
* Нужны расширения curl и sqlite3 (или pdo_sqlite). Файлы настроек и очереди лежат в
|
||||
* каталоге данных: рядом с сайтом (../bj-lk-data), если туда можно писать, иначе ./data.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
ini_set('display_errors', '0');
|
||||
error_reporting(E_ALL);
|
||||
set_time_limit(45);
|
||||
|
||||
const LK_VERSION = '1.0';
|
||||
const RELAY_MAX_BODY = 1048576; // 1 МБ от браузера
|
||||
const RELAY_WAIT_SEC = 25; // сколько ждём ответ ядра в режиме ожидания
|
||||
const AGENT_POLL_SEC = 5; // сколько держим подключение ядра, если очередь пуста
|
||||
|
||||
// ---------- служебное ----------
|
||||
|
||||
function dataDir(): string
|
||||
{
|
||||
$outside = dirname(__DIR__) . '/bj-lk-data';
|
||||
if (is_dir($outside) && is_writable($outside)) {
|
||||
return $outside;
|
||||
}
|
||||
if (!is_dir($outside) && is_writable(dirname(__DIR__)) && @mkdir($outside, 0700)) {
|
||||
return $outside;
|
||||
}
|
||||
$inside = __DIR__ . '/data';
|
||||
if (!is_dir($inside)) {
|
||||
@mkdir($inside, 0700);
|
||||
@file_put_contents($inside . '/.htaccess', "Require all denied\nDeny from all\n");
|
||||
@file_put_contents($inside . '/index.php', "<?php http_response_code(404);\n");
|
||||
}
|
||||
return $inside;
|
||||
}
|
||||
|
||||
function dataInsideWebroot(): bool
|
||||
{
|
||||
return str_starts_with(dataDir(), __DIR__);
|
||||
}
|
||||
|
||||
/** Настройки: файл config.php — PHP-заглушка + JSON, чтобы даже отданный веб-сервером файл ничего не показал. */
|
||||
function loadConfig(): array
|
||||
{
|
||||
$f = dataDir() . '/config.php';
|
||||
if (!is_file($f)) {
|
||||
return [];
|
||||
}
|
||||
$raw = (string)file_get_contents($f);
|
||||
$pos = strpos($raw, "\n");
|
||||
$json = $pos === false ? '' : substr($raw, $pos + 1);
|
||||
$cfg = json_decode($json, true);
|
||||
return is_array($cfg) ? $cfg : [];
|
||||
}
|
||||
|
||||
function saveConfig(array $cfg): void
|
||||
{
|
||||
$f = dataDir() . '/config.php';
|
||||
$raw = "<?php exit; ?>\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('Кабинет временно недоступен', '<div class="msg err">' . h($e->getMessage()) . '</div>'
|
||||
. '<p class="mut">Страницы кабинета собирает учётная система депозитария; сейчас связи с ней нет. Попробуйте обновить страницу через минуту.</p>'
|
||||
. '<a class="btn" href="/">Обновить</a>', 'Страницы собирает учётная система депозитария; сейчас связи с ней нет.');
|
||||
}
|
||||
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 .= '<span class="st"><span class="n" style="color:' . ($on ? '#e8a33d' : '#8e8879') . '">0' . ($i + 1) . '</span><span>' . h($name) . '</span></span>';
|
||||
}
|
||||
echo '<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . h($title) . '</title>
|
||||
<style>
|
||||
:root{--paper:#edebe4;--board:#191a1c;--ink:#1b1b1d;--mut:#6e6a60;--line:#d9d5ca;--amb:#e8a33d}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--paper);color:var(--ink);line-height:1.5}
|
||||
.login{min-height:100vh;display:grid;grid-template-columns:minmax(0,460px) 1fr}
|
||||
.side{background:var(--board);color:#d9d3c6;padding:40px 36px;display:flex;flex-direction:column;justify-content:space-between;gap:30px}
|
||||
.side h1{color:#f3f0e7;font-size:30px;line-height:1.2;margin:0}.side .lbl{font-family:ui-monospace,monospace;font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#f3f0e7}
|
||||
.st{display:flex;gap:12px;margin-top:12px;font-size:14.5px}.n{font-family:ui-monospace,monospace;font-variant-numeric:tabular-nums}
|
||||
.form{display:flex;align-items:center;justify-content:center;padding:36px 20px}.form form,.form .box{width:100%;max-width:520px;display:flex;flex-direction:column;gap:16px}
|
||||
h2{margin:0;font-size:26px}label.l{display:block;font-family:ui-monospace,monospace;font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--mut);margin-bottom:6px}
|
||||
input[type=text],input[type=password],input[type=email]{width:100%;padding:11px 12px;border:1px solid var(--line);border-radius:3px;background:#fff;font:inherit;font-size:16px}
|
||||
.radio{display:flex;gap:8px;align-items:flex-start;font-size:14px;margin-top:6px}.mut{color:var(--mut);font-size:13.5px;margin:0}
|
||||
.btn{display:inline-block;padding:12px 18px;border-radius:3px;border:1px solid var(--ink);background:var(--ink);color:#f3f0e7;font:inherit;font-weight:600;cursor:pointer;text-decoration:none;text-align:center}
|
||||
.btn.sec{background:transparent;color:var(--ink)}.msg{padding:12px 14px;border-radius:3px;font-size:14px}.msg.err{background:#f6d9d4;color:#7a2418}.msg.ok{background:#d7e9dc;color:#1f5a33}
|
||||
.chk{display:flex;gap:10px;font-size:14px}.chk b.ok{color:#3f8f5a}.chk b.warn{color:#e8a33d}.chk b.bad{color:#c0392b}
|
||||
@media(max-width:820px){.login{grid-template-columns:1fr}.side{padding:26px 20px}.side h1{font-size:24px}}
|
||||
</style></head><body><div class="login"><section class="side"><span class="lbl">Установка кабинета</span><div><h1>' . h($title) . '</h1>' . $stepsHtml . '</div><p class="mut" style="color:#8e8879">' . h($side) . '</p></section><section class="form">' . $formHtml . '</section></div></body></html>';
|
||||
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 = '<div style="display:grid;gap:8px">';
|
||||
foreach (installChecks() as [$name, $note, $state]) {
|
||||
$mark = $state === 'bad' ? '✗' : '✓';
|
||||
$html .= '<div class="chk"><b class="' . $state . '">' . $mark . '</b><span><b>' . h($name) . '.</b> ' . h($note) . '</span></div>';
|
||||
}
|
||||
return $html . '</div>';
|
||||
}
|
||||
|
||||
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('Кабинет уже настроен', '<div class="box"><p class="mut">Установщик закрыт: настройки сохранены в ' . h(dataDir()) . '/config.php. Чтобы изменить их, удалите этот файл и откройте страницу снова.</p><a class="btn" href="/login">Перейти ко входу</a></div>', $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 = '<form method="post" action="/install"><input type="hidden" name="csrf" value="' . h($csrf) . '"><input type="hidden" name="step" value="connect">'
|
||||
. ($error !== '' ? '<div class="msg err">' . h($error) . '</div>' : '')
|
||||
. '<h2>Сервер и связь с ядром</h2>' . checksHtml()
|
||||
. '<div><label class="l">Заголовок кабинета</label><input type="text" name="title" value="' . h($vals['title']) . '" placeholder="Депозитарий · личный кабинет"></div>'
|
||||
. '<div><label class="l">Как связаться с ядром</label>'
|
||||
. '<label class="radio"><input type="radio" name="mode" value="direct"' . ($vals['mode'] !== 'agent' ? ' checked' : '') . '> <span><b>Кабинет обращается к ядру</b> — у ядра есть адрес, доступный с этого хостинга.</span></label>'
|
||||
. '<label class="radio"><input type="radio" name="mode" value="agent"' . ($vals['mode'] === 'agent' ? ' checked' : '') . '> <span><b>Ядро подключается к кабинету</b> — у ядра нет адреса в интернете; оно само дозвонится сюда.</span></label></div>'
|
||||
. '<div><label class="l">Адрес ядра (только для первого способа)</label><input type="text" name="bj_url" value="' . h($vals['bj_url']) . '" placeholder="https://bj.депозитарий.ru" autocomplete="off"></div>'
|
||||
. '<div><label class="l">Ключ доступа модуля</label><input type="password" name="bj_key" value="' . h($vals['bj_key']) . '" autocomplete="off" placeholder="lk-…"><p class="mut" style="margin-top:6px">Создаётся в настройках ядра: «Личный кабинет» → «Создать ключ».</p></div>'
|
||||
. '<button class="btn" type="submit">Проверить связь и сохранить</button></form>';
|
||||
page('Три шага — и кабинет работает', $f, $side, $steps);
|
||||
}
|
||||
|
||||
function adminForm(string $csrf, string $notice, string $error, array $steps, string $side): never
|
||||
{
|
||||
$f = '<form method="post" action="/install"><input type="hidden" name="csrf" value="' . h($csrf) . '"><input type="hidden" name="step" value="admin">'
|
||||
. ($error !== '' ? '<div class="msg err">' . h($error) . '</div>' : '') . ($notice !== '' ? '<div class="msg ok">' . h($notice) . '</div>' : '')
|
||||
. '<h2>Администратор кабинета</h2><p class="mut">Сотрудник депозитария, который входит в кабинет, видит список клиентов и может открыть кабинет клиента на просмотр. Учётная запись заводится в ядре. Если администратор уже есть — шаг можно пропустить.</p>'
|
||||
. '<div><label class="l">Логин</label><input type="text" name="login" autocomplete="off"></div>'
|
||||
. '<div><label class="l">Имя сотрудника</label><input type="text" name="full_name" autocomplete="off"></div>'
|
||||
. '<div><label class="l">Электронная почта (письма пока не отправляются)</label><input type="email" name="email" autocomplete="off"></div>'
|
||||
. '<div><label class="l">Пароль (от 10 символов, буквы и цифра)</label><input type="password" name="password" autocomplete="new-password"></div>'
|
||||
. '<div><label class="l">Пароль ещё раз</label><input type="password" name="password2" autocomplete="new-password"></div>'
|
||||
. '<div style="display:flex;gap:10px;flex-wrap:wrap"><button class="btn" type="submit">Создать и завершить</button><button class="btn sec" type="submit" name="skip" value="1">Пропустить</button></div></form>';
|
||||
page('Три шага — и кабинет работает', $f, $side, $steps);
|
||||
}
|
||||
|
||||
function done(string $side): never
|
||||
{
|
||||
page('Кабинет установлен', '<div class="box"><h2>Готово</h2><p class="mut">Клиенты входят по логину и паролю, которые выдаёт депозитарий. Учётные записи заводятся в настройках ядра, раздел «Личный кабинет». Настройки ретранслятора — в ' . h(dataDir()) . '/config.php; чтобы пройти установку заново, удалите его.</p><a class="btn" href="/login">Перейти ко входу</a></div>', $side);
|
||||
}
|
||||
|
||||
function installWait(array $cfg): never
|
||||
{
|
||||
securityHeaders();
|
||||
$side = 'В настройках ядра (раздел «Личный кабинет») выберите «Ядро подключается к кабинету» и укажите адрес этого сайта. Ключ должен совпадать.';
|
||||
if (coreSeen()) {
|
||||
adminForm(csrfToken(), 'Ядро подключилось к кабинету. Осталось завести администратора.', '', [['Проверка сервера', true], ['Связь с ядром', true], ['Администратор кабинета', true]], $side);
|
||||
}
|
||||
header('Refresh: 5');
|
||||
page('Ждём подключения ядра', '<div class="box"><div class="msg ok">Настройки сохранены. Ждём, когда ядро подключится к кабинету по адресу <b>' . h($_SERVER['HTTP_HOST'] ?? '') . '</b>… Страница обновляется сама.</div></div>', $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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -344,6 +344,11 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;text-transform:none;letter-spacing:0;font-family:inherit;font-size:14px;color:var(--ink);margin-top:6px"><input type="radio" name="mode" value="direct" {{if not $d.Cfg.Agent}}checked{{end}}> <span><b>Кабинет обращается к ядру</b> — у ядра есть адрес, доступный с этого сервера.</span></label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;text-transform:none;letter-spacing:0;font-family:inherit;font-size:14px;color:var(--ink);margin-top:6px"><input type="radio" name="mode" value="agent" {{if $d.Cfg.Agent}}checked{{end}}> <span><b>Ядро подключается к кабинету</b> — у ядра нет адреса в интернете; оно само дозвонится сюда.</span></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Где собираются страницы</label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;text-transform:none;letter-spacing:0;font-family:inherit;font-size:14px;color:var(--ink);margin-top:6px"><input type="radio" name="render" value="core" {{if $d.Cfg.Relay}}checked{{end}}> <span><b>В ядре</b> — здесь только передача; новые разделы появляются обновлением ядра.</span></label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;text-transform:none;letter-spacing:0;font-family:inherit;font-size:14px;color:var(--ink);margin-top:6px"><input type="radio" name="render" value="local" {{if not $d.Cfg.Relay}}checked{{end}}> <span><b>Здесь</b> — эта программа собирает страницы сама из данных ядра.</span></label>
|
||||
</div>
|
||||
<div>
|
||||
<label for="bj_url">Адрес ядра <span style="text-transform:none;letter-spacing:0">(только для первого способа)</span></label>
|
||||
<input id="bj_url" name="bj_url" value="{{$d.Cfg.BJURL}}" placeholder="https://bj.депозитарий.ru" autocomplete="off">
|
||||
@@ -385,6 +390,11 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
||||
<section class="form"><form action="/login" method="get" style="max-width:420px"><p class="mut" style="margin:0">Установщик закрыт: настройки сохранены в <span class="n">{{.File}}</span>. Чтобы изменить их, отредактируйте файл или удалите его и откройте эту страницу снова.</p><button class="btn main" type="submit">Перейти ко входу</button></form></section></div>
|
||||
</body></html>{{end}}
|
||||
|
||||
{{define "relay_error"}}{{template "head" .}}
|
||||
<div class="login"><section class="side"><span class="brand">{{template "mark" .}}<span class="lbl" style="font-size:12px;letter-spacing:.14em;color:#f3f0e7">{{.Title}}</span></span><div><h1>Кабинет временно недоступен</h1></div><p class="mut" style="color:#8e8879;margin:0">Страницы кабинета собирает учётная система депозитария; сейчас связи с ней нет.</p></section>
|
||||
<section class="form"><form action="/" method="get" style="max-width:420px"><div class="msg err">{{.Error}}</div><p class="mut" style="margin:0">Попробуйте обновить страницу через минуту. Если не помогает — сообщите в депозитарий.</p><button class="btn main" type="submit">Обновить</button></form></section></div>
|
||||
</body></html>{{end}}
|
||||
|
||||
{{define "profile"}}{{template "head" .}}{{template "nav" .}}
|
||||
<section class="board"><div class="board-row">
|
||||
<div><span class="lbl" style="color:var(--dim)">Профиль</span>
|
||||
|
||||
Reference in New Issue
Block a user