\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('Кабинет временно недоступен', '
Страницы кабинета собирает учётная система депозитария; сейчас связи с ней нет. Попробуйте обновить страницу через минуту.
' . 'Обновить', 'Страницы собирает учётная система депозитария; сейчас связи с ней нет.'); } 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($side) . '
Установщик закрыт: настройки сохранены в ' . h(dataDir()) . '/config.php. Чтобы изменить их, удалите этот файл и откройте страницу снова.
Перейти ко входуКлиенты входят по логину и паролю, которые выдаёт депозитарий. Учётные записи заводятся в настройках ядра, раздел «Кабинет клиента». Настройки ретранслятора — в ' . h(dataDir()) . '/config.php; чтобы пройти установку заново, удалите его.
Перейти ко входу