Files
Bridge-and-Join-s/modules/lk/relay-php/index.php
T
zuevav 3bb5e79272 Безопасность: остаток аудита — L2b, L3, L6, L7, L8, L10, L11, M4
- L2b: CSRF для изменяющих запросов админки — проверка Origin (иначе Referer)
  против Host на всех не-GET под /admin (sameOriginRequest в authMiddleware);
  за loopback-прокси учитывается X-Forwarded-Host. Второй слой поверх
  SameSite=Lax, без правки десятков форм настроек.
- L3: /back-to-admin меняет состояние — только POST + csrfOK (форма уже с токеном,
  проверки в обработчике не было).
- L6: EXEC процедуры DEPO по имени из настроек — через execProc с проверкой
  идентификатора (sqlident.go, см. коммит строчных событий).
- L7: сессия просмотра (impersonate) снимает IsAdmin цели — сотрудник не действует
  от имени другого администратора; свои действия — из своей сессии.
- L8: bootstrap администратора кабинета под замком lkBootstrapMu — два
  параллельных запроса не заведут двух админов (TOCTOU).
- L10: allowlist перезапуска служб не пропускает пустое имя.
- L11: ретранслятор — лимит тела проверяется по Content-Length до чтения и
  чтение ограничено RELAY_MAX_BODY+1.
- M4: .htaccess дополнительно запрещает *.lock (код установки), если данные
  оказались в веб-корне; nginx-запрет /data уже в README.

L4 (привязка сессии к сети по умолчанию выкл.) и L9 (косметическая cookie
bj_lk_admin, арбитр — ядро) оставлены как есть, отмечены в отчёте.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-22 16:28:14 +03:00

710 lines
39 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__);
}
// installTokenFile — файл одноразового «кода установки». Установщик кабинета
// открыт без входа (его ставят в браузере), поэтому чувствительные шаги (сохранить
// связь с ядром, завести администратора) требуют код, который лежит в файле рядом
// с данными: прочитать его можно только имея доступ к файловой системе (FTP,
// файловый менеджер, SSH) — то есть будучи владельцем сервера. Атакующий с одним
// HTTP-доступом код не узнает и настройку/создание админа не выполнит.
function installTokenFile(): string
{
return dataDir() . '/install.lock';
}
// installToken — текущий код установки; создаётся при первом показе установщика.
function installToken(): string
{
$f = installTokenFile();
if (is_file($f)) {
return trim((string)@file_get_contents($f));
}
try {
$tok = bin2hex(random_bytes(16));
} catch (Throwable $e) {
return '';
}
@file_put_contents($f, $tok);
@chmod($f, 0600);
return $tok;
}
// installTokenOK — введённый код совпадает с файлом (постоянное сравнение).
function installTokenOK(): bool
{
$want = installToken();
return $want !== '' && hash_equals($want, trim((string)($_POST['itoken'] ?? '')));
}
/** Настройки: файл 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
{
// L11: не читать тело целиком, если заявленный размер уже превышает лимит.
if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > RELAY_MAX_BODY) {
throw new LengthException('Запрос слишком большой');
}
$body = (string)file_get_contents('php://input', false, null, 0, RELAY_MAX_BODY + 1);
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();
installToken(); // создаём файл кода при первом показе, чтобы оператор мог его прочитать
$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' || $step === 'admin') && !installTokenOK()) {
// Чувствительный шаг без верного кода установки — не выполняем.
$error = 'Неверный код установки. Откройте файл ' . h(installTokenFile()) . ' через файловый менеджер или FTP и вставьте код из него.';
if ($step === 'admin') {
adminForm($csrf, '', $error, [['Проверка сервера', true], ['Связь с ядром', true], ['Администратор кабинета', true]], $side);
}
} 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)(:\d+)?(/|$)~', $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>'
. '<div><label class="l">Код установки</label><input type="text" name="itoken" autocomplete="off" placeholder="из файла install.lock"><p class="mut" style="margin-top:6px">Откройте файл <code>' . h(installTokenFile()) . '</code> через файловый менеджер хостинга или FTP и вставьте код из него — так мы убеждаемся, что настройку выполняет владелец сервера, а не посторонний из интернета.</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"><input type="hidden" name="itoken" value="' . h(installToken()) . '">'
. ($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
{
@unlink(installTokenFile()); // установка завершена — код больше не нужен
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);