Files
Bridge-and-Join-s/modules/lk/relay-php/index.php
T
zuevav d975957335 Безопасность: fail-closed auth (H1), доверенный прокси для IP (H2), CSRF на /order (M2)
По результатам security-аудита (docs, run-1). Три верифицированные находки.

H1 (HIGH) — fail-open аутентификация. loadUsers() при ошибке чтения/парсинга
users.json молча возвращал пустой стор → authOpen() считал это первичной
настройкой → после удаления first-run файла весь /admin отдавался без входа.
Теперь: постоянный маркер configured (пишется при создании первого админа и
бэкфиллится при загрузке валидного стора); authOpen() открыт только на реально
свежей системе; при пустом/битом сторе на настроенной системе — 503 и блокировка
до восстановления файла (authLockedOut). Тест TestAuthFailClosed (4 сценария).

H2 (HIGH) — интернет-слой доверял клиентскому X-Forwarded-For/X-Real-IP, что
позволяло обойти лимит входа по IP и травить аудит. Теперь заголовкам доверяем
только от доверенного прокси (по умолчанию loopback; расширяется
BJ_TRUSTED_PROXIES), из XFF берём правый элемент. Исправлено и в Go-модуле
(modules/lk/security.go), и в PHP-ретрансляторе. Тесты TestClientIPTrust (Go) и
прогон логики PHP.

M2 (MEDIUM) — POST /order (поручение на списание бумаг) не проверял CSRF, в
отличие от /login и /impersonate. Добавлен csrfOK (форма уже несла токен).
Тест TestOrderCSRF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 21:25:35 +03:00

662 lines
35 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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';
}
// peerTrusted — непосредственный источник запроса (REMOTE_ADDR) — доверенный прокси:
// loopback или адрес/CIDR из переменной окружения BJ_TRUSTED_PROXIES (через запятую).
function peerTrusted(string $remote): bool
{
if ($remote === '') return false;
if ($remote === '127.0.0.1' || $remote === '::1' || str_starts_with($remote, '127.')) return true;
$list = getenv('BJ_TRUSTED_PROXIES') ?: '';
foreach (explode(',', $list) as $cidr) {
$cidr = trim($cidr);
if ($cidr === '') continue;
if (!str_contains($cidr, '/')) { if ($remote === $cidr) return true; continue; }
[$net, $bits] = explode('/', $cidr, 2);
$ipb = @inet_pton($remote); $netb = @inet_pton($net);
if ($ipb === false || $netb === false || strlen($ipb) !== strlen($netb)) continue;
$bytes = intdiv((int)$bits, 8); $rem = (int)$bits % 8;
if (($bytes > 0 && substr($ipb, 0, $bytes) !== substr($netb, 0, $bytes))) continue;
if ($rem === 0) return true;
$mask = chr((0xff << (8 - $rem)) & 0xff);
if ((ord($ipb[$bytes]) & ord($mask)) === (ord($netb[$bytes]) & ord($mask))) return true;
}
return false;
}
// clientIP — адрес посетителя. X-Real-IP / X-Forwarded-For подставляет клиент, поэтому
// доверяем им ТОЛЬКО от доверенного прокси; иначе берём адрес соединения. Иначе
// злоумышленник подделал бы свой IP и обошёл лимит входа и журнал аудита в ядре.
function clientIP(): string
{
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
if (peerTrusted($remote)) {
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
return trim($_SERVER['HTTP_X_REAL_IP']);
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// правый элемент добавил наш доверенный прокси; левые может подставить клиент
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($parts[count($parts) - 1]);
}
}
return $remote;
}
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);