Files
Bridge-and-Join-s/modules/lk/relay-php/index.php
T
zuevav e16d4454ce Безопасность: код установки ретранслятора (M1), zip-slip 7z (M3), L1/L5/L2
Продолжение исправлений по security-аудиту.

M1 (MEDIUM) — установщик PHP-ретранслятора был открыт без входа: до создания
админа атакующий из интернета мог завести админа кабинета или подменить адрес
ядра/ключ. Добавлен «код установки»: генерируется в файл install.lock рядом с
данными (вне веб-корня), прочитать его можно только с доступом к файловой
системе (FTP/файловый менеджер) — то есть владельцу сервера. Шаги connect и
admin (включая «пропустить») требуют верный код. Регексп loopback заякорен
(`http://127.0.0.1.attacker.com` больше не проходит как loopback). Код удаляется
по завершении установки. Проверено встроенным php-сервером.

M3 (MEDIUM) — распаковка 7z в мастере ключа (ExtractISO) сохраняла пути из
архива → запись «../…» вышла бы за каталог. Перед распаковкой архив
просматривается (7z l -slt, без записи); при абсолютном пути или выходе за
корень распаковка отклоняется. Тест TestUnsafeArchivePath.

L1 — target_device не проверялся перед root mount: значение не из списка
съёмных теперь отклоняется (иначе root смонтировал бы произвольный девайс).
L5 — likeEscape в ручном поиске DEPO (manualDepoSearch): метасимволы LIKE от
пользователя больше не «расползаются» по базе.
L2a — кука сессии ядра получает Secure и за TLS-терминирующим прокси на loopback
(reqIsHTTPS учитывает X-Forwarded-Proto только от loopback).

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

706 lines
38 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
{
$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();
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);