054222d154
- modules/lk: конверт запроса/ответа (PageRequest/PageResponse), ServeEnvelope — тот же обработчик страниц внутри ядра; клиент к API внутри процесса (Config.Local/LocalKey); режим ретранслятора (Config.Relay, BJ_RENDER=core, выбор в установщике и lk.json) - ядро: встроенный кабинет (lkcabinet.go), POST /api/lk/render для прямого режима, цикл режима ожидания выполняет конверты страниц; API через ретранслятор недостижимо - PHP-ретранслятор для обычного хостинга (modules/lk/relay-php: index.php, .htaccess, README): установщик в браузере, прямой режим через curl, режим ожидания через очередь в SQLite (BEGIN IMMEDIATE, busy_timeout), конфиг в bj-lk-data рядом с сайтом или в data под .htaccess; архив со страницы настроек (/admin/setup/lk/relay-php.zip) - настройки: карточка НПР/УДС перенесена из раздела «Лицензия» в «Экспорт QUIK»; кнопка «Обновить с сервера лицензий» видна при любом ключе - инструкция /admin/help/lk: два варианта размещения, шаги для PHP-хостинга - тесты: TestLKRelayRendersInCore (оба режима), TestLKPHPRelayAgentMode (настоящий PHP против цикла ядра, PHP_BIN), проверка безопасности через PHP-ретранслятор 34/34 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
112 lines
3.9 KiB
Go
112 lines
3.9 KiB
Go
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)
|
|
}
|
|
}
|