Кабинет: страницы собирает ядро, на хостинге — ретранслятор (Go и PHP)

- modules/lk: конверт запроса/ответа (PageRequest/PageResponse), ServeEnvelope — тот же
  обработчик страниц внутри ядра; клиент к API внутри процесса (Config.Local/LocalKey);
  режим ретранслятора (Config.Relay, BJ_RENDER=core, выбор в установщике и lk.json)
- ядро: встроенный кабинет (lkcabinet.go), POST /api/lk/render для прямого режима,
  цикл режима ожидания выполняет конверты страниц; API через ретранслятор недостижимо
- PHP-ретранслятор для обычного хостинга (modules/lk/relay-php: index.php, .htaccess,
  README): установщик в браузере, прямой режим через curl, режим ожидания через очередь
  в SQLite (BEGIN IMMEDIATE, busy_timeout), конфиг в bj-lk-data рядом с сайтом или в
  data под .htaccess; архив со страницы настроек (/admin/setup/lk/relay-php.zip)
- настройки: карточка НПР/УДС перенесена из раздела «Лицензия» в «Экспорт QUIK»;
  кнопка «Обновить с сервера лицензий» видна при любом ключе
- инструкция /admin/help/lk: два варианта размещения, шаги для PHP-хостинга
- тесты: TestLKRelayRendersInCore (оба режима), TestLKPHPRelayAgentMode (настоящий PHP
  против цикла ядра, PHP_BIN), проверка безопасности через PHP-ретранслятор 34/34

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
zuevav
2026-09-18 11:15:29 +03:00
parent f51414269d
commit 054222d154
19 changed files with 1403 additions and 97 deletions
+24 -41
View File
@@ -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
}
+212
View File
@@ -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
View File
@@ -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
View File
@@ -11,6 +11,7 @@ import (
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"net/url"
@@ -34,6 +35,12 @@ type Config struct {
// Agent — режим ожидания: у ядра нет адреса, доступного кабинету, поэтому
// не кабинет ходит в ядро, а ядро само подключается сюда за запросами.
Agent bool
// Relay — ретранслятор: страницы рисует ядро, здесь только передача конвертов.
Relay bool
// Local — кабинет собран внутрь ядра: API вызывается в том же процессе, ключ
// берётся функцией LocalKey (он меняется в настройках без перезапуска).
Local http.Handler
LocalKey func() string
}
// Server — кабинет. Настройки живут за мьютексом: веб-установщик меняет их на ходу,
@@ -45,6 +52,9 @@ type Server struct {
agent *agentQueue
dataDir string // где лежит lk.json (веб-установщик)
tpl *template.Template
handlerOnce sync.Once
handler http.Handler
}
// New — кабинет с разобранными шаблонами.
@@ -124,7 +134,7 @@ func (s *Server) apply(cfg Config) {
if cfg.Agent {
s.agent = newAgentQueue()
}
s.bjc = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: s.agent}
s.bjc = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: s.agent, local: cfg.Local, localKey: cfg.LocalKey}
}
// conf — текущие настройки (копия).
@@ -151,6 +161,9 @@ func (s *Server) agentQueue() *agentQueue {
func (s *Server) Configured() bool { return s.conf().configured() }
func (c Config) configured() bool {
if c.Local != nil {
return true // внутри ядра настраивать нечего
}
if c.BJKey == "" {
return false
}
@@ -175,9 +188,11 @@ func (c Config) insecureCore() bool {
// ---- клиент к bj-server ----
type bjClient struct {
cfg Config
hc *http.Client
agent *agentQueue // задан в режиме ожидания
cfg Config
hc *http.Client
agent *agentQueue // задан в режиме ожидания
local http.Handler // задан внутри ядра: API вызывается без сети
localKey func() string
}
func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error {
@@ -189,11 +204,19 @@ func (b *bjClient) call(ctx context.Context, method, path, session string, body
return b.callAgent(ctx, method, path, session, raw, out)
}
rdr := bytes.NewReader(raw)
req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr)
target := b.cfg.BJURL + path
if b.local != nil {
target = path
}
req, err := http.NewRequestWithContext(ctx, method, target, rdr)
if err != nil {
return err
}
req.Header.Set("X-BJ-Key", b.cfg.BJKey)
key := b.cfg.BJKey
if b.localKey != nil {
key = b.localKey()
}
req.Header.Set("X-BJ-Key", key)
if session != "" {
req.Header.Set("X-BJ-Session", session)
}
@@ -206,9 +229,18 @@ func (b *bjClient) call(ctx context.Context, method, path, session string, body
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := b.hc.Do(req)
if err != nil {
return fmt.Errorf("нет связи с сервером: %w", err)
var resp *http.Response
if b.local != nil {
// Внутри ядра: тот же обработчик API, только без сети. Граница проверок та же.
rec := &envelopeRecorder{status: http.StatusOK, hdr: http.Header{}}
req.RemoteAddr = "127.0.0.1:0"
b.local.ServeHTTP(rec, req)
resp = &http.Response{StatusCode: rec.status, Header: rec.hdr, Body: io.NopCloser(&rec.buf)}
} else {
resp, err = b.hc.Do(req)
if err != nil {
return errNoCore(err)
}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
@@ -225,6 +257,12 @@ func (b *bjClient) call(ctx context.Context, method, path, session string, body
return nil
}
// errNoCore / errCore — ошибки связи с ядром одним текстом для страниц.
func errNoCore(err error) error { return fmt.Errorf("нет связи с сервером: %w", err) }
func errCore(msg string) error { return fmt.Errorf("%s", msg) }
var errRequestTooLarge = fmt.Errorf("запрос слишком большой")
// callAgent — тот же запрос, но через очередь: его заберёт ядро, когда подключится.
func (b *bjClient) callAgent(ctx context.Context, method, path, session string, raw []byte, out any) error {
res, err := b.agent.push(ctx, method, path, raw, session, 40*time.Second)
@@ -512,6 +550,11 @@ func session(r *http.Request) string {
// Handler — все страницы кабинета. Пока кабинет не настроен, любая страница ведёт
// в веб-установщик (/install), как у обычных веб-приложений после копирования файлов.
func (s *Server) Handler() http.Handler {
s.handlerOnce.Do(func() { s.handler = s.buildHandler() })
return s.handler
}
func (s *Server) buildHandler() http.Handler {
mux := http.NewServeMux()
s.registerInstall(mux)
@@ -977,10 +1020,16 @@ func (s *Server) Handler() http.Handler {
s.registerAgent(mux)
mux.Handle("/assets/", assetsHandler())
return harden(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.Configured() && !strings.HasPrefix(r.URL.Path, "/install") && !strings.HasPrefix(r.URL.Path, "/assets/") && r.URL.Path != "/health" {
p := r.URL.Path
if !s.Configured() && !strings.HasPrefix(p, "/install") && !strings.HasPrefix(p, "/assets/") && p != "/health" {
http.Redirect(w, r, "/install", http.StatusSeeOther)
return
}
// Ретранслятор: всё, кроме своих служебных страниц, уезжает в ядро конвертом.
if s.conf().Relay && !strings.HasPrefix(p, "/install") && !strings.HasPrefix(p, "/agent/") && p != "/health" {
s.relay(w, r)
return
}
mux.ServeHTTP(w, r)
}))
}
+12
View File
@@ -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>
+36
View File
@@ -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.
+629
View File
@@ -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);
+20
View File
@@ -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
}
+10
View File
@@ -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>