\n" . json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); if (file_put_contents($f . '.tmp', $raw, LOCK_EX) === false || !rename($f . '.tmp', $f)) { throw new RuntimeException('не удалось записать настройки в ' . dataDir()); } @chmod($f, 0600); } function configured(array $cfg): bool { if (empty($cfg['bj_key'])) { return false; } return ($cfg['mode'] ?? '') === 'agent' || !empty($cfg['bj_url']); } function isHttps(): bool { if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') { return true; } $xf = strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')[0])); return $xf === 'https'; } function isLocal(): bool { $h = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]); return $h === 'localhost' || $h === '127.0.0.1' || $h === '::1'; } function clientIP(): string { foreach (['HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR'] as $k) { if (!empty($_SERVER[$k])) { return trim(explode(',', $_SERVER[$k])[0]); } } return $_SERVER['REMOTE_ADDR'] ?? ''; } function keyOK(string $key): bool { $got = $_SERVER['HTTP_X_BJ_KEY'] ?? ''; return $key !== '' && $got !== '' && hash_equals($key, $got); } function jsonOut(int $code, array $data): never { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; } function securityHeaders(): void { header('X-Frame-Options: DENY'); header('X-Content-Type-Options: nosniff'); header('Referrer-Policy: same-origin'); header('Cache-Control: no-store, no-cache, must-revalidate, private'); if (isHttps()) { header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); } } // ---------- вызовы ядра (прямой режим) ---------- function coreCall(array $cfg, string $method, string $path, ?array $body, int $timeout = 15): array { $ch = curl_init(rtrim((string)$cfg['bj_url'], '/') . $path); $headers = ['X-BJ-Key: ' . $cfg['bj_key'], 'Accept: application/json']; $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 8, CURLOPT_FOLLOWLOCATION => false, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]; if ($body !== null) { $opts[CURLOPT_POSTFIELDS] = json_encode($body, JSON_UNESCAPED_UNICODE); $headers[] = 'Content-Type: application/json'; } $opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts); $raw = curl_exec($ch); if ($raw === false) { $err = curl_error($ch); curl_close($ch); return [0, null, 'нет связи с ядром: ' . $err]; } $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $data = json_decode((string)$raw, true); if ($code >= 400) { $msg = is_array($data) && !empty($data['error']) ? (string)$data['error'] : 'ядро ответило ' . $code; return [$code, $data, $msg]; } return [$code, is_array($data) ? $data : [], '']; } // ---------- очередь режима ожидания (SQLite) ---------- function queue(): PDO { static $pdo = null; if ($pdo instanceof PDO) { return $pdo; } // Несколько процессов PHP открывают файл одновременно (ждущий браузер и подключения // ядра): сначала ждём занятую базу, и только потом трогаем режим журнала. $pdo = new PDO('sqlite:' . dataDir() . '/queue.sqlite', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]); $pdo->exec('PRAGMA busy_timeout=5000'); try { $pdo->exec('PRAGMA journal_mode=WAL'); } catch (Throwable) { // режим журнала уже выставлен другим процессом — не страшно } $pdo->exec('CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, created REAL NOT NULL, taken INTEGER NOT NULL DEFAULT 0, request TEXT NOT NULL, response TEXT)'); $pdo->exec('CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT)'); @chmod(dataDir() . '/queue.sqlite', 0600); return $pdo; } function queueCleanup(PDO $q): void { $q->prepare('DELETE FROM tasks WHERE created < ?')->execute([microtime(true) - 300]); } function metaSet(string $k, string $v): void { queue()->prepare('INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v')->execute([$k, $v]); } function metaGet(string $k): string { try { $st = queue()->prepare('SELECT v FROM meta WHERE k = ?'); $st->execute([$k]); return (string)($st->fetchColumn() ?: ''); } catch (Throwable) { return ''; } } function coreSeen(): bool { $t = (float)metaGet('last_fetch'); return $t > 0 && microtime(true) - $t < 60; } /** Поставить конверт в очередь и дождаться ответа ядра. */ function queueRoundTrip(array $env): array { $q = queue(); queueCleanup($q); $env['id'] = bin2hex(random_bytes(8)); $q->prepare('INSERT INTO tasks (id, created, request) VALUES (?, ?, ?)') ->execute([$env['id'], microtime(true), json_encode($env, JSON_UNESCAPED_UNICODE)]); $deadline = microtime(true) + RELAY_WAIT_SEC; $st = $q->prepare('SELECT response FROM tasks WHERE id = ?'); while (microtime(true) < $deadline) { usleep(150000); $st->execute([$env['id']]); $resp = $st->fetchColumn(); if (is_string($resp) && $resp !== '') { $q->prepare('DELETE FROM tasks WHERE id = ?')->execute([$env['id']]); $data = json_decode($resp, true); return is_array($data) ? $data : ['status' => 502, 'body' => '']; } } $q->prepare('DELETE FROM tasks WHERE id = ?')->execute([$env['id']]); throw new RuntimeException('ядро не ответило: проверьте, что bj-server подключён к кабинету'); } /** GET /agent/tasks — ядро забирает конверт; пусто — 204 через несколько секунд. */ function agentTasks(array $cfg): never { if (!keyOK((string)$cfg['bj_key'])) { jsonOut(403, ['error' => 'неверный ключ модуля']); } $q = queue(); metaSet('last_fetch', (string)microtime(true)); $deadline = microtime(true) + AGENT_POLL_SEC; while (true) { $row = null; try { $q->exec('BEGIN IMMEDIATE'); // сразу берём запись на письмо: два подключения ядра не заберут один конверт $st = $q->query('SELECT id, request FROM tasks WHERE taken = 0 AND response IS NULL ORDER BY created LIMIT 1'); $row = $st->fetch(PDO::FETCH_ASSOC); if ($row) { $q->prepare('UPDATE tasks SET taken = 1 WHERE id = ?')->execute([$row['id']]); } $q->exec('COMMIT'); } catch (Throwable) { try { $q->exec('ROLLBACK'); } catch (Throwable) { } $row = null; // база занята другим процессом — попробуем на следующем круге } if ($row) { http_response_code(200); header('Content-Type: application/json; charset=utf-8'); echo $row['request']; exit; } if (microtime(true) >= $deadline) { http_response_code(204); exit; } usleep(200000); } } /** POST /agent/result — ядро возвращает ответ на конверт. */ function agentResult(array $cfg): never { if (!keyOK((string)$cfg['bj_key'])) { jsonOut(403, ['error' => 'неверный ключ модуля']); } $raw = (string)file_get_contents('php://input'); $data = json_decode($raw, true); if (!is_array($data) || empty($data['id'])) { jsonOut(400, ['error' => 'не разобрали ответ']); } $st = queue()->prepare('UPDATE tasks SET response = ? WHERE id = ? AND response IS NULL'); $st->execute([$raw, (string)$data['id']]); http_response_code($st->rowCount() ? 204 : 410); exit; } // ---------- ретрансляция ---------- function envelopeFromRequest(): array { $body = (string)file_get_contents('php://input'); if (strlen($body) > RELAY_MAX_BODY) { throw new LengthException('Запрос слишком большой'); } $headers = []; foreach (['Cookie' => 'HTTP_COOKIE', 'Accept' => 'HTTP_ACCEPT', 'Accept-Language' => 'HTTP_ACCEPT_LANGUAGE', 'Content-Type' => 'CONTENT_TYPE', 'If-None-Match' => 'HTTP_IF_NONE_MATCH', 'If-Modified-Since' => 'HTTP_IF_MODIFIED_SINCE'] as $name => $key) { if (!empty($_SERVER[$key])) { $headers[$name] = [(string)$_SERVER[$key]]; } } return [ 'kind' => 'page', 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', 'path' => $_SERVER['REQUEST_URI'] ?? '/', 'host' => $_SERVER['HTTP_HOST'] ?? '', 'scheme' => isHttps() ? 'https' : 'http', 'headers' => (object)$headers, // объект, а не [] — иначе ядро не разберёт пустой список 'body' => base64_encode($body), 'client_ip' => clientIP(), 'client_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', ]; } function writeEnvelope(array $res): never { $hop = ['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'content-length', 'te', 'trailer']; http_response_code((int)($res['status'] ?? 502) ?: 502); foreach (($res['headers'] ?? []) as $name => $values) { if (in_array(strtolower((string)$name), $hop, true)) { continue; } foreach ((array)$values as $v) { header($name . ': ' . $v, false); } } echo base64_decode((string)($res['body'] ?? '')); exit; } function relay(array $cfg): never { try { $env = envelopeFromRequest(); } catch (LengthException $e) { http_response_code(413); exit($e->getMessage()); } try { if (($cfg['mode'] ?? '') === 'agent') { $res = queueRoundTrip($env); } else { [$code, $res, $err] = coreCall($cfg, 'POST', '/api/lk/render', $env, 40); if ($err !== '') { throw new RuntimeException($err); } } } catch (Throwable $e) { securityHeaders(); http_response_code(502); page('Кабинет временно недоступен', '
' . h($e->getMessage()) . '
' . '

Страницы кабинета собирает учётная система депозитария; сейчас связи с ней нет. Попробуйте обновить страницу через минуту.

' . 'Обновить', 'Страницы собирает учётная система депозитария; сейчас связи с ней нет.'); } 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 .= '0' . ($i + 1) . '' . h($name) . ''; } echo '' . h($title) . '
Установка кабинета

' . h($title) . '

' . $stepsHtml . '

' . h($side) . '

' . $formHtml . '
'; 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 = '
'; foreach (installChecks() as [$name, $note, $state]) { $mark = $state === 'bad' ? '✗' : '✓'; $html .= '
' . $mark . '' . h($name) . '. ' . h($note) . '
'; } return $html . '
'; } 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('Кабинет уже настроен', '

Установщик закрыт: настройки сохранены в ' . h(dataDir()) . '/config.php. Чтобы изменить их, удалите этот файл и откройте страницу снова.

Перейти ко входу
', $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 = '
' . ($error !== '' ? '
' . h($error) . '
' : '') . '

Сервер и связь с ядром

' . checksHtml() . '
' . '
' . '' . '
' . '
' . '

Создаётся в настройках ядра: «Кабинет клиента» → «Создать ключ».

' . '
'; page('Три шага — и кабинет работает', $f, $side, $steps); } function adminForm(string $csrf, string $notice, string $error, array $steps, string $side): never { $f = '
' . ($error !== '' ? '
' . h($error) . '
' : '') . ($notice !== '' ? '
' . h($notice) . '
' : '') . '

Администратор кабинета

Сотрудник депозитария, который входит в кабинет, видит список клиентов и может открыть кабинет клиента на просмотр. Учётная запись заводится в ядре. Если администратор уже есть — шаг можно пропустить.

' . '
' . '
' . '
' . '
' . '
' . '
'; page('Три шага — и кабинет работает', $f, $side, $steps); } function done(string $side): never { page('Кабинет установлен', '

Готово

Клиенты входят по логину и паролю, которые выдаёт депозитарий. Учётные записи заводятся в настройках ядра, раздел «Кабинет клиента». Настройки ретранслятора — в ' . h(dataDir()) . '/config.php; чтобы пройти установку заново, удалите его.

Перейти ко входу
', $side); } function installWait(array $cfg): never { securityHeaders(); $side = 'В настройках ядра (раздел «Кабинет клиента») выберите «Ядро подключается к кабинету» и укажите адрес этого сайта. Ключ должен совпадать.'; if (coreSeen()) { adminForm(csrfToken(), 'Ядро подключилось к кабинету. Осталось завести администратора.', '', [['Проверка сервера', true], ['Связь с ядром', true], ['Администратор кабинета', true]], $side); } header('Refresh: 5'); page('Ждём подключения ядра', '
Настройки сохранены. Ждём, когда ядро подключится к кабинету по адресу ' . h($_SERVER['HTTP_HOST'] ?? '') . '… Страница обновляется сама.
', $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);