d975957335
По результатам 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
662 lines
35 KiB
PHP
662 lines
35 KiB
PHP
<?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);
|