diff --git a/.github/workflows/docker-pr-build.yml b/.github/workflows/docker-pr-build.yml index 1a9c67bc49..bd12c473ac 100644 --- a/.github/workflows/docker-pr-build.yml +++ b/.github/workflows/docker-pr-build.yml @@ -89,6 +89,19 @@ jobs: cache-from: type=gha,scope=pr-${{ github.base_ref }} cache-to: type=gha,scope=pr-${{ github.base_ref }},mode=max + # HF16 QC stand (#6618): 21-validator testnet image for the tc-netem bench. Only built + # for PRs from `pm`, where the QC work lives; other PRs skip it. + - uses: docker/build-push-action@v7 + if: github.head_ref == 'pm' + with: + context: . + file: ./share/vizd/docker/Dockerfile-testnet + build-args: TESTNET_VALIDATORS=21 + push: true + tags: vizblockchain/vizd:${{ steps.tag.outputs.value }}-testnet21 + cache-from: type=gha,scope=pr-testnet21 + cache-to: type=gha,scope=pr-testnet21,mode=max + # The Docker build above proves the top of the supported Boost range (1.91, # from the vizblockchain/vizd:boost-base base image). The tree is also supposed to build # against the 1.83 that Ubuntu 24.04 ships, and nothing else covers that end. @@ -137,6 +150,16 @@ jobs: # touching PM consensus math, evaluators, crons, or chain properties. Builds only the # tests/pm targets (chain/protocol/fc compile as make-dependencies); the consensus_sim # harness is configured by BUILD_CONSENSUS_TESTS but never built here. + # + # 2026-10-20: this job is now a SUBSET of tests.yml and is kept only as a fast manual + # run. tests.yml discovers every binary named by add_test (so it builds all six pm_* + # targets plus node_admin_util and capped_file_appender), configures with the same + # flags, and runs the whole ctest — a superset of `ctest -R '^pm_'` here. Measured: + # the six binaries below are all in tests.yml's list. It skips only docs-only PRs, + # where there is nothing to test. Missing coverage is therefore NOT pm-tests but + # check-boost-range (the only job that builds `vizd` itself on Boost 1.83) and + # testnet-config (the only job that runs consensus_sim against testnet constants). + # A skipped gate is not a passing gate: a PR with 0 labels reports these as skipped. pm_tests: name: PM unit tests (ctest) runs-on: ubuntu-latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a00a822a4..166ef47158 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,8 +51,10 @@ jobs: -DCHAINBASE_CHECK_LOCKING=FALSE \ -DBUILD_CONSENSUS_TESTS=ON .. # Build exactly the binaries ctest will run, so a new add_test never shows up as "Not Run". - TEST_BINS=$(grep -ho 'add_test(NAME [a-z_]* COMMAND [a-z_]*' \ - ../tests/pm/CMakeLists.txt ../tests/consensus_sim/CMakeLists.txt | awk '{print $NF}') + # Discover the suites instead of naming them: a hardcoded list silently skipped + # tests/node_admin and ctest reported its only test as "Not Run". + TEST_CMAKE=$(find ../tests -mindepth 2 -maxdepth 2 -name CMakeLists.txt) + TEST_BINS=$(grep -ho 'add_test(NAME [a-z_]* COMMAND [a-z_]*' $TEST_CMAKE | awk '{print $NF}') make -j"$(nproc)" $TEST_BINS - name: Run tests diff --git a/@l10n/ru/docs/api/ids-and-hashes.md b/@l10n/ru/docs/api/ids-and-hashes.md new file mode 100644 index 0000000000..d98d37c8bd --- /dev/null +++ b/@l10n/ru/docs/api/ids-and-hashes.md @@ -0,0 +1,29 @@ +# Идентификаторы транзакций и блоков + +Типы идентификаторов VIZ унаследовал от Graphene (BitShares, затем Steem). В исходниках они +объявлены как `fc::ripemd160`, и старая документация и библиотеки называют их «ripemd160 id». Это +только имя 20-байтового контейнера, в котором они хранятся: **ни один из этих id не является +ripemd160-хэшем.** Нода объявляет их через алиас `id160_type`, чтобы сказать это прямо. + +| Значение | Как вычисляется | Размер | +|---|---|---| +| `transaction_id` | первые 20 байт `sha256(сериализованная транзакция)`, подписи **не** входят | 20 байт | +| `block_id` | первые 20 байт `sha224(сериализованный заголовок блока)`, затем байты 0–3 заменяются номером блока (big-endian) | 20 байт | +| `transaction_merkle_root` | `ripemd160` от корня sha256-дерева по подписанным транзакциям | 20 байт | + +Merkle root — единственное значение, которое действительно ripemd160. + +## Что это значит для клиентов + +* Чтобы посчитать id транзакции, сериализуйте её **без** `signatures`, возьмите sha256 и оставьте + первые 20 байт. ripemd160 не применять — результат никогда не совпадёт с нодой. +* Номер блока читается прямо из block id: это первые 4 байта, big-endian. TaPoS + (`ref_block_num` / `ref_block_prefix`) берёт младшие 16 бит этого номера и байты 4–7 block id. +* Id имеют длину 20 байт во всех ответах API, в block log и в истории аккаунтов. Смена хэша (на + настоящий ripemd160 или на полный 32-байтовый sha256) была бы хардфорком, который делит историю на + два формата id, без выигрыша в безопасности: 160 бит для идентификатора достаточно, а подпись + ставится на полный sha256-дайджест, а не на id. + +Исходники: `transaction::id()` в `libraries/protocol/transaction.cpp`, `signed_block_header::id()` и +`signed_block::calculate_merkle_root()` в `libraries/protocol/block.cpp`, типы — в +`libraries/protocol/include/graphene/protocol/types.hpp`. diff --git a/@l10n/ru/docs/consensus/block-processing.md b/@l10n/ru/docs/consensus/block-processing.md index 570af1c07f..1d9993b4a7 100644 --- a/@l10n/ru/docs/consensus/block-processing.md +++ b/@l10n/ru/docs/consensus/block-processing.md @@ -298,6 +298,7 @@ struct block_post_validation_message { ### Ограничения - В зачёт порога 2/3 идут только валидаторы из **текущего перетасованного расписания**; валидаторы вне расписания пропускаются. +- **Один голос на раунд (правило безопасности Jolteon).** Валидатор голосует за блок раунда *r* (раунд = слот блока, `timestamp / CHAIN_BLOCK_INTERVAL`), только если *r* выше самого старшего раунда, в котором он уже голосовал. Плагин валидатора хранит этот раунд для каждого валидатора в `/block-vote-round.txt`, записывает его **до** отправки голоса (голос, раунд которого не удалось сохранить, не отправляется) и пишет в лог `Not voting for … already voted for … in round …`, если в том же раунде появился другой блок. В отличие от правила «одна подпись на высоту», в более позднем раунде валидатор может проголосовать за другой блок той же высоты — именно это позволяет расколу голосов разрешиться, а не застрять. Два конфликтующих сертификата кворума в одном раунде требуют валидаторов, голосующих дважды; ноды пишут такие голоса в лог как `Equivocation: …`. Повторная рассылка того же голоса разрешена. - Во время **аварийного консенсуса** (`emergency_consensus_active = true`) `check_block_post_validation_chain()` сразу возвращается — LIB продвигается исключительно по классическому пути, чтобы не заблокировать восстановление. - Список подтверждений ограничен **20 записями** (`CHAIN_MAX_BLOCK_POST_VALIDATION_COUNT`). Записи ниже текущего LIB удаляются после каждого блока. diff --git a/@l10n/ru/docs/node/configuration.md b/@l10n/ru/docs/node/configuration.md index 73fc1e5561..14691b9d6e 100644 --- a/@l10n/ru/docs/node/configuration.md +++ b/@l10n/ru/docs/node/configuration.md @@ -197,6 +197,28 @@ logger.p2p.level = warn logger.p2p.appenders = p2p ``` +Аппендер `file` НАСТРОЕН ротироваться по времени (новый файл каждый час, хранится сутки), +но ротация срабатывает только пока обслуживается очередь файберов fc, а `vizd` после старта +её не обслуживает (замер 20.10.2026: нода тестнета написала один файл на 37 МБ за девять +часов на уровне `info`, часовых файлов нет; нода мейннета проработала четыре дня без нового +файла). Не рассчитывайте, что аппендер ограничит размер, и помните: на `debug` нода может +писать гигабайты в час. Если нужен только свежий хвост, используйте опциональный +capped-аппендер: один файл, в котором остаются последние `max_lines` строк (по умолчанию +10000, `0` — без ограничения). Он считает строки при записи и сжимается на месте, поэтому +файл держится между `max_lines` и ~1.1 x `max_lines`, без всяких таймеров. Ноды, в конфиге +которых нет секции `log.capped_file_appender.*`, ведут себя ровно как раньше; саму секцию +безопасно завозить ДО обновления бинаря — старая сборка её игнорирует и продолжает писать +в stderr. Если указанный путь не открывается, нода всё равно стартует: печатает в stderr +одну строку `capped_file_appender: cannot open log file …` и просто не создаёт файл. + +```ini +log.capped_file_appender.node.filename = logs/node/node.log +log.capped_file_appender.node.max_lines = 10000 + +# Куда именно писать — решает логгер; сам аппендер ничего не пишет. +logger.default.appenders = stderr, node +``` + --- ## Полный справочник diff --git a/@l10n/ru/docs/node/getting-started.md b/@l10n/ru/docs/node/getting-started.md index a35c15c081..d47ebc82f5 100644 --- a/@l10n/ru/docs/node/getting-started.md +++ b/@l10n/ru/docs/node/getting-started.md @@ -37,6 +37,7 @@ docker pull vizblockchain/vizd:latest ```bash docker run -d \ --name vizd \ + --restart unless-stopped \ -p 2001:2001 \ -p 8090:8090 \ -p 8091:8091 \ diff --git a/@l10n/ru/docs/plugins/node-admin.md b/@l10n/ru/docs/plugins/node-admin.md new file mode 100644 index 0000000000..68779071d1 --- /dev/null +++ b/@l10n/ru/docs/plugins/node-admin.md @@ -0,0 +1,1179 @@ +# Административный API ноды (`node_admin`) + +Административный JSON-RPC-эндпоинт самого процесса ноды: здоровье, конфигурация, +состояние плагинов, логи, аудит, управление P2P-пирами и снапшотами. Предназначен для +инструментов оператора и AI-агентов, у которых есть bearer-токен; не для конечных +пользователей и не вместо публичного API. На том же слушателе работают эндпоинт **MCP** +(Model Context Protocol) поверх HTTP и небольшой встроенный веб-интерфейс по адресу `/ui/`. + +> **Изменяющая поверхность: P2P-пиры, снапшоты, ключи валидаторов, политика webserver, +> `config.ini` и процесс.** +> `admin.p2p.add_node`, `admin.p2p.add_trusted` и `admin.p2p.clear_banned` меняют, с кем нода +> устанавливает соединения; `admin.snapshot.create` и `admin.snapshot.restore` пишут файл +> снапшота или заменяют состояние ноды в памяти; `admin.validator.*` включает и выключает +> валидаторов и меняет их signing-ключи, подписывая транзакции в цепи; изменяющие методы +> `admin.webserver.*` меняют лимиты, переключатель статистики и списки +> разрешённых/запрещённых IP; `admin.config.*` и `admin.plugins.*` правят `config.ini` через +> двухфазные `plan`/`apply`; `admin.restart` просит процесс завершиться. Им нужен токен со +> скоупом `p2p`, `snapshot`, `chain`, `webserver`, `config`, `plugins` или `process` +> соответственно. Действия над пирами сериализуются на P2P-потоке; действия над +> валидаторами подписывает плагин `validator_guard` и рассылает их в сеть; изменения +> политики webserver применяются к следующему же запросу; правки конфигурации и плагинов +> читаются при старте, поэтому нужен перезапуск. + +--- + +## Почему отдельный слушатель + +Публичный API обычно опубликован через nginx. Внутри ноды такой запрос приходит +**с 127.0.0.1**, поэтому проверка адреса источника на публичном эндпоинте ничего не +защищает. Поэтому `node_admin` поднимает собственный HTTP-слушатель, по умолчанию +привязанный к loopback, и он никогда не проксируется. + +Адрес не на loopback отклоняется при старте, если явно не задан +`admin-allow-remote = true`. Bearer-токен на открытом порту — это другая модель угроз, +а не удобство. + +--- + +## Конфигурация + +```ini +plugin = node_admin + +admin-endpoint = 127.0.0.1:8099 +admin-token = agent:read:3f8c…(64 hex)…e1 +admin-audit-log = /var/lib/vizd/node-admin-audit.log +admin-log-file = node=/var/log/vizd/vizd.log +``` + +| Опция | По умолчанию | Описание | +|---|---|---| +| `admin-endpoint` | — | Адрес прослушивания. Пусто/нет — плагин неактивен. | +| `admin-token` | — | `label:scope[,scope]:sha256hex[:expires-unixtime]`. Можно повторять. | +| `admin-allow-remote` | `false` | Разрешить `admin-endpoint` не на loopback. | +| `admin-audit-log` | рядом с `config.ini` | Append-only журнал аудита (0600, ротация на 16 МБ). | +| `admin-log-file` | — | Записи `name=path`, которые разрешено читать `admin.logs.tail`. Можно повторять. | +| `admin-plan-ttl` | `120` | Сколько секунд `plan_id` остаётся действительным до отказа `apply`. | + +Плагин отказывается стартовать, если `admin-endpoint` задан, а токенов нет: админский +порт без аутентификации не бывает допустимым состоянием. + +Вкладка Logs читает **файл**, а не stdout контейнера. Ноде, которая внутри Docker пишет +только в stderr, нужен аппендер, кладущий те же строки на диск; именно этот файл и +называется в `admin-log-file`: + +```ini +[log.file_appender.node] +filename = /var/lib/vizd/logs/node.log + +[logger.default] +level = info +appenders = stderr,node + +admin-log-file = node=/var/lib/vizd/logs/node.log +``` + +Пиши секциями: плоские ключи `logger.default.*` сливаются первыми, а секция +`[logger.default]` ниже их перетирает — плоская строка поверх существующей секции молча +игнорируется. Файловый аппендер НАСТРОЕН на часовую ротацию с хранением суток, но в +`vizd` она фактически не происходит: цепочка живёт в очереди файберов fc, а главный поток +ноды после старта сидит в `boost/asio` и её не обслуживает (замер 20.10.2026 — один файл +на 37 МБ за девять часов, часовых файлов нет). То есть файл не ограничен ни по времени, ни +по размеру; на `debug` он доходит до гигабайт. Для жёсткого предела есть опциональный +аппендер `capped_file`: один файл, только последние `max_lines` строк (по умолчанию +10000, `0` — без ограничения). + +```ini +[log.capped_file_appender.node] +filename = /var/lib/vizd/logs/node.log +max_lines = 10000 +``` + +Укажи тот же путь в `admin-log-file` — таб Logs читает его без изменений. Аппендер +опционален: ноды без секции и старые бинари, не знающие типа, работают как раньше. + +### Токены + +Токен — 32 случайных байта, например `openssl rand -hex 32`. В конфигурации хранится +только его SHA-256; сравнение — constant-time. Скоупы: + +* `read` — все методы, включая `admin.p2p.status` и скачивание снапшотов. Нужен любому токену. +* `p2p` — изменяющие методы над пирами `admin.p2p.add_node`, `admin.p2p.add_trusted`, + `admin.p2p.clear_banned`. Токен только с `p2p` (без `read`) не может вызвать ничего. +* `snapshot` — `admin.snapshot.create` и `admin.snapshot.restore`. Список и скачивание + снапшотов требуют только `read`. +* `webserver` — изменяющие методы `admin.webserver.*` (лимиты, переключатель статистики, + списки IP, reset, reload). `admin.webserver.status` и `admin.webserver.ips` требуют + только `read`. +* `config` — `admin.config.plan` и `admin.config.apply` (правка разрешённых ключей + `config.ini`). `admin.config.backups` требует только `read`. +* `plugins` — `admin.plugins.plan` и `admin.plugins.apply` (правка списка `plugin =`). +* `process` — `admin.restart` и `admin.objects.repair`. Оба гасят процесс; поднять его + обратно обязан супервизор. Repair вдобавок оставляет маркер, который переписывает + состояние на следующем старте, так что это скоуп записи в состояние, а не только + доступности. +* `chain` — `admin.validator.plan` и `admin.validator.apply` (включение/выключение + валидаторов и смена их signing-ключей). Эти вызовы принимаются **только пока + `validator_guard` загружен, запущен и сконфигурирован с active-ключом** — именно этот + плагин хранит ключ, которым подписывается транзакция цепи, и через API он ключ не + отдаёт. Без живого guard токен с `chain` не может вызвать ничего: `admin.validator.list` + остаётся доступен со скоупом `read` и объясняет, почему изменяющие вызовы недоступны. + +Отзыв — правка строки и перезапуск. Необязательный `expires-unixtime` делает токен +недействительным после этого момента без перезапуска. + +--- + +## Протокол + +`POST` с `Content-Type: application/json` и `Authorization: Bearer `. +Тело ≤ 64 КБ. Ответы — JSON-RPC 2.0: + +```json +{"jsonrpc":"2.0","id":1,"result":{...}} +{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"forbidden", + "data":{"reason":"token 'agent' has no 'read' scope"}}} +``` + +| Код | Значение | +|---|---| +| `-32700` | Ошибка разбора | +| `-32600` | Некорректный запрос | +| `-32601` | Метод не найден | +| `-32602` | Некорректные параметры | +| `-32001` | Нет авторизации (HTTP 401) | +| `-32002` | Не хватает скоупа (HTTP 200) | +| `-32004` | Неизвестное/запрещённое имя (например, лог-файл) | +| `-32005` | Лимит: 20 запросов/с на токен (HTTP 429) | +| `-32099` | Внутренняя ошибка | + +Аутентификация проверяется **до** разбора тела. + +Маршруты не-`POST` — встроенный интерфейс и скачивание снапшотов: + +* `GET /ui/` (и `GET /ui`) отдаёт встроенную страницу, описанную ниже. В ней нет ни данных, + ни секретов; все настоящие вызовы страница делает обычными авторизованными `POST` на `/`. +* `GET /snapshot/latest` отдаёт самый свежий снапшот как файл-вложение. `GET /snapshot/` + отдаёт именованный файл из каталога снапшотов. Оба требуют `Authorization: Bearer …` и + скоуп `read` — в отличие от `/ui/`, снапшот это состояние ноды. `HEAD` работает на обоих и + несёт те же заголовки, включая `Content-Length`. Любой другой метод или путь отвечает `405`. + +Ограничения скачивания: файл обязан быть обычным файлом непосредственно в каталоге +снапшотов (имена берутся из `admin.snapshot.status`; traversal отклоняется), а файлы больше +**512 МБ** получают `413` — их копируйте через `scp`. + +--- + +## Методы + +### `admin.ping` + +```json +{"ok": true, "server_time": "2026-10-19T14:40:00Z"} +``` + +Проверка живости; базу цепи не трогает. + +### `admin.auth.me` + +Без параметров. Возвращает личность, которую несёт предъявленный токен: `label`, `scopes` +и `expires` (unixtime, `null` — токен бессрочный). Сам токен и его хэш **не** возвращаются +никогда. Встроенный UI по этому ответу скрывает действия, которые токену недоступны; агент +может так же узнать свои права перед планированием вызова. + +```json +{"label":"ui-read","scopes":["read"],"expires":null} +``` + +### `admin.node_info` + +| Поле | Смысл | +|---|---| +| `version`, `hardfork_count` | Версия сборки и число известных бинарнику хардфорков | +| `chain_id` | Идентификатор цепи | +| `head_block_num`, `head_block_id`, `head_block_time` | Текущая голова | +| `head_age_sec` | Возраст головы по стенным часам; `null` на пустой цепи (там время головы 1970 — «возраст» ничего не значит) | +| `last_irreversible_block_num` | Последний необратимый блок | +| `emergency_consensus_active`, `emergency_consensus_start_block` | Состояние аварийного консенсуса цепи и блок, с которого он начался (`0`, когда не активен) | +| `emergency_key_configured` | В плагине validator загружен аварийный ключ | +| `recovering` | Chain-плагин восстанавливает shared memory | +| `uptime_sec` | Время с запуска админского слушателя | +| `server_time` | Текущее UTC | + +### `admin.diagnostics` + +Всё из `admin.node_info`, плюс: + +| Поле | Смысл | +|---|---| +| `data_dir`, `config_path` | Используемые пути | +| `p2p_loaded`, `peers` | P2P-плагин присутствует, число активных соединений (читается на p2p-потоке) | +| `plugins_configured`, `plugins_observed` | Записи `plugin =` в `config.ini` и сколько из них читаются напрямую (см. `admin.plugins.list`) | +| `shared_memory_bytes` | Размер `shared_memory.bin` | +| `disk_total_bytes`, `disk_free_bytes` | Файловая система `data_dir` (POSIX) | +| `rss_peak_kb` | Пиковый RSS (POSIX) | +| `loadavg` | Средняя нагрузка за 1/5/15 минут (POSIX) | + +Числа отдаются числами JSON, а не строками: дефолтный форматер fc берёт int64 выше 2^32-1 +и все double в кавычки, из-за чего размеры в байтах и load average пришлось бы парсить дважды. + +### `admin.plugins.list` + +Список плагинов, названных строками `plugin = …` в `config.ini`, без дублей. В строке может +стоять несколько имён (`plugin = chain p2p`) — AppBase читает её именно так, поэтому каждое имя +получает свою запись. Реестр плагинов AppBase закрыт +(поиск по имени — protected, перечисления нет), поэтому видимое население — сам конфиг; +плагины, включённые только командной строкой, здесь не видны. + +У каждой записи есть `loaded`, `state` и `state_source`: + +* `state_source: "observed"` — состояние прочитано у живого плагина (`chain`, `p2p`, + `snapshot`, `webserver`, `validator`, `validator_guard`, `node_admin`). `state` — + `registered`, `initialized`, `started` или `stopped`; `loaded` равен false только пока + плагин в состоянии `registered`. +* `state_source: "inferred"` — AppBase инициализирует каждую строку `plugin =` и падает на + старте при неизвестном имени, значит на работающей ноде сконфигурированный плагин загружен. + `state` при этом `null`, а не догадка. + +Для панели оператора есть ещё два поля: + +* `removable` — `false` у `chain` и `node_admin`: `admin.plugins.plan` всё равно откажет в + их удалении. Клиент обязан рисовать их как «непереключаемые», а не как «выключенные». +* `params` — allow-list-ключи этого плагина (`p2p`, `webserver`, `validator_guard`); пустой + массив значит «редактируемых параметров нет», и панель должна это сказать, а не показать + пустую форму. Ключи логов сгруппированы под `logging` — это не плагин, они в + `admin.config.get`. +* `status_method` — метод, отдающий **собственный** статус этого плагина + (`admin.p2p.status`, `admin.webserver.status`, `admin.snapshot.status`, + `admin.validator.list` для `validator_guard`), либо `null`, если своего статуса у плагина + нет. `null` — не ошибка: панель должна сказать «статуса нет», а не рисовать пустой ящик. + +В ответе также есть `known` — все имена плагинов, которые умеет загружать бинарь; ровно это +множество проверяет `admin.plugins.plan` для `add`. Сконфигурированные имена там тоже есть, +клиент вычитает их, собирая выбор «добавить плагин». + +### `admin.plugins.status` + +`{"name":"p2p"}` возвращает состояние жизненного цикла и то, чем плагин занят на самом деле: + +| Поле | Смысл | +|---|---| +| `configured` | В `config.ini` есть строка `plugin =` с этим именем | +| `state`, `state_source` | Как в `admin.plugins.list`; `state` = `null`, если тип плагина не слинкован в этот бинарь | +| `detail_available`, `detail_method` | Есть ли у плагина свой метод статуса и какой именно был вызван | +| `detail` | Ответ этого метода как есть (`admin.p2p.status`, `admin.webserver.status`, `admin.snapshot.status`, `admin.validator.list` для `validator_guard`) | +| `reason` | Почему детали нет: тип не наблюдается, плагин не запущен или своего статуса нет | + +Метод детали зовётся только у **запущенного** плагина: его обработчики читают состояние, +принадлежащее чужому потоку (карта пиров, счётчики соединений), и на остановленном плагине +бросят исключение. + +Плагин, тип которого не слинкован в этот бинарь (`snapshot` в сборке без него, скажем), +отвечает `state: null` и `reason` — не пустым объектом и не выдуманным «started». + +### `admin.objects.list` + +Граф объектов chainbase, как его видит живой процесс: по строке на индекс (тип объекта), +число объектов и ревизия индекса, плюс сколько отображённого файла занято. + +```json +{"state_dir":"/var/lib/vizd/state","shared_memory_path":"/var/lib/vizd/state/shared_memory.bin", + "shared_memory_bytes":34183577600,"mapped_bytes":34183577600,"free_bytes":12648009654, + "revision":1234567,"head_block_num":1234567,"recovering":false, + "schema":{"file_present":true,"stored":15,"compiled":15,"match":true}, + "block_log":{"path":"/var/lib/vizd/state/block_log","present":true,"bytes":2147483648}, + "markers":[],"index_count":36,"total_objects":9876543, + "indexes":[{"type":"graphene::chain::account_object","type_id":7,"objects":812345, + "revision":1234567}], + "generated_at":"2026-10-20T13:40:00Z"} +``` + +Счётчики читаются под тем же weak read lock, что и chain API; если поток блоков не уступил +вовремя, вызов вернёт файловые факты и `lock_error`, а не зависнет. + +Блок-лог показывается и файлом, и каталогом: chain-плагин стирает его через `remove_all`, +поэтому каталог там — законная форма. + +`schema_version` и `block_log` лежат в state-каталоге цепи (`/state` по умолчанию, +рядом с `shared_memory.bin`), а не в каталоге данных приложения: chain-плагин открывает БД, +передавая state-каталог как свой data dir. Лог, в котором только однобайтовая заглушка +chainbase, считается пустым — `replay_available` false, а `replay`-ремонт отвергается, потому +что проиндексировал бы ноль блоков. + +### `admin.objects.check` + +Тот же снимок, но с судом. `verdict` — `ok`, `warn`, `corrupted` или `unreadable`; в ответе +именованные `findings` (`severity`, `code`, `text`), счётчики, `replay_available` и +`recommended_repair` (`replay`, `resync` или `null`). + +| Код | Severity | Что значит | +|---|---|---| +| `read_lock_timeout` | critical | Shared memory не ответила на read lock; процесс завис | +| `state_dir_missing` / `shared_memory_missing` | critical | У ноды нет каталога состояния или образа | +| `shared_memory_truncated` | critical | Файл меньше отображения: обрезан или подменён под работающим процессом | +| `shared_memory_larger_than_mapping` | warn | Файл вырос под этим процессом — обычно вторая нода на том же каталоге | +| `stale_marker` | critical | `undo_all_in_progress`, `resize_in_progress`, `force_resync` или `force_replay` пережили прерванную операцию | +| `schema_mismatch` | critical | `state/schema_version` нет или он не равен `CHAIN_SCHEMA_VERSION` бинаря; следующий старт всё сотрёт и пересоберёт | +| `revision_behind_head` | warn | Ревизия индексов не равна head; следующий старт доиграет разрыв. После краша это норма | +| `shared_memory_exhausted` | critical | Свободно меньше 8 МиБ: нода не запишет следующий блок | +| `shared_memory_low` | warn | Свободно меньше 5% | +| `global_properties_count` / `validator_schedule_count` | critical | Ожидается ровно одна строка в каждом | +| `account_index_empty` | critical | Head выше нуля, а индекс аккаунтов пуст | +| `block_log_missing` | warn | Реплей состояние не пересоберёт, поможет только полный resync | +| `recovery_in_progress` | warn | Chain-плагин выполняет автовосстановление | + +`corrupted` — только жёсткие улики (critical выше); расхождение ревизии после краша — это +`warn`, а не порча. `recommended_repair` заполняется, только когда переписывание состояния +может починить находку; исчерпанная shared memory исключена — тот же размер забьётся снова. + +**Чего проверка не видит.** Порчу страниц, контрольные суммы и environment-штамп chainbase +можно проверить только с закрытым файлом, так что это структурная проверка живого +процесса, а не `fsck`. Чистый вердикт не обещает, что реплей пройдёт. + +### `admin.objects.repair` + +`{"mode":"replay"|"resync","confirm":"repair"}` (скоуп `process`) пишет маркер рядом с +`shared_memory.bin`, отвечает и через 300 мс завершает процесс — тот же жизненный цикл, что +у `admin.restart`. Маркер читает chain-плагин на следующем старте, до отображения shared +memory: + +* `force_replay` — стереть состояние, оставить блок-лог и переиндексировать его с блока 1. + Дешевле сетевого resync и достаточно, когда сам блок-лог цел. +* `force_resync` — стереть состояние **и** блок-лог, подняться пустым и синхронизироваться + с сетью. + +`confirm` обязан быть ровно `repair`, а режим ещё раз сверяется с файлами: `replay` +отклоняется с `-32000 no block log`, если лога нет или он пуст. Если уже лежит маркер +`force_resync` от wedge-сторожа, старт выполнил бы resync и отбросил просьбу реплея вместо +того чтобы реплеить пустой лог, — поэтому обработчик **сначала снимает противоположный +маркер** и называет его в `removed_marker`. Без этого подтверждённый «replay (сохраняет +блоки)» молча стирал бы блок-лог: приоритет на старте — `resync > replay`. Если маркер +снять не удалось, это отказ `-32000`, а не обещание, которое нода не выполнит. + +**Ноды в снапшот-режиме.** Описанные эффекты маркеров верны для ноды, стартующей обычным +путём. Нода с `--replay-from-snapshot` до него не доходит: chain-плагин восстанавливает +состояние из снапшота и сверху проигрывает блок-лог, поэтому `replay` на такой ноде +превращается в пересборку из снапшота, а `resync` возвращается из снапшота, а не с нуля. +Ремонт при этом остаётся полной пересборкой состояния — то есть тем, что оператору и +нужно, — но ответ так и говорит, а не повторяет общую формулировку. `effect` считается по +опциям, с которыми нода реально стартовала, и `snapshot_replay` на такой ноде равен `true`. + +В ответе есть `marker`, `removed_marker`, `effect` и `snapshot_replay`; вернуть ноду обратно +обязан супервизор (`--restart unless-stopped` / `Restart=always`) — ровно как при обычном +рестарте. + +### `admin.config.get` + +Отдаёт `config.ini` упорядоченными секциями с сохранением дублей ключей (нужно для +повторяющихся строк `plugin`). **Значения ключей, несущих секреты, заменяются на `***`**: +всё, что содержит `private-key`, `private_key`, `password`, `secret`, `token`, `wif` или +`emergency`. + +```json +{"path":"/etc/vizd/config.ini","exists":true,"sections":[ + {"name":"","entries":[{"key":"plugin","value":"chain"}, + {"key":"private-key","value":"***"}]}]} +``` + +Ещё он отдаёт `allow` — редактируемое подмножество, готовое к отрисовке. Элемент: +`{key, group, section, type, value, defaulted, min, max, values, needs_restart}`. + +* `key` — полный адрес, который принимает `admin.config.plan` (`logger.default.level` для + ключа внутри `[logger.default]`), поэтому клиенту не нужно знать, какие ключи секционные. +* `value` равен `null`, а `defaulted` — `true`, когда в файле ключа нет: нода берёт значение + из кода, и панель должна это показать, а не пустое поле. + Значение, прочитанное из файла, отдаётся **нормализованным** (`yes`/`on`/`1` → `true`), + чтобы совпадать со списком вариантов; `valid:false` значит, что строку нормализовать не + удалось и её надо подсветить. +* `type` — `boolean`, `unsigned_int` (с `min`/`max`) или `log_level` (с `values`). +* `group` — плагин, которому принадлежит ключ (`p2p`, `webserver`, `validator_guard`), либо + `logging` для настроек логгеров и аппендеров. + +### `admin.logs.list` + +Без параметров. Перечисляет файлы, объявленные в `admin-log-file`: `name`, `path`, `exists` +и `size` (`null`, если файла ещё нет). Настроенный, но пока пустой аппендер тоже виден: +«пусто» и «не настроено» — разные факты. + +```json +{"logs":[{"name":"node","path":"/var/lib/vizd/logs/node.log","exists":true,"size":1048576}]} +``` + +### `admin.logs.tail` + +Параметры: `{"name":"node","bytes":16384,"lines":200}`. `name` обязан совпасть с +записью `admin-log-file`; ничего вне этого allowlist прочитать нельзя. `bytes` +ограничивается 64 КБ, `lines` — 2000. Строки, похожие на WIF, и секреты вида +`ключ = значение` маскируются. + +### `admin.audit.tail` + +Параметры: `{"lines":100}`. Хвост журнала аудита. + +### `admin.p2p.status` + +Без параметров. Нужен только `read` (кто может смотреть — может смотреть и на пиров). +Отдаёт те же факты, что нода пишет в свой лог — `log_node_status()` раз в минуту и +`log_peer_stats()` раз в пять минут — но по запросу и в JSON: + +| Поле | Смысл | +|---|---| +| `as_of` | UTC-метка замера | +| `uptime_sec` | Аптайм ноды | +| `node_status`, `fork_status` | `SYNC`/`FORWARD`, `NORMAL`/`LOOKING_RESOLUTION`/`MINORITY` | +| `paused`, `isolated_peers`, `catching_up` | Обработка блоков на паузе, режим только-сиды, догон после паузы | +| `head_block_num`, `lib_block_num` | Наша голова и последний необратимый блок | +| `dlt_earliest`, `dlt_latest` | Диапазон блоков DLT, который держим локально (что эта нода может отдать) | +| `connections`, `active`, `max_connections` | Открытые соединения, из них полностью активных, и лимит | +| `known_peers`, `trusted_peers`, `banned_peers` | Множество известных пиров, доверенные оператором, сейчас забаненные | +| `best_peer_head` | Наибольшая голова среди отслеживаемых пиров | +| `best_peer_head_lag` | `наша голова − лучшая голова пира`; **отрицательное значит, что мы отстаём** | +| `peers_ahead` | Сколько пиров впереди нас | +| `peers[]` | Детали по каждому пиру, отсортированы по адресу | + +Поля пира: `endpoint`, `node_id` (hex, пусто до hello), `lifecycle` +(`CONNECTING`/`HANDSHAKING`/`SYNCING`/`ACTIVE`/`DISCONNECTED`/`BANNED`), `node_status`, +`fork_status`, `incoming`, `trusted`, `exchange_enabled`, `fork_alignment`, `connected_sec`, +`peer_head`, `peer_lib`, `peer_dlt_earliest`, `peer_dlt_latest`, `head_lag` (наш минус его), +`spam_strikes`, `ban_reason`, `ban_duration_sec`, `reconnect_backoff_sec`, +`next_reconnect_in_sec`. + +```json +{"jsonrpc":"2.0","id":40,"method":"admin.p2p.status"} +``` + +```json +{"jsonrpc":"2.0","id":40,"result":{ + "as_of":"2026-10-19T15:10:00Z","uptime_sec":86400, + "node_status":"FORWARD","fork_status":"NORMAL","paused":false, + "isolated_peers":false,"catching_up":false, + "head_block_num":22164,"lib_block_num":22150, + "dlt_earliest":1,"dlt_latest":22164, + "connections":3,"active":3,"max_connections":50, + "known_peers":12,"trusted_peers":1,"banned_peers":0, + "best_peer_head":22164,"best_peer_head_lag":0,"peers_ahead":0, + "peers":[{"endpoint":"1.2.3.4:1776","node_id":"02ab…","lifecycle":"ACTIVE", + "node_status":"FORWARD","fork_status":"NORMAL","incoming":false, + "trusted":true,"exchange_enabled":true,"fork_alignment":true, + "connected_sec":3600,"peer_head":22164,"peer_lib":22150, + "head_lag":0,"ban_reason":"","next_reconnect_in_sec":0}]}} +``` + +### `admin.p2p.add_node` + +Параметры: `{"endpoint":"1.2.3.4:1776"}` — IPv4 и порт. Нужны `read` **и** `p2p`. +Добавляет адрес в множество известных пиров и сразу звонит по нему (снимая старый бэкофф +или бан на этом адресе). Пир при этом не доверенный. + +```json +{"endpoint":"1.2.3.4:1776","trusted":false,"status":"added"} +``` + +### `admin.p2p.add_trusted` + +Те же параметры и форма ответа, `"trusted": true`. Доверенный пир: + +* **никогда не получает soft-ban** — `soft_ban_peer()` отключает его и повторяет попытку + через 30 с вместо часового бана; +* **не выбрасывается сборщиком известных пиров**, который иначе удаляет молчащих; +* звонится сразу и переподключается по мере надобности. + +Множество доверенных живёт в памяти: оно **не сохраняется между перезапусками**. Чтобы +закрепить пир навсегда, держите его в `p2p-seed-node` (или в списке сидов, который +используется вместе с `p2p-isolated-peers`) в `config.ini`. + +### `admin.p2p.clear_banned` + +Без параметров. Нужны `read` и `p2p`. Снимает все soft-баны (lifecycle `BANNED` или +непустой `ban_reason`), сбрасывает бэкофф переподключения и сразу повторяет попытки с +этими пирами. + +```json +{"cleared":3,"status":"ok"} +``` + +Типичный операторский сценарий: + +```bash +# сначала статус: что не так и с кем +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"admin.p2p.status"}' | jq '.result | {head_block_num, best_peer_head_lag, peers}' + +# добавить пира и сделать его доверенным +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"admin.p2p.add_trusted","params":{"endpoint":"1.2.3.4:1776"}}' + +# после форк-шторма или волны банов: повторить всех +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":3,"method":"admin.p2p.clear_banned"}' +``` + +### `admin.snapshot.status` + +Параметры: `{"offset":0,"limit":20,"after":"snapshot-block-84360000.vizjson"}` — все +необязательны. Нужен `read` и загруженный плагин `snapshot`. Возвращает одну страницу файлов +снапшотов из настроенного `snapshot-dir` (сначала наибольший номер блока) и сообщает, что +делает плагин. `limit` ограничивается 200. + +Листая каталог, который меняется под читателем, передавай `after` (имя последней уже +полученной строки), а не `offset`: снапшот, записанный между двумя страницами, сдвигает все +offset'ы ниже себя, и страница повторяет строку; чистка по сроку (24 ч) сдвигает в другую +сторону и заставляет offset пропускать строку. С `after` страница продолжается ровно после +этой строки. Если якорь уже вычищен, ответ несёт `after_found: false` и работает по `offset`. + +| Поле | Смысл | +|---|---| +| `as_of` | UTC-метка замера | +| `dir` | Используемый каталог снапшотов | +| `head_block_num` | Текущая голова; `null`, пока восстановление перестраивает состояние | +| `snapshot_count` | Число файлов во всём каталоге | +| `snapshot_total` | Тот же итог, названный явно для клиентов с пагинацией | +| `offset`, `limit` | Выданная страница | +| `after`, `after_found` | Присланный курсор и остался ли он в каталоге | +| `has_more` | После этой страницы есть ещё файлы | +| `in_progress`, `restoring`, `recovering` | Пишется снапшот, идёт восстановление, chain-плагин восстанавливается | +| `latest_path`, `latest_name` | Самый свежий снапшот (наибольший номер блока); когда файлов нет — `latest_path` пустая строка, `latest_name` равен `null` | +| `snapshots[]` | `name`, `path`, `block_num` (0, если имя не несёт блок), `size_bytes`, `modified` (unixtime), `modified_iso`, `latest` | + +```json +{"jsonrpc":"2.0","id":50,"method":"admin.snapshot.status","params":{"offset":0,"limit":20}} +``` + +```json +{"jsonrpc":"2.0","id":50,"result":{ + "as_of":"2026-10-19T15:30:00Z","dir":"/var/lib/vizd/snapshots", + "head_block_num":22164,"snapshot_count":1,"snapshot_total":1, + "offset":0,"limit":20,"has_more":false,"in_progress":false, + "restoring":false,"recovering":false, + "latest_path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "latest_name":"snapshot-block-22164.vizjson", + "snapshots":[{"name":"snapshot-block-22164.vizjson", + "path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "block_num":22164,"size_bytes":4404019, + "modified":1792423800,"modified_iso":"2026-10-19T15:30:00Z","latest":true}]}} +``` + +### `admin.snapshot.create` + +Без параметров. Нужны `read` и `snapshot`. Пишет `snapshot-block-.vizjson` в +`snapshot-dir` **асинхронно** на потоке снапшотов плагина; обработка блоков P2P приостановлена +на время чтения базы и возобновляется до сжатия. Опрашивайте `admin.snapshot.status`, пока +`in_progress` не станет false. + +```json +{"jsonrpc":"2.0","id":51,"method":"admin.snapshot.create"} +``` + +```json +{"jsonrpc":"2.0","id":51,"result":{ + "status":"started","path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "note":"snapshot creation runs on the node's snapshot thread; poll admin.snapshot.status until in_progress is false"}} +``` + +`status` — `started`, `exists` (файл для этой головы уже на диске) или `busy` (идёт другое +создание/восстановление). На пустой цепи (голова `0`) вызов падает с `-32000` и ничего не пишет. + +### `admin.snapshot.restore` + +Параметры: `{}` (восстановить последний), `{"latest":true}`, +`{"name":"snapshot-block-22164.vizjson"}` или +`{"path":"/var/lib/vizd/snapshots/<файл>"}`. Нужны `read` и `snapshot`. + +Восстановление идёт **в процессе**, это не перезапуск процесса: обработка блоков P2P +приостанавливается, shared memory закрывается и очищается, снапшот импортируется, +`dlt_block_log` проигрывается заново, пиры сбрасываются и P2P возобновляется. ОС-процесс и +его аптайм не меняются, поэтому слушатель продолжает отвечать во время операции +(`restoring: true`, голова `null`). Если задан `path`, он обязан указывать на обычный файл +непосредственно в каталоге снапшотов. + +```json +{"jsonrpc":"2.0","id":52,"method":"admin.snapshot.restore","params":{"latest":true}} +``` + +```json +{"jsonrpc":"2.0","id":52,"result":{ + "status":"started","snapshot":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "snapshot_name":"snapshot-block-22164.vizjson","block_num":22164, + "process_restarted":false, + "note":"in-process restore: P2P is paused, shared memory is wiped, the snapshot is imported, dlt_block_log is replayed and P2P resumes. The OS process and its uptime are unchanged. Progress: admin.snapshot.status (restoring)."}} +``` + +Вызов, который не может начаться, отвечает `-32000` (нет снапшотов, идёт создание, файл +нечитаем); некорректные имена, traversal и файлы вне каталога снапшотов отвечают `-32602` +до любого изменения состояния. Если сам импорт падает, `do_snapshot_load` уже очистил базу +и запросил завершение — смотрите лог ноды; админский API не делает вид, что восстановление +удалось. + +--- + +## Методы ключей валидаторов (скоуп `chain`) + +Решение q#3051=D: оператор управляет валидаторами через `node_admin`, но **active-ключ +никогда не попадает в админский API**. Транзакции собираются и подписываются внутри +плагина `validator_guard`, которому заданы signing- и active-WIF каждого валидатора: + +```ini +plugin = validator_guard +validator-guard-validator = ["val1", "5J…signing…", "5J…active…"] +validator-guard-validator = ["val2", "5J…signing…", "5J…active…"] +``` + +Guard должен быть загружен, запущен и содержать хотя бы одного настроенного валидатора; +иначе изменяющие методы отвечают `-32002 forbidden / validator-guard is not configured`, а +`admin.validator.list` показывает `guard.reason`. Настроенный валидатор обязан **существовать +в цепи** до планирования; создание нового валидатора здесь не предлагается (оно списывает +сбор за объявление валидатора и меняет набор производителей блоков). + +### `admin.validators.active` + +Без параметров, только `read`. Возвращает имена из текущего расписания валидаторов +(дубликаты от коэффициента повтора схлопнуты), число слотов расписания, состояние аварийного +консенсуса и запущен ли плагин validator в этом процессе. **Ключи не возвращаются** — это +публичные данные цепи. Для валидаторов, которыми управляет эта нода, есть +`admin.validator.list`. + +```json +{"as_of":"2026-10-19T15:40:00Z","head_block_num":22164,"count":21, + "num_scheduled":21,"validators":["val1","val2"], + "emergency_consensus_active":false,"emergency_consensus_start_block":0, + "validator_plugin_loaded":true} +``` + +### `admin.validator.list` + +Скоуп `read`. Параметров нет. Возвращает: + +```json +{"guard":{"loaded":true,"started":true,"available":true}, + "validators":[{"name":"val1","on_chain":true,"enabled":true,"hold":false, + "active_authority":true,"scheduled_in":2, + "signing_key":"VIZ6…","configured_key":"VIZ6…"}]} +``` + +* `enabled` — on-chain signing-ключ не null. +* `hold` — оператор выключил валидатор через этот API; авто-восстановление guard'а для него + подавлено до успешного `enable`/`set_key` (см. ниже). +* `active_authority` — active-ключ guard'а входит в active-власть аккаунта. `false` значит, + что транзакция, подписанная guard'ом, будет отвергнута цепью; то же предупреждение + пишется в лог при старте guard'а. +* `scheduled_in` — позиция (с 1) в ближайших 3 слотах расписания, которые производит этот + валидатор; `null`, если его там нет. + +### `admin.validator.plan` + +Скоуп `chain`. Параметры: `{"actions":[{"name":"val1","action":"disable"},…]}`. `action` — +`enable` (вернуть signing-ключ, настроенный в guard), `disable` (обнулить signing-ключ и +поставить operator hold) или `set_key` (требует `signing_key` — публичный ключ в base58). +Каждое имя должно быть настроено в guard и существовать в цепи; дубликаты отвергаются. + +Ответ содержит `plan_id`, `kind:"validators"`, `transactions` (сколько транзакций цепи +отправит план) и `diff` по каждому действию: ключи `from`/`to`, `hold_from`/`hold_to`, +`tx_needed`. Уже выполненное действие в план не попадает; план без фактических изменений +отвергается с `-32602 no changes`. **Ничего пока не подписано и не отправлено.** Планы +одноразовые, привязаны к метке токена и истекают через `admin-plan-ttl`, как и планы +конфигурации. Захват текущего ключа в план и делает устаревший план безопасным: если цепь +изменилась между plan и apply, apply отвергает это действие, а не затирает чужое обновление. + +### `admin.validator.apply` + +Скоуп `chain`. Параметры: `{"plan_id":"…"}`. Подписывает по одной `validator_update` на +каждое изменившееся действие active-ключом guard'а, кладёт её в локальный pending pool и +рассылает пирам. Ответ перечисляет по каждому действию `status`: `applied` (с `tx_id`), +`already` (цепь сама пришла в нужное состояние) или `failed` (с `error`). Также даны +счётчики `applied`, `unchanged`, `failed`. + +**Operator hold следует за результатом, а не за намерением.** Успешный `disable` записывает +валидатора в `/validator-guard-holds.txt`; периодическое авто-восстановление +guard'а пропускает удерживаемых, а файл перечитывается при старте — поэтому перезапуск тоже +не включит валидатор молча (disable-on-shutdown hold не создаёт — это штатное действие +выключения). Успешный `enable` или `set_key` снимает hold. Неудачный disable не оставляет +защёлки: валидатор остаётся как был. + +Транзакции собираются под read-lock цепи и рассылаются после его освобождения — то же +правило, что у любой другой записи в цепь из этого плагина. Перезапуск не нужен. + +--- + +## Методы политики webserver + +Эти методы управляют плагином публичного API: счётчиками запросов по IP, логом статистики, +лимитами и списками IP. Семантика, значения по умолчанию и формат лога описаны в +[Webserver — «Учёт запросов и лимиты»](./webserver.md#учёт-запросов-и-лимиты). + +**Изменения живые.** Плагин держит это состояние в памяти; изменение применяется к +следующему запросу webserver — без перезапуска и без перечитывания `config.ini`. Каждое +изменение также атомарно сохраняется в `webserver-admin-state` +(по умолчанию `/webserver-admin.json`), который загружается при старте и потому +перекрывает стартовое значение из `config.ini`. Метод `reload` перечитывает этот файл для +правок руками. + +| Метод | Скоуп | Параметры | Назначение | +|-------|-------|-----------|------------| +| `admin.webserver.status` | `read` | нет | Лимиты, итоги, списки IP и число отслеживаемых адресов. | +| `admin.webserver.ips` | `read` | `{"limit":200}` (макс. 5000) | Строки по IP: `total`, `minute`, `hour`, `day`, `blocked`, `limited`, `last_seen`, `whitelisted`, `blacklisted`; сортировка по total. | +| `admin.webserver.limits` | `webserver` | `{"enabled":bool,"per_minute":N,"per_hour":N,"per_day":N}` | Опущенные поля сохраняют значение. | +| `admin.webserver.stats` | `webserver` | `{"enabled":bool}` | Включить/выключить учёт запросов. Лимиты управляются отдельно. | +| `admin.webserver.whitelist_add` / `whitelist_remove` | `webserver` | `{"ip":"1.2.3.4"}` | Адреса из белого списка обходят лимиты, но всё равно считаются. | +| `admin.webserver.blacklist_add` / `blacklist_remove` | `webserver` | `{"ip":"1.2.3.4"}` | Адреса из чёрного списка получают `403`; действует даже при выключенной статистике. | +| `admin.webserver.reset` | `webserver` | нет | Обнулить счётчики; списки и лимиты сохранить. | +| `admin.webserver.reload` | `webserver` | нет | Перечитать файл состояния с диска; вернуть применённый статус. | + +Добавление IP в один список удаляет его из другого. Адрес должен быть одним литералом +IPv4 или IPv6; иначе `-32602`. + +```json +{"jsonrpc":"2.0","id":53,"method":"admin.webserver.limits","params":{"per_minute":600}} +``` + +```json +{"jsonrpc":"2.0","id":53,"result":{ + "status":"applied", + "limits":{"enabled":true,"per_minute":600,"per_hour":10000,"per_day":100000}, + "stats_enabled":true, + "note":"applied to the live webserver on the next request and persisted to its admin state file; config.ini is unchanged."}} +``` + +Если плагин `webserver` не загружен, ответ — `-32004 webserver unavailable`. Публичный +webserver отвечает клиентам из чёрного списка `403`, а превысившим лимит — `429` с +`Retry-After: 60`; эти ответы производит webserver, а не этот API. + +--- + +## Правка конфигурации и плагинов (две фазы) + +`config.ini` правится через **plan**, который возвращает точный дифф и ничего не пишет, и +**apply**, который перепроверяет файл и заменяет его атомарно. Так намерение агента можно +проверить до того, как оно коснётся боевой ноды, а падение посреди apply не оставит +обрезанный файл. + +### Семантика plan + +* `admin.config.plan {"set":{"key":value,…}}` проверяет каждый ключ по **allow-list** ниже, + нормализует значение (`yes/on/1` → `true`; целые проверяются по диапазону) и возвращает + `plan_id`, `kind:"config"`, `diff` (`[{key, from, to}]`), `config_path`, `needs_restart:true` + и `expires_at`. **Ничего не пишется.** План без фактических изменений отклоняется с + `-32602`. +* `admin.plugins.plan {"add":[…],"remove":[…]}` проверяет имена по набору плагинов, которые + умеет грузить бинарник, отклоняет дубли, отсутствующие имена и удаление `chain` и + `node_admin`, и возвращает `diff` из `[{op:"add"|"remove", name}]`. +* План **одноразовый** (сгорает на первом `apply`, успешном или нет), **привязан к label + токена**, который его создал (`-32002` для любого другого), и **истекает** через + `admin-plan-ttl` секунд (`-32004 plan expired`). Хранится до 64 планов; при переполнении + вытесняется самый старый. `admin.plan.cancel` отбрасывает план явно. +* `apply` заново считает SHA-256 файла `config.ini` и отказывает с `-32000 config changed … + nothing was written`, если файл изменился после создания плана. Затем он заново + нормализует и проверяет каждое значение — apply никогда не доверяет плану слепо. + +### Атомарная запись и бэкапы + +Оба apply правят файл как **сырые строки**, поэтому комментарии, порядок, отступы и CRLF +сохраняются. Отсутствующий ключ верхнего уровня вставляется перед первым `[section]`; +существующий ключ верхнего уровня меняется на месте. Ключ, у которого в спецификации +указана секция (см. строку логов в таблице ниже), правится внутри этой секции и +вставляется сразу под её заголовок, если секция есть, а ключа в ней нет. + +Каждый успешный apply пишет один бэкап рядом с конфигом — `.bak-` +(при коллизии с суффиксом `-n`), — затем временный файл в том же каталоге, `fsync`, копию +прав исходного файла, `rename` поверх `config.ini` и `fsync` каталога. Неудачная запись +оставляет исходный файл нетронутым. Старые бэкапы нода не чистит; в них есть секреты +(токены, ключи), поэтому обращаться с ними нужно как с самим конфигом. + +`admin.config.backups` (скоуп `read`) перечисляет `.bak-*`, новые сверху, с +размером и mtime. Откат вручную — скопировать бэкап обратно и перезапустить ноду. + +### Allow-list + +Админский API правит только эти ключи. Всё остальное — идентичность, ключи, data-dir, +эндпоинты, `plugin`, `shared-file-size` — намеренно вне досягаемости bearer-токена. + +| Ключи | Тип | Диапазон | +|---|---|---| +| `p2p-max-connections` | uint | 1–10000 | +| `dlt-peer-max-disconnect-hours` | uint | 1–8760 | +| `dlt-mempool-max-tx` | uint | 1–1000000 | +| `dlt-mempool-max-bytes` | uint | 1024–1073741824 | +| `dlt-mempool-max-tx-size` | uint | 256–67108864 | +| `dlt-mempool-max-expiration-hours` | uint | 1–720 | +| `dlt-peer-exchange-max-per-reply` | uint | 0–1000 | +| `dlt-peer-exchange-max-per-subnet` | uint | 0–1000 | +| `dlt-peer-exchange-min-uptime-sec` | uint | 0–86400 | +| `dlt-stats-interval-sec` | uint | 10–86400 | +| `p2p-isolated-peers`, `auto-resync-on-wedge` | bool | — | +| `webserver-thread-pool-size` | uint | 1–4096 | +| `webserver-cache-enabled` | bool | — | +| `webserver-cache-size` | uint | 0–10000000 | +| `webserver-stats-enabled`, `webserver-limits-enabled` | bool | — | +| `webserver-limit-m`, `webserver-limit-h`, `webserver-limit-d` | uint | 1–100000000 | +| `webserver-stats-log-max-size` | uint | 1024–1073741824 | +| `webserver-stats-log-keep` | uint | 1–1000 | +| `webserver-stats-interval` | uint | 1–86400 | +| `validator-guard-enabled`, `validator-guard-disable`, `validator-guard-disable-on-shutdown` | bool | — | +| `validator-guard-interval`, `validator-guard-shutdown-grace` | uint | 1–3600 | +| `validator-guard-disable-max` | uint | 0–1000 | +| `logger.default.level`, `logger.p2p.level` | уровень лога | `all`, `debug`, `info`, `warn`, `error`, `off` | +| `log.capped_file_appender.node.max_lines` | uint | 0–10000000 | +| `log.capped_file_appender.node.flush` | bool | — | + +Ключи логов адресуются вместе с секцией: `logger.default.level` правит `level` внутри +`[logger.default]`, а не одноимённую опцию верхнего уровня. Отсутствующая в `config.ini` +секция — ошибка, а не повод её создать: нода такую секцию не объявляет, и значение молча +игнорировалось бы на следующем старте. Имена файлов аппендеров и окна ротации остаются за +оператором: опечатка в пути — это потерянный лог, а не настройка. + +### Пример + +```json +{"jsonrpc":"2.0","id":80,"method":"admin.config.plan", + "params":{"set":{"p2p-max-connections":200,"webserver-limit-m":600}}} + +{"jsonrpc":"2.0","id":80,"result":{ + "plan_id":"9f2c41a0d3b54e8770c5f1a2b3c4d5e6","kind":"config", + "config_path":"/var/lib/vizd/config.ini","needs_restart":true, + "diff":[{"key":"p2p-max-connections","from":"64","to":"200"}, + {"key":"webserver-limit-m","from":null,"to":"600"}], + "expires_at":"2026-10-19T14:42:00Z", + "hint":"nothing was written yet; apply with admin.config.apply {plan_id}…"}} +``` + +```json +{"jsonrpc":"2.0","id":81,"method":"admin.config.apply", + "params":{"plan_id":"9f2c41a0d3b54e8770c5f1a2b3c4d5e6"}} + +{"jsonrpc":"2.0","id":81,"result":{ + "applied":true,"kind":"config", + "config_path":"/var/lib/vizd/config.ini", + "backup":"/var/lib/vizd/config.ini.bak-1760884860", + "needs_restart":true, + "restart_hint":"config.ini is read at startup: call admin.restart {confirm:\"restart\"}…"}} +``` + +Пример с плагинами: + +```json +{"jsonrpc":"2.0","method":"admin.plugins.plan", + "params":{"add":["block_info"],"remove":[]}} +{"jsonrpc":"2.0","method":"admin.plugins.apply","params":{"plan_id":"…"}} +``` + +Оба apply отвечают `-32004 config unavailable`, если `config.ini` не читается, и +`-32000 write failed`, если атомарная замена не удалась (исходный файл остаётся нетронутым). + +--- + +## Перезапуск и супервизия + +`admin.restart {"confirm":"restart"}` (скоуп `process`) просит `appbase` завершиться: +сначала уходит ответ, затем отдельный поток через 300 мс вызывает `quit()`, чтобы HTTP-ответ +успел дойти до клиента. Процесс завершается с кодом 0. + +**Сам плагин ноду обратно не поднимает.** Это работа супервизора — `--restart +unless-stopped` у контейнера или `Restart=always` у systemd. Без них нода просто останется +лежать; ответ говорит об этом в `hint`, и это намеренно: админский эндпоинт, который +форкает новый процесс ноды, был бы путём эскалации привилегий за пределы жизненного цикла +процесса. + +Поскольку `config.ini` и список плагинов читаются при старте, `admin.config.apply` и +`admin.plugins.apply` вступают в силу только после этого перезапуска. `confirm` обязан быть +ровно словом `restart`; иначе `-32602`. Повторный вызов, пока завершение ещё не началось, +отвечает `-32000 restart already requested`. + +--- + +## Эндпоинт MCP (Streamable HTTP) + +Решение q#3052=B: MCP-сервер живёт **внутри этого плагина**, а не в sidecar. Тот же +loopback-слушатель, те же bearer-токены и тот же журнал аудита обслуживают Model Context +Protocol поверх HTTP — на хосте ничего дополнительно ставить не нужно. + +Запросы MCP — обычные JSON-RPC 2.0 `POST` на админский эндпоинт: + +| Метод | Назначение | +|---|---| +| `initialize` | Рукопожатие. Поддерживаемые версии: `2024-11-05`, `2025-03-26`, `2025-06-18` (запрошенная возвращается как есть, иначе `2025-06-18`). Объявляется только возможность `tools`. | +| `notifications/initialized` | Уведомление (без `id`); ответ — HTTP 202 с пустым телом. | +| `ping` | Keepalive MCP; возвращает `{}`. | +| `tools/list` | Инструменты ниже с JSON-схемой `inputSchema`. | +| `tools/call` | `{"name":"admin.node_info","arguments":{…}}` — выполняет соответствующий метод `admin.*`. | + +Инструменты 1:1 с методами admin: `admin.ping`, `admin.auth.me`, `admin.node_info`, +`admin.diagnostics`, `admin.plugins.list`, `admin.plugins.status`, `admin.objects.list`, +`admin.objects.check`, `admin.objects.repair`, `admin.config.get`, `admin.logs.list`, +`admin.logs.tail`, `admin.audit.tail`, `admin.validators.active`, +`admin.p2p.status`, `admin.p2p.add_node`, `admin.p2p.add_trusted`, `admin.p2p.clear_banned`, +`admin.snapshot.status`, `admin.snapshot.create`, `admin.snapshot.restore`, +`admin.webserver.status`, `admin.webserver.ips`, `admin.webserver.limits`, +`admin.webserver.stats`, `admin.webserver.whitelist_add`, `admin.webserver.whitelist_remove`, +`admin.webserver.blacklist_add`, `admin.webserver.blacklist_remove`, `admin.webserver.reset`, +`admin.webserver.reload`, `admin.validator.list`, `admin.validator.plan`, +`admin.validator.apply`, `admin.config.plan`, `admin.config.apply`, `admin.config.backups`, +`admin.plugins.plan`, `admin.plugins.apply`, `admin.plan.cancel`, `admin.restart` +(сорок один). +Действия над пирами дополнительно требуют скоупа `p2p`, действия над снапшотами — `snapshot`, +действия над валидаторами — `chain` (и живого `validator_guard` с active-ключом), действия +над webserver — `webserver`, правки конфига и плагинов — `config` или `plugins`, +перезапуск и починка состояния — `process`, ровно как и по обычному JSON-RPC. Ошибки исполнения инструмента возвращаются **внутри** +результата (`"isError": true`, текст в `content[0].text`), как требует MCP; протокольные +ошибки (неизвестный инструмент, плохие параметры, нет скоупа) остаются ошибками JSON-RPC. + +```json +{"jsonrpc":"2.0","id":7,"result":{"tools":[ + {"name":"admin.node_info","description":"Chain id, head block, hardfork, uptime and head age.", + "inputSchema":{"type":"object","properties":{},"additionalProperties":false}}]}} +``` + +```json +{"jsonrpc":"2.0","id":8,"result":{ + "content":[{"type":"text","text":"{\"head_block_num\":1234,…}"}],"isError":false}} +``` + +Эндпоинт без сессий: `Mcp-Session-Id` не выдаётся, `GET` на всё, кроме `/ui/` и маршрутов +`/snapshot/…` (включая необязательный SSE-поток), отвечает `405`. Клиенты, которым обязателен +SSE, не поддерживаются; ответы — JSON. + +Конфигурация клиента (транспорт Streamable HTTP) указывает на тот же loopback-адрес: + +```json +{"mcpServers":{"viz-node":{"type":"http","url":"http://127.0.0.1:8099", + "headers":{"Authorization":"Bearer "}}}} +``` + +--- + +## Встроенный веб-интерфейс (`/ui/`) + +Одна статическая страница, вкомпилированная в бинарник и отдаваемая тем же слушателем: + +```text +http://127.0.0.1:8099/ui/ +``` + +Чистые HTML + vanilla JS — без сборки, без внешних ресурсов, без фреймворков. CSP разрешает +только собственные inline-стиль/скрипт страницы и same-origin `fetch`, всё остальное — +`'none'`. + +Базу JSON-RPC страница выводит из собственного URL, поэтому работает и когда оператор +публикует слушатель за префиксом обратного прокси, а не в корне: при nginx +`location /admin/ { proxy_pass http://127.0.0.1:9099/; }` страница живёт на `/admin/ui/` +и вызывает `/admin/`. В корне (`/ui/`) база — `/`, как и раньше. + +**Аутентификация.** У страницы нет собственного секрета. При первом использовании оператор +вставляет bearer-токен; он хранится в `sessionStorage` (на вкладку, очищается при её +закрытии) и отправляется как `Authorization: Bearer …` в каждом JSON-RPC-вызове обратно на +тот же слушатель. Сама страница отдаётся без аутентификации намеренно: она одинакова для +всех, данных в ней нет, а браузер всё равно не умеет приложить bearer-заголовок к переходу +по ссылке. Токены никогда не кладутся в URL (иначе они попадут в историю и access-логи). + +**Что показывает** + +Страница разбита на семь вкладок; последняя выбранная запоминается на сессию вкладки. + +* **Status** — состояние ноды, аварийный консенсус (`ACTIVE` с блоком начала или `normal`), + настроен ли аварийный ключ, голова/LIB, возраст головы, версия, аптайм, счётчики пиров, + лучшая голова пира и наше отставание, баны и флаги. Карточка состояния снапшотов прямо + расшифровывает `idle` («никакой задачи со снапшотом не выполняется»), `creating` и + `restoring`. +* **Peers** — таблица всех отслеживаемых пиров: направление, lifecycle, голова, отставание, + статусы ноды/форка, флаги (`trusted`, `exchange`, `aligned`), возраст соединения, + примечание о бане/повторе. При скоупе `p2p` появляется панель действий: добавить пира по + `IP:port`, добавить как доверенного, очистить баны. +* **Snapshots** — состояние, каталог, общее число и самый свежий файл; **20** новейших + файлов с кнопкой **Load more**, которая листает через `offset`/`limit`; блок, размер, + возраст и кнопки **Download** / **Restore** в строке, плюс **Create snapshot** и + **Restore latest**. На время скачивания кнопка блокируется и показывает радиальный + индикатор до прихода тела ответа, поэтому повторные клики не запускают параллельные + скачивания. Скачивание идёт через авторизованный маршрут `/snapshot/` и + сохраняется браузером под именем файла. +* **Logs** — выбор файла из записей `admin-log-file`, кнопка **Play/Pause** и прокручиваемая + панель. По умолчанию **пауза**: Play опрашивает `admin.logs.tail` каждые две секунды и + дописывает только новые строки внизу, раскрашивая их по важности. Если оператор прокрутил + вверх, автопрокрутка прекращается; возврат вниз её возобновляет. +* **Plugins** — плагины из `config.ini` с наблюдаемым состоянием и числом редактируемых + параметров, кнопка **Disable** в каждой строке (`chain` и `node_admin` показывают + недоступную кнопку **protected**) и выбор плагина, который умеет загружать бинарь. + Любое изменение идёт через `plugins.plan` → подтверждение с последствием → `plugins.apply`. + Ниже таблицы **Parameters** рисует allow-list-ключи по группам плагинов, а **Logging** — + ключи логгеров и аппендеров; каждая группа сохраняется через `config.plan`/`config.apply`. + Значения, взятые из кода (ключа нет в `config.ini`), помечены `code default`, а не пустым + полем. После любого apply появляется баннер «файл изменён» с кнопкой **Restart node**: + она зовёт `admin.restart` после двух подтверждений и работает только со скоупом `process`. + Кнопка **Status** в каждой строке, у плагина которой есть свой статус, зовёт + `admin.plugins.status` и показывает ответ как есть над таблицей; у строк без статуса + кнопки нет — отсутствие видно, а не превращается в пустую панель. +* **Objects** — взгляд chainbase: путь и размер shared memory, отображено/свободно/ + зарезервировано, ревизия индексов против head, версии схемы, размер блок-лога, маркеры + починки и таблица типов объектов с числом строк и ревизией индекса, от большего к + меньшему. **Check** зовёт `admin.objects.check` и красит вердикт (`ok` / `warn` / + `corrupted` / `unreadable`), каждая находка — строкой + `SEVERITY [code] text`; рекомендованный режим починки подставляет селектор. + **Restart with repair** шлёт `admin.objects.repair` после двух подтверждений, где прямо + сказана разница между replay (блоки остаются) и resync (блоки стираются), и требует скоуп + `process`. Как и Plugins, вкладка вне пятисекундного таймера, чтобы ожидающий вердикт не + перерисовывался под оператором. +* **Validators** — имена из текущего расписания валидаторов (публичные данные цепи, без + ключей), размер расписания и аварийное состояние; ниже — валидаторы, которыми управляет + эта нода через `validator_guard`, если плагин жив. + +**Страницей управляют скоупы.** При загрузке страница вызывает `admin.auth.me` и скрывает +каждое действие, которое токену недоступно: без `snapshot` пропадают кнопки Create/Restore +и Restore в строках, без `p2p` — панель действий над пирами, без `plugins` — кнопки +Disable/Add, без `config` поля параметров заблокированы, без `process` недоступны кнопки +Restart и Repair. Поэтому read-only токен видит +дашборд только со списком и скачиванием, и страница говорит об этом прямо, вместо кнопок, +на которые API ответил бы `-32002`. + +Статус обновляется каждые пять секунд, пока вкладка видима. + +**Чего оно не делает.** Нет управления токенами и удаления файлов снапшотов. Записи — три +действия над пирами, создание снапшота, восстановление в процессе, правка `config.ini` +парой plan/apply, запрос перезапуска и запрос починки состояния (это маркер плюс рестарт: +сам пересбор делает chain-плагин на следующем старте, а не админский плагин). Это тонкий вид поверх того же JSON-RPC-контракта, описанного +выше — всё, что умеет страница, умеют и `curl`, и MCP-клиент, а каждый вызов попадает в +журнал аудита под меткой токена (собственный `GET /ui/` пишется в аудит с исходом `ui`, +скачивание — с `snapshot_download`). + +**Токен для страницы.** Заведите отдельный токен с нужными странице скоупами: токен только +с `read` даёт живой read-only дашборд со списком и скачиванием снапшотов, добавление `p2p` +включает кнопки действий над пирами, а `snapshot` — кнопки **Create** и **Restore**: + +```ini +admin-token = ui:read,snapshot,p2p: +``` + +--- + +## Аудит + +Каждый запрос — включая неудачную аутентификацию, ошибки разбора и срабатывание лимита — +добавляет одну JSON-строку: + +```json +{"ts":"2026-10-19T14:40:00Z","rid":"17","token":"agent","method":"admin.node_info", + "params":{},"outcome":"ok","code":0,"ms":2,"remote":"127.0.0.1:51234"} +``` + +`token` — метка, никогда не сам токен и не его хеш. `remote` — настоящий адрес пира +соединения (не `X-Forwarded-For`: слушатель по построению не стоит за прокси, иначе клиент +выбирал бы себе атрибуцию в аудите). `params` маскируются теми же правилами, что и хвост +лога. Файл создаётся с правами `0600` и ротируется в `.1` на 16 МБ. + +--- + +## Замечания по безопасности + +* Слушатель по умолчанию только на loopback; не ставьте его за nginx. +* Никогда не передавайте токен в query string — он попадёт в логи прокси и access-логи. +* Токен — это доступ к процессу ноды. Один токен на агента, чтобы аудит атрибутировал + вызовы, и минимальный набор скоупов. +* `admin.config.get` маскирует секреты по имени ключа, а не по форме значения. Секрет под + безобидным именем будет напечатан — не заводите таких ключей. +* Админский API никогда не отдаёт WIF-ключи. Кроме журнала аудита, на диск пишут + `admin.snapshot.create` (в настроенный каталог снапшотов) и apply у + `admin.config.*`/`admin.plugins.*` (конфиг плюс один `.bak-*` на каждый apply). +* `admin.snapshot.restore` **заменяет всё состояние цепи** выбранным файлом. Он + приостанавливает обработку блоков и очищает shared memory; неверный или устаревший снапшот + разрушителен. Считайте скоуп `snapshot` эквивалентом права записи в состояние ноды, а при + ожидаемом догоне с пирами предпочитайте снапшот на уровне LIB пиров или ниже. +* Снапшоты отдаёт только админский слушатель, никогда публичный webserver. Не копируйте их + в общедоступный путь; снапшот содержит полное состояние цепи и, в зависимости от сборки, + метаданные аккаунтов. +* `GET /ui/` без аутентификации — так задумано, и это одни и те же байты для всех. Доступа + это не даёт: без токена каждый её вызов получает `401`. `OPTIONS` он не отвечает и + CORS-заголовков не шлёт, поэтому чужая страница не сможет дёргать его из браузера. +* Действия над пирами живут в памяти. `add_trusted` и `add_node` действуют до перезапуска + процесса; постоянные закрепления — в конфигурации сидов P2P, а не в админском API. +* Скоуп `webserver` может влиять на доступность публичного API (слишком строгий лимит или + ошибочная запись в чёрном списке отклоняют добросовестных клиентов). Токен с ним — это + политический credential, а не только читающий; изменения к тому же переживают перезапуск + через `webserver-admin-state`. +* Скоупы `config` и `plugins` правят `config.ini` на диске. Allow-list держит вне + досягаемости идентичность, ключи, эндпоинты и `plugin`, но редактируемые ключи всё равно + меняют поведение ноды при следующем перезапуске. `apply` сверяет хеш файла, поэтому правка, + сделанная кем-то другим после плана, никогда не будет молча перезаписана; бэкап — это путь + отката. +* `admin.config.backups` перечисляет файлы, в которых есть секреты (токены, строки + `private-key`). Они создаются с правами исходного файла; не копируйте их в общедоступный + путь. +* Скоуп `process` останавливает ноду. Без супервизора нода не вернётся; админский API + намеренно не умеет перезапускать себя в обход жизненного цикла процесса. +* `admin.objects.repair` — это право записи, замаскированное под обслуживание. + `mode=replay` выбрасывает всё состояние и собирает его заново из блок-лога; + `mode=resync` **вдобавок удаляет блок-лог**, и нода сможет подняться только синхронизацией + с пирами. Сначала гоняйте `admin.objects.check`, а скоуп считайте равным удалению + каталога состояния руками. + +--- + +## См. также + +* [Обзор плагинов](./overview.md) +* [Webserver](./webserver.md) — публичный API, который этот плагин намеренно не использует + +--- + +## Тесты + +`plugins/node_admin/tests/acceptance.sh` поднимает одноразовую ноду, дёргает слушатель +curl'ом и проверяет этот контракт: аутентификацию и скоупы, маскирование конфига/логов, +allowlist логов, коды ответов, аудит, лимит, действия над P2P-пирами (включая отказ без +скоупа `p2p`), статус/список/скачивание снапшотов (включая `HEAD`, traversal и отсутствующий +файл, а также отказ без скоупа `snapshot` на create/restore), статус каждого плагина +(запущенный со своим представлением, неслинкованный тип отвечает `state: null` с причиной, +запущенный плагин без своего статуса, отказ без имени), объекты chainbase (`index_count` +совпадает с длиной массива, порядок от большего, схема совпадает с бинарём, чистый вердикт на +свежей ноде и отказы repair — скоуп read, отсутствующий `confirm`, неизвестный режим и +`replay` при пустом блок-логе, причём отказ не оставляет маркера), статистику/лимиты webserver +(всплеск → `429`, обход белым списком, `403` чёрным, смена лимитов на ходу, сохранение и +reload), двухфазную правку конфига и плагинов (allow-list, скоупы и привязка к токену, +одноразовость, истечение, отказ по устаревшему хешу, бэкапы, защита плагинов), операции с +ключами валидаторов (список остаётся читаемым и называет недостающий плагин, `plan` отвергается +без `chain`, а затем без живого guard'а; после перезапуска guard загружен и стартовал, но +валидатора с active-ключом нет — `plan` по-прежнему отвергается), перезапуск +(гард `confirm`, гард скоупа, чистый выход с кодом 0 и старт «как у супервизора», который +читает отредактированный конфиг обратно), MCP `initialize`/`tools/list`/`tools/call` и +страницу `/ui/`. Поднимает плагин +webserver на третьем порту, поэтому нужны собранный `vizd` и три свободных порта; в CTest +не входит. + +Путь подписи этим скриптом не проверить: у свежей цепи нет второго валидатора, а у +genesis-аккаунта `committee` active-авторитет пуст. Его проверяет +`plugins/node_admin/tests/validator-e2e.sh` — он поднимает собственную черновую цепь (временный +каталог и три порта; `--keep` оставляет её для разбора). Скрипт проводит тестовую однослотовую +цепь через HF9/HF12: `viz` регистрируется, вестится и получает голос в первых блоках, потому что +голоса весят доли вестинга, HF9 удаляет валидаторов, не подписавших ни одного блока, а HF12 +исключает `committee` из выбора; затем создаются два валидатора: `backup` несёт операторские +действия, а `cold` нужен, чтобы план-батч мог назвать двух разных валидаторов. Проверки: список +(`on_chain`, `enabled`, `active_authority`), disable/enable/set-key с id транзакции, файл удержаний +переживает перезапуск ноды, устаревший план отвергается с `chain state changed` и ничего не +подписывает, план из двух действий применяет оба, производящий валидатор ротирует собственный +ключ, и углы контракта (дубль, неизвестное имя, плохой ключ, no-op → `-32602`). + +Два решения в подготовке цепи принципиальны. Операторские действия никогда не целятся в +единственного *запланированного* валидатора: расписание пересобирается только в конце +применённого блока, поэтому обнуление его ключа оставляет устаревшее расписание, указывающее на +валидатора без ключа, и однослотовая цепь встаёт намертво. Секция саморотации — регрессия на +исправленный баг ноды: заголовок блока проверяется по ключу, зафиксированному в head, поэтому +блок, несущий обновление, обязан быть подписан СТАРЫМ ключом, а новый вступает в силу со +следующего блока. Раньше продюсер брал ключ подписи из session-visible объекта валидатора +(`validator.cpp`), поэтому ожидающий `validator_update`, меняющий ключ производящего валидатора, +заставлял подписать блок НОВЫМ ключом; `validate_block_header` отвергал блок, pending-пул +очищался, обновление молча терялось. Теперь плагин держит снапшот committed-ключей (засевается на +старте, обновляется по содержимому применённых блоков) и подписывает им. + +`tests/node_admin/` — две цели CTest (Boost.Test). `node_admin_util` покрывает чистые +хелперы: маскирование секретов, разбор токенов, парсер конфига, allow-list и нормализацию, +а также редакторы сырых строк (отступы, CRLF, секции, гарды добавления/удаления плагинов). + +`node_admin_objects` покрывает ветки вердикта `admin.objects.check` в `objects_check.hpp` — +заголовок намеренно без fc и chainbase, поэтому каждое плечо гоняется на вручную собранном +состоянии: здоровое, отставание ревизии после краша (warn, не порча), обрезанный файл +(replay), нет блок-лога (resync), исчерпанная память в одиночку (починка не предлагается — +тот же размер забьётся снова), исчерпание рядом с другой критичной находкой (replay), +пережившие маркеры, правила схемы, кардинальность ключевых индексов выше и на генезисе, +граница 5% свободного места, зависший read lock (unreadable, и починка не предлагается по +замку, на который так и не ответили) и предупреждения о восстановлении/блок-логе. Именно +судья приглашает оператора стереть состояние, поэтому ложное `corrupted` и ложное `ok` — +оба дефекты, и на каждое есть тест. diff --git a/@l10n/ru/docs/plugins/overview.md b/@l10n/ru/docs/plugins/overview.md index 6d3ac89c8d..260c1783a7 100644 --- a/@l10n/ru/docs/plugins/overview.md +++ b/@l10n/ru/docs/plugins/overview.md @@ -35,6 +35,7 @@ VIZ Ledger использует фреймворк плагинов **AppBase**. | `snapshot` | Рекомендуется | `chain` | — | | `block_archive` | Опционально | `chain` | — | | `validator_guard` | Рекомендуется для валидаторов | `chain`, `p2p` | — | +| `node_admin` | Опционально (оператор/AI-агент) | `chain` | Свой loopback-слушатель | ### API diff --git a/@l10n/ru/docs/plugins/prediction-market-api.md b/@l10n/ru/docs/plugins/prediction-market-api.md index 7d4d84c1bf..060955736d 100644 --- a/@l10n/ru/docs/plugins/prediction-market-api.md +++ b/@l10n/ru/docs/plugins/prediction-market-api.md @@ -56,7 +56,7 @@ Read-only JSON-RPC доступ к состоянию прогнозных ры | `get_account_leverage_positions` | `account, from, limit` | `pm_leverage_position_object[]` | | `get_market_leverage_positions` | `market_id, from, limit` | `pm_leverage_position_object[]` | | `get_creator_ban` | `account` | `pm_creator_ban_object` (или ошибка, если нет) | -| `get_oracle` | `owner` | `pm_oracle` (объект + `reliability_score`) | +| `get_oracle` | `owner` | `pm_oracle` (объект + `reliability_score` + чтения no-contest) | | `list_oracles` | `from, limit` | `pm_oracle_object[]` | ### Превью плеча (Boost) @@ -85,10 +85,12 @@ Read-only котировки, вызывающие **ту же внутриуз | `get_lazy_deposit` | `account` | `pm_lazy_deposit_object` | | `get_lazy_allocations` | `from, limit` | `pm_lazy_allocation_object[]` | | `get_market_lazy_allocation` | `market_id` | `pm_lazy_allocation_object` (или ошибка, если нет) | -| `get_pm_chain_properties` | — | `chain_properties_pm` (медиана, v5) | +| `get_pm_chain_properties` | — | `chain_properties_pm` (медиана, v5) + `pm_no_contest_fee_per_market` (производное) | `get_lazy_allocations` перечисляет записи аллокаций lazy-пула по рынкам (для дашборда пула); `get_market_lazy_allocation` берёт запись для конкретного рынка. Штрафные штампы оракула отдельного метода не требуют — они идут на `pm_oracle_object` (`penalty_stamps`, `last_penalty_stamp_time`) через `get_oracle`. +Каждая карточка рынка (`pm_market_card` — в списках, `get_market` и `get_market_full`) несёт **`settlement_charge`**: штраф за no-contest, который расчёт спишет прямо сейчас — замороженная при объявлении цена (HF16) или текущая медиана для дофорковых строк, с кэпом по страховке оракула — и `0`, если рынок не объявлен void (`status 3`, выплата не произведена, спора нет) или на нём нет ставок. `get_oracle` агрегирует тот же расчёт по всему оракулу: `no_contest_pending_count` / `_stake` / `_fee_nominal` / `_fee_capped` (обход `by_oracle_finalized` по открытым обязательствам, история оракула не читается). Оба поля — только чтение для отображения; само списание делает консенсус при расчёте. + ### Графики — kline / история весов Тайм-серия для построения графика изменения веса каждого исхода. Плагин добавляет точку **каждый раз, когда веса исходов рынка меняются** — ставка, отмена, ликвидация, batch-settle, открытие плеча или расчёт плеча — как таймстампированный снимок паримутюэль-веса (сумма ставок) по каждому исходу. Это **неконсенсусное** состояние плагина (хранится в chainbase, undo/redo-безопасно, не входит в хеш состояния); история копится с момента первого включения плагина на узле. diff --git a/@l10n/ru/docs/plugins/webserver.md b/@l10n/ru/docs/plugins/webserver.md index ce7ffaee1e..81b807a648 100644 --- a/@l10n/ru/docs/plugins/webserver.md +++ b/@l10n/ru/docs/plugins/webserver.md @@ -23,9 +23,21 @@ json_rpc::plugin | `webserver-thread-pool-size` | `256` | Рабочие потоки для обработки HTTP и WebSocket запросов. | | `webserver-cache-enabled` | `true` | Включить кеш ответов. | | `webserver-cache-size` | `10000` | Максимальное количество кешированных ответов. Кеш полностью вытесняется при достижении этого лимита. | +| `webserver-stats-enabled` | `false` | Включить учёт запросов по IP, лог статистики и (если не переопределено) лимиты. | +| `webserver-limits-enabled` | значение `webserver-stats-enabled` | Применять лимиты по IP. Лимитам нужны счётчики из `webserver-stats-enabled`. | +| `webserver-limit-m` | `1000` | Максимум запросов с одного IP в минуту. | +| `webserver-limit-h` | `10000` | Максимум запросов с одного IP в час. | +| `webserver-limit-d` | `100000` | Максимум запросов с одного IP в сутки. | +| `webserver-stats-log` | `/webserver-stats.log` | Путь лога статистики; ротация по размеру. | +| `webserver-stats-log-max-size` | `10485760` (10 MiB) | Ротировать лог при превышении размера. | +| `webserver-stats-log-keep` | `5` | Сколько ротированных файлов (`.1` … `.N`) хранить. | +| `webserver-stats-interval` | `60` | Интервал в секундах между снимками в лог статистики. | +| `webserver-admin-state` | `/webserver-admin.json` | Живое состояние (лимиты, списки IP, флаг статистики), сохраняемое `node_admin`. | Для работы плагина должно быть задано хотя бы одно из `webserver-http-endpoint` или `webserver-ws-endpoint`. Оба могут быть включены одновременно. +Опции статистики и лимитов — только **стартовое значение**. После запуска живые значения принадлежат плагину и меняются через `node_admin` (см. [Управление статистикой и лимитами на ходу](#управление-статистикой-и-лимитами-на-ходу)); каждое изменение применяется к следующему запросу и сохраняется в `webserver-admin-state`. + --- ## Минимальный config.ini @@ -79,6 +91,70 @@ webserver-cache-enabled = false --- +## Учёт запросов и лимиты + +По умолчанию выключено. `webserver-stats-enabled = true` включает подсчёт запросов по IP-адресу клиента и лимиты (или задайте `webserver-limits-enabled` явно, чтобы разделить эти две вещи). + +### Лимиты + +| Окно | По умолчанию | +|------|--------------| +| минута | 1000 запросов | +| час | 10000 запросов | +| сутки | 100000 запросов | + +Окна фиксированные (минутная корзина сбрасывается на границе минуты, часовая — на границе часа, суточная — в полночь UTC), и каждый запрос проверяется по всем трём. Запрос сверх любого лимита отклоняется: + +- HTTP: `429 Too Many Requests`, `Retry-After: 60`, тело `rate limit exceeded`. +- WebSocket: ошибка JSON-RPC `-32006 rate limit exceeded`. + +Это осознанная защита от скрапинга и парсерного трафика: тем, кому нужно больше, следует поднимать свою ноду. + +### Списки + +- **Белый список** — адреса, обходящие лимиты. Они всё равно считаются и видны в статистике. +- **Чёрный список** — адреса, получающие `403 Forbidden` (HTTP) или `-32005 forbidden` (WebSocket). Чёрный список важнее белого. Он действует даже при выключенной статистике. +- Добавление адреса в один список удаляет его из другого. + +### Что содержит лог статистики + +Каждые `webserver-stats-interval` секунд плагин дописывает строку на каждый отслеживаемый IP плюс итог: + +```text +2026-10-19T12:00:00Z ip=203.0.113.7 total=4210 minute=320 hour=4210 day=4210 blocked=0 limited=3 whitelisted=0 blacklisted=0 last_seen=1760875200 +2026-10-19T12:00:00Z summary total_requests=4210 blocked_requests=0 limited_requests=3 tracked_ips=1 +``` + +Файл ротируется по размеру: `<имя>` становится `<имя>.1`, старые файлы сдвигаются в `.2` … `.webserver-stats-log-keep`, самый старый удаляется. + +### Адрес клиента + +Плагин использует TCP-адрес пира соединения. За nginx все запросы выглядят приходящими от прокси (`127.0.0.1`), поэтому лимиты по IP становятся глобальными, а отчёт показывает одну строку. Варианты: публиковать API напрямую, внести прокси в белый список и ограничивать на уровне прокси (`limit_req`), либо включить поддержку PROXY protocol на уровне прокси/сети. + +### Управление статистикой и лимитами на ходу + +Изменяющие методы требуют скоупа `webserver` у токена `node_admin`; чтению достаточно `read`: + +| Метод | Назначение | +|-------|------------| +| `admin.webserver.status` | Лимиты, итоги, списки IP, число отслеживаемых адресов | +| `admin.webserver.ips` | Счётчики по IP (`limit` до 5000), сортировка по числу запросов | +| `admin.webserver.limits` | Изменить `enabled`, `per_minute`, `per_hour`, `per_day` (опущенные поля сохраняют значение) | +| `admin.webserver.stats` | Включить/выключить учёт (`{"enabled": true|false}`) | +| `admin.webserver.whitelist_add` / `whitelist_remove` | Правка белого списка (`{"ip": "..."}`) | +| `admin.webserver.blacklist_add` / `blacklist_remove` | Правка чёрного списка (`{"ip": "..."}`) | +| `admin.webserver.reset` | Обнулить счётчики, сохранив списки и лимиты | +| `admin.webserver.reload` | Перечитать `webserver-admin-state` с диска | + +Каждый из них также доступен как MCP-инструмент с тем же именем. **Изменения применяются к живому плагину сразу — без перезапуска и без перечитывания `config.ini`.** Они записываются в `webserver-admin-state` (атомарно: временный файл + rename), который загружается при следующем старте и потому перекрывает стартовое значение из `config.ini`. Сам `config.ini` не перезаписывается. `reload` нужен оператору, правившему JSON руками; обычным изменениям он не требуется. + +```json +{"jsonrpc":"2.0","id":1,"method":"admin.webserver.limits", + "params":{"per_minute":600,"per_hour":8000}} +``` + +--- + ## WebSocket-подписки WebSocket-клиенты могут регистрировать колбеки: @@ -117,7 +193,7 @@ WebSocket-клиенты могут регистрировать колбеки: ## Безопасность - **Привяжите к localhost** (`127.0.0.1`) и используйте обратный прокси (nginx/Caddy) для публичного доступа. Привязка к `0.0.0.0` открывает RPC напрямую в сеть. -- Плагин не имеет встроенной аутентификации или ограничения скорости. Применяйте их на уровне обратного прокси. +- Плагин не имеет встроенной аутентификации. Есть необязательные лимиты по IP и списки адресов (см. [Учёт запросов и лимиты](#учёт-запросов-и-лимиты)), но это грубые инструменты — аутентификацию и общую фильтрацию держите на уровне обратного прокси. - Мутирующие методы (`network_broadcast_api`) защищены от отравления кеша по замыслу, но они остаются доступными для вызова с любого подключённого клиента — при необходимости ограничьте доступ на сетевом уровне. --- diff --git a/@l10n/ru/docs/prediction-markets/pm-no-contest-fee-freeze.md b/@l10n/ru/docs/prediction-markets/pm-no-contest-fee-freeze.md new file mode 100644 index 0000000000..ff3fcbe87b --- /dev/null +++ b/@l10n/ru/docs/prediction-markets/pm-no-contest-fee-freeze.md @@ -0,0 +1,153 @@ +# HF16 — заморозка цены no-contest: чеклист активации и семантика бэкфилла + +Статус: **реализовано, но не запланировано.** Форк зарегистрирован в обеих конфигурациях +(`CHAIN_NUM_HARDFORKS = 16`, `CHAIN_VERSION = 4.2.0`) и гейтится временем активации плюс кворумом +валидаторов: production-бинарь применит его, как только это время наступит, а до тех пор ничего не +выполняется. Эта заметка — операторский чеклист: что именно замораживается, как на рынке +различаются два состояния, что меняется в layout объектов и снапшоте, как проверить активацию и +почему seed-проход для строк, уже находящихся в окне спора на момент форка, не нужен. + +## 1. Что меняет форк + +**Проблема.** `pm_no_contest` лишь объявляет void и открывает окно `pm_dispute_grace_sec`, а штраф +считается на **сеттлменте**. До HF16 сумма бралась как +`floor(pm_dispute_fee × pm_no_contest_penalty_percent / 10000)` из медианного `chain_properties_pm` +**на момент сеттлмента**. Оба входа — медианно голосуемые параметры, поэтому голосование внутри окна +спора меняло счёт после того, как оракул уже объявил void: оракул объявлял одно число, а платил +другое, и узнать итоговую цену при объявлении было невозможно. + +**Фикс: число фиксируется при объявлении.** `pm_no_contest_evaluator` считает ту же формулу от +медианы **блока объявления** и сохраняет её на рынке в `pm_market_object::no_contest_fee`. +`settle_market(win < 0)` списывает ровно эту сумму, а не перечитывает медиану. Кап по страховке не +меняется и по-прежнему применяется на каждом сеттлменте: +`penalty = min(frozen_fee, oracle.insurance)`. + +**Два состояния, один флаг.** `pm_market_object::no_contest_fee_frozen` говорит, была ли сумма вообще +заморожена: + +* `true` — void объявлен **под HF16**; сеттлмент списывает `no_contest_fee` как есть. +* `false` — строка объявлена **до HF16** (или импортирована из до-HF16-снапшота); сеттлмент сохраняет + legacy-вычисление от медианы — ровно поведение до форка. + +Флаг отделён от суммы намеренно. Замороженный штраф **0** (процент штрафа был проголосован в 0 на +момент объявления) обязан остаться нулём, даже если позже процент поднимут: «замороженный ноль» и +«никогда не замораживался» — разные состояния, и вывод legacy-ности из `fee == 0` молча переоценил бы +первое. Рынок, объявленный под HF16 без ставок, тоже записывает `frozen = true, fee = 0`; это +безвредно, потому что сеттлмент списывает только при `settlement.stake_total > 0`. + +**Счётчик атрибуции.** `pm_oracle_object::no_contest_slashed_total` — монотонный итог штрафов +no-contest, списанных с оракула; он инкрементируется в **том же** `modify()`, что растит +`total_insurance_slashed`. Это даёт классу no-contest точную атрибуцию без virtual operation, и +счётчик растёт на обоих путях (legacy-строка, рассчитавшаяся после активации, — всё равно +no-contest-списание). Он накапливается вперёд от HF16: штрафы, списанные до форка, **не** +бэкфиллятся — агрегатная дельта осталась единственной записью, и вычитать её задним числом было бы +догадкой. Только отображение/атрибуция; консенсус его никогда не читает. + +**Layout объектов.** Оба поля **дописаны в конец** своих структур и в соответствующий `FC_REFLECT`: + +* `pm_market_object`: `share_type no_contest_fee; bool no_contest_fee_frozen = false;` +* `pm_oracle_object`: `share_type no_contest_slashed_total;` + +Импорт снапшота защищён `contains()` для всех трёх, поэтому до-HF16-снапшот импортируется с +дефолтами (`0` / `false`) — а это ровно состояние «legacy-строка», которого ждёт сеттлмент. Экспорт +снапшота общий (`EXPORT_INDEX`) и правки не требует. В `apply_hardfork` случая для HF16 нет +(`default: break` здесь корректен): нет синглтона для создания и нечего пересчитывать при активации. + +**Что едет без форка.** Эти read-only добавки полезны и до активации и не гейтятся — они выводятся из +того же состояния, которое прочитает сеттлмент: + +* `get_oracle` (и блок оракула в `get_market_full`) получает `no_contest_pending_count`, + `no_contest_pending_stake`, `no_contest_fee_nominal`, `no_contest_fee_capped` — цена объявленных этим + оракулом void'ов, которые ещё в окне спора (status 3, payout pending, спор не подан) и несут ставки. + Показываются и номинальная, и урезанная страховкой суммы: иначе срабатывание капа скрыло бы + номинальную цену. Считается на чтении, кэш на блок. +* Карточка рынка получает `settlement_charge`: сколько спишется, если этот рынок рассчитается void + **сейчас** (0 для всего, что не является ожидающим no-contest со ставками). +* `get_pm_chain_properties` сохраняет все прежние поля и добавляет + `pm_no_contest_fee_per_market = dispute_fee × penalty_percent / 10000` — номинальную единицу, + которую клиенты и скрипты иначе пересчитывают руками. RPC теперь возвращает variant; JSON-клиенты + видят тот же объект плюс один ключ. + +## 2. Как форк регистрируется и активируется + +* **Регистрация безусловна в обеих конфигурациях.** `0-preamble.hf` ставит `CHAIN_NUM_HARDFORKS` = 16; + `hardfork.d/16.hf` определяет `CHAIN_HARDFORK_16` / `CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK` и + `CHAIN_HARDFORK_16_VERSION`; `database_hardfork.cpp` регистрирует `_hardfork_times[16]` / + `_hardfork_versions[16]` для любой сборки. Массивы размера `[CHAIN_NUM_HARDFORKS + 1]`. Гейт — + таймстемп плюс кворум, а не flavour сборки — то же правило, что у HF15, и по той же причине: + shelter-тестнет — **production-config**-деплой, так что testnet-only форк там активироваться не + смог бы никогда. Полное обоснование — `pm-audit-fix-upgrade.md` §2. +* **Активация: 2026-10-31 00:00:00 UTC** (`CHAIN_HARDFORK_16_TIME` = 1793404800 в обеих + конфигурациях, задано владельцем 20.10.2026). Объявление валидаторам должно называть именно этот + момент, и до него 17 из 21 валидатора должны стоять на бинаре 4.2.0. Держать значение в **будущем**: с прошедшим + таймстемпом форк активируется в том блоке, где случился кворум, и объявляемого момента нет. + Менять его вместе с текстом объявления и никогда — после публикации. +* **Версия, а не ревизия.** `CHAIN_VERSION` переезжает на `4.2.0` в **обеих** конфигурациях, + `config.hpp` и `config_testnet.hpp`; средняя компонента — это hardfork-версия, за которую голосуют + валидаторы, и `database_hardfork.cpp` проверяет её равенство + `_hardfork_versions[CHAIN_NUM_HARDFORKS]`. (В двух конфигах раньше стояли 4.1.2 и 4.1.0 — обе + переезжают на один и тот же 4.2.0.) +* **Чеклист активации.** (1) Подтвердить или заменить провизорную дату и объявить её. (2) Замерить + популяцию из §4 на свежем снапшоте и закрыть там вопрос (ожидаемый ответ: действий не нужно). + (3) Выкатить образ; голоса валидаторов автоматические. (4) Подтвердить активацию по логу ноды и + прогнать проверки из §3. (5) Помнить ловушку emergency consensus из `pm-audit-fix-upgrade.md` §3: + на цепи в emergency consensus форк запланировать нельзя вообще, и вкомпилированное время — мёртвая + ручка. `testnet_plugin --testnet-hardfork 16` форсит форк на старте production-config-тестнета для + проверки; плагин ничего не делает, если не загружен и не получил цель. +* **Откат.** До таймстемпа передеплой прошлого образа безопасен — форк остаётся pending. После + активации маркер — состояние цепи: откатываться нельзя (до-HF16-бинарь рассчитал бы ожидающие + no-contest'ы от медианы, пока цепь считает иначе). Только вперёд. + +## 3. Проверка + +**До активации** (read-only добавки работают на живой цепи): + +* в `get_pm_chain_properties` есть `pm_no_contest_fee_per_market`, численно равный + `pm_dispute_fee × pm_no_contest_penalty_percent / 10000`; +* у оракула с void'ом в окне спора и ставками на нём `get_oracle` показывает + `no_contest_pending_count ≥ 1` и `no_contest_fee_nominal = count × unit`; карточка + соответствующего рынка показывает `settlement_charge`; +* ни один рынок ещё не несёт `no_contest_fee_frozen = true` — флаг пишется только под HF16. + +**После активации:** + +* рынок, объявленный void через `pm_no_contest`, сразу показывает `no_contest_fee_frozen = true` и + `no_contest_fee`, равный единице блока объявления; +* проголосовать `pm_dispute_fee` / `pm_no_contest_penalty_percent`, пока рынок в окне спора: итоговое + списание равно замороженной сумме, а не новой медиане; +* рынок, объявленный **до** активации с `frozen = false`, рассчитывается от текущей на тот момент + медианы, т.е. дофорковое поведение для него сохраняется; +* `oracle.no_contest_slashed_total` растёт ровно на списанную сумму, синхронно с + `total_insurance_slashed`; +* обычные post-redeploy-инварианты: SHARES delta 0, TOKEN delta равен legacy-якорю цепи, + `restarts 0`, листинги непустые. + +**Консенсус-регрессия.** `tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp` (BUILD_TESTNET) +гоняет одну цепь с тремя рынками, объявленными вокруг точки форка — legacy до форка, замороженный +после форка, замороженный в ноль, — затем двигает медиану и проверяет, что падение страховки и +`no_contest_slashed_total` равны `8u + 1u + 0` для legacy/замороженной/нулевой ноги соответственно. +Замер 19.10.2026 (BUILD_TESTNET, `consensus_sim_tests --run_test=pm_no_contest_fee_three_states`): +страховка `5 000 000 → 4 100 000` raw, то есть ровно `9u`; тест прошёл без ошибок. + +## 4. Строки, уже находящиеся в окне спора на момент активации + +**Seed-проход не нужен, и он был бы неверен.** У строки, объявленной до форка, замороженной суммы +нет; она обязана сохранить дофорковое правило (медиана на сеттлменте), потому что именно по нему её +объявляли. Запись замороженной суммы при активации изменила бы счёт оракула задним числом — ровно тот +дефект, который HF16 убирает. Флаг `false` — маркер, который делает это автоматическим: ни кода +миграции, ни переписывания снапшота, а старый снапшот восстанавливается ровно в то состояние, которого +ждёт сеттлмент. + +Остаточная экспозиция поэтому ограничена и названа: строки, объявленные до форка **и** всё ещё +находящиеся в окне спора на момент активации, могут быть переоценены голосованием между активацией и +их сеттлментом. Их число измеримо из цепи; если оно не ноль и сеть не хочет никакой ретроактивной +переоценки, единственный чистый ответ — дождаться окончания этих окон до любого голосования по +параметрам, а не миграция. + +**Замер перед планированием.** Пройти по ожидающим no-contest-строкам оракулов (status 3, payout +pending, `resolved_outcome = -1`) и посчитать те, у которых `bets_sum > 0`, плюс их стейк: +`get_oracle` отдаёт ровно это по каждому оракулу (`no_contest_pending_count` / `_stake`), так что новый +прибор не нужен. Замер на живом production-config-тестнете **2026-10-19**: 112 рынков в окне спора, +**0** из них со ставками — популяция, на которую могло бы повлиять голосование внутри окна, на тот +момент была пуста. Перемерить на свежем снапшоте непосредственно перед планированием: число ходит +вместе с популяцией оракулов и о будущем не говорит ничего. diff --git a/@l10n/ru/docs/protocol/operations/overview.md b/@l10n/ru/docs/protocol/operations/overview.md index c6ea56c1dc..9e08a17e76 100644 --- a/@l10n/ru/docs/protocol/operations/overview.md +++ b/@l10n/ru/docs/protocol/operations/overview.md @@ -127,6 +127,7 @@ | 103 | `pm_early_exit_claim_paid_operation` | Отложенная выплата досрочного выхода оплачена при расчёте | [Прогнозные рынки](./prediction-markets.md) | | 104 | `pm_lp_payout_operation` | Доход LP выплачен при расчёте | [Прогнозные рынки](./prediction-markets.md) | | 105 | `set_agent_permission_operation` | Выдать, заменить или отозвать ключ агента (HF15) | [Агент-доступ](./agent-access.md) | +| 106 | `validator_equivocation_operation` | Доказана двойная подпись валидатора (HF16 QC) | [Виртуальные операции](../virtual-operations.md) | --- diff --git a/@l10n/ru/docs/protocol/virtual-operations.md b/@l10n/ru/docs/protocol/virtual-operations.md index 909bbf4a06..f99c0bf696 100644 --- a/@l10n/ru/docs/protocol/virtual-operations.md +++ b/@l10n/ru/docs/protocol/virtual-operations.md @@ -377,6 +377,13 @@ Всё движение средств PM строго zero-sum (без эмиссии); расчёт сохраняет `Σ out == Σ ставок + принципал LP + forfeit_pool`. + +### Консенсус (HF16 QC) + +| ID | Операция | Триггер | +|----|----------|---------| +| 106 | `validator_equivocation_operation` | Блок принёс доказательство, что `validator` подписал голоса за два разных блока (`first_block_id`, `second_block_id`) в одном `round`. Наказание как за пропуск блока (`total_missed++`, `penalty_percent += validator_miss_penalty_percent` на `validator_miss_penalty_duration`); не чаще раза за раунд (`last_equivocation_round`) | + --- См. также: [Обзор операций](./operations/overview.md), [Награды](./operations/awards.md), [Комитет](./operations/committee.md). diff --git a/@l10n/zh-CN/docs/api/ids-and-hashes.md b/@l10n/zh-CN/docs/api/ids-and-hashes.md new file mode 100644 index 0000000000..1f374a71e0 --- /dev/null +++ b/@l10n/zh-CN/docs/api/ids-and-hashes.md @@ -0,0 +1,19 @@ +# 交易与区块 ID + +VIZ 的 ID 类型继承自 Graphene(BitShares,随后是 Steem)。源码中它们声明为 `fc::ripemd160`,旧文档和库也称之为“ripemd160 ID”。这只是存放它们的 20 字节容器的名字:**这两个 ID 都不是 ripemd160 哈希。** 节点通过别名 `id160_type` 明确说明这一点。 + +| 值 | 计算方式 | 大小 | +|---|---|---| +| `transaction_id` | `sha256(序列化的交易)` 的前 20 字节,**不**包含签名 | 20 字节 | +| `block_id` | `sha224(序列化的区块头)` 的前 20 字节,再把第 0–3 字节替换为区块号(大端序) | 20 字节 | +| `transaction_merkle_root` | 对已签名交易构建的 sha256 树的根做 `ripemd160` | 20 字节 | + +Merkle 根是唯一真正的 ripemd160 值。 + +## 对客户端的影响 + +* 计算交易 ID:序列化交易时**不含** `signatures`,取 sha256 的前 20 字节。不要使用 ripemd160,否则结果永远与节点不符。 +* 区块号可直接从 block id 读出:前 4 字节,大端序。TaPoS(`ref_block_num` / `ref_block_prefix`)取该编号的低 16 位和 block id 的第 4–7 字节。 +* 在所有 API 响应、block log 和账户历史中,ID 都是 20 字节。更换哈希(改为真正的 ripemd160 或完整的 32 字节 sha256)将是一次硬分叉,会把历史分成两种 ID 格式,却没有安全收益:160 位对标识符已经足够,签名针对的是完整的 sha256 摘要而非 ID。 + +源码:`libraries/protocol/transaction.cpp` 中的 `transaction::id()`,`libraries/protocol/block.cpp` 中的 `signed_block_header::id()` 与 `signed_block::calculate_merkle_root()`,类型定义见 `libraries/protocol/include/graphene/protocol/types.hpp`。 diff --git a/@l10n/zh-CN/docs/consensus/block-processing.md b/@l10n/zh-CN/docs/consensus/block-processing.md index 52c6864ce4..d376bee998 100644 --- a/@l10n/zh-CN/docs/consensus/block-processing.md +++ b/@l10n/zh-CN/docs/consensus/block-processing.md @@ -298,6 +298,7 @@ struct block_post_validation_message { ### 约束条件 - 只有**当前打乱排列的调度**中的验证者才计入 2/3 阈值;调度外的验证者被跳过。 +- **每轮只投一票(Jolteon 安全规则)。** 验证者只在 *r* 高于其已投票的最高轮次时,才为第 *r* 轮的区块投票(轮次 = 区块的时隙,`timestamp / CHAIN_BLOCK_INTERVAL`)。验证者插件把每个验证者的该轮次保存在 `/block-vote-round.txt`,在投票发出**之前**写入(无法持久化轮次的投票不会发送),并在同一轮出现另一个区块时记录日志 `Not voting for … already voted for … in round …`。与“每个高度只签一次”不同,验证者可以在更晚的轮次为同一高度的另一个区块投票——正是这一点让分裂的投票得以解决而不是卡住。同一轮中两个冲突的法定人数证书需要有验证者投票两次;节点会把这类投票记录为 `Equivocation: …`。重复广播同一投票仍然允许。 - 在**紧急共识**期间(`emergency_consensus_active = true`),`check_block_post_validation_chain()` 立即返回——LIB 仅通过经典路径推进,以避免恢复过程死锁。 - 确认列表上限为 **20 条**(`CHAIN_MAX_BLOCK_POST_VALIDATION_COUNT`)。低于当前 LIB 的条目在每个区块后删除。 diff --git a/@l10n/zh-CN/docs/node/configuration.md b/@l10n/zh-CN/docs/node/configuration.md index d7d8e1bc21..f6a5f8a558 100644 --- a/@l10n/zh-CN/docs/node/configuration.md +++ b/@l10n/zh-CN/docs/node/configuration.md @@ -197,6 +197,25 @@ logger.p2p.level = warn logger.p2p.appenders = p2p ``` +`file` appender 已配置为按时间轮转(每小时一个新文件,保留一天),但只有在 fc +纤程队列被处理时轮转才会触发,而 `vizd` 启动后不再处理它(2026-10-20 实测:测试网节点在 +`info` 级别下九小时写了一个 37 MB 的文件,没有任何按小时的文件;主网节点运行四天也没有 +产生新文件)。不要指望它来限制文件大小,并注意在 `debug` 下节点每小时可能写出数 GB 日志。 +如果只需要最近的尾部,请使用可选的 capped appender:单个文件,只保留最后 `max_lines` 行 +(默认 10000,`0` 表示不限制)。它在写入时统计行数并在原文件上就地压缩,因此文件保持在 +`max_lines` 与约 1.1 x `max_lines` 之间,不依赖任何定时器。配置中没有 +`log.capped_file_appender.*` 节的节点行为与之前完全一致;该节也可以安全地先于二进制升级 +下发——旧版本会忽略它并继续写入 stderr。若配置的路径无法打开,节点仍会启动:向 stderr +输出一行 `capped_file_appender: cannot open log file …`,只是不会创建文件。 + +```ini +log.capped_file_appender.node.filename = logs/node/node.log +log.capped_file_appender.node.max_lines = 10000 + +# 写哪些日志由 logger 决定;appender 本身不写任何东西。 +logger.default.appenders = stderr, node +``` + --- ## 完整参考 diff --git a/@l10n/zh-CN/docs/node/getting-started.md b/@l10n/zh-CN/docs/node/getting-started.md index 483001422f..012de525cb 100644 --- a/@l10n/zh-CN/docs/node/getting-started.md +++ b/@l10n/zh-CN/docs/node/getting-started.md @@ -37,6 +37,7 @@ docker pull vizblockchain/vizd:latest ```bash docker run -d \ --name vizd \ + --restart unless-stopped \ -p 2001:2001 \ -p 8090:8090 \ -p 8091:8091 \ diff --git a/@l10n/zh-CN/docs/protocol/operations/overview.md b/@l10n/zh-CN/docs/protocol/operations/overview.md index 65543cc4da..d7313afab2 100644 --- a/@l10n/zh-CN/docs/protocol/operations/overview.md +++ b/@l10n/zh-CN/docs/protocol/operations/overview.md @@ -127,6 +127,7 @@ VIZ Ledger 操作是包含在交易中的原子状态变更动作。每个操作 | 103 | `pm_early_exit_claim_paid_operation` | 提前退出的延迟索赔在结算时支付 | [预测市场](./prediction-markets.md) | | 104 | `pm_lp_payout_operation` | LP 收益在结算时支付 | [预测市场](./prediction-markets.md) | | 105 | `set_agent_permission_operation` | 授予、替换或撤销代理密钥(HF15) | [Agent access](./agent-access.md) | +| 106 | `validator_equivocation_operation` | 验证者双重投票已被证明(HF16 QC) | [虚拟操作](../virtual-operations.md) | --- diff --git a/@l10n/zh-CN/docs/protocol/virtual-operations.md b/@l10n/zh-CN/docs/protocol/virtual-operations.md index 9c22fd7ff3..1b32e86e7e 100644 --- a/@l10n/zh-CN/docs/protocol/virtual-operations.md +++ b/@l10n/zh-CN/docs/protocol/virtual-operations.md @@ -377,6 +377,13 @@ 所有 PM 资金流动严格零和(无增发);结算守恒 `Σ out == Σ 下注 + LP 本金 + forfeit_pool`。 + +### 共识(HF16 QC) + +| ID | 操作 | 触发条件 | +|----|------|----------| +| 106 | `validator_equivocation_operation` | 某区块携带证据,证明 `validator` 在同一 `round` 中为两个不同区块(`first_block_id`、`second_block_id`)签署了投票。处罚等同于漏块(`total_missed++`,`penalty_percent += validator_miss_penalty_percent`,持续 `validator_miss_penalty_duration`);每轮最多一次(`last_equivocation_round`) | + --- 参见:[操作概述](./operations/overview.md)、[奖励](./operations/awards.md)、[委员会](./operations/committee.md)。 diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ced54bbfa..0815b957b3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,12 @@ if(BUILD_TESTNET) message(STATUS " ") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -DBUILD_TESTNET") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DBUILD_TESTNET") + # Multi-validator testnet (see config_testnet.hpp); empty = one validator. + set(CHAIN_TESTNET_VALIDATORS "" CACHE STRING "Number of genesis validators on the test network") + if(CHAIN_TESTNET_VALIDATORS) + message(STATUS "CHAIN_TESTNET_VALIDATORS: ${CHAIN_TESTNET_VALIDATORS}") + add_definitions(-DCHAIN_TESTNET_VALIDATORS=${CHAIN_TESTNET_VALIDATORS}) + endif() endif() @@ -280,6 +286,8 @@ if(BUILD_CONSENSUS_TESTS) enable_testing() add_subdirectory(tests/consensus_sim) add_subdirectory(tests/pm) + add_subdirectory(tests/node_admin) + add_subdirectory(tests/logging) endif() if(ENABLE_INSTALLER) diff --git a/README.md b/README.md index edcf96eae4..8c65adeb61 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Example run: ``` docker run \ - -d -p 2001:2001 -p 8090:8090 -p 8091:8091 --name vizd \ + -d --restart unless-stopped -p 2001:2001 -p 8090:8090 -p 8091:8091 --name vizd \ vizblockchain/vizd:latest docker logs -f vizd diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 95e4a83d80..8481fde74f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -78,6 +78,7 @@ interface SidebarLabels { jsonRpc: string; cliWallet: string; clientLibraries: string; + idsAndHashes: string; development: string; buildingDev: string; testing: string; @@ -166,6 +167,7 @@ const en: SidebarLabels = { jsonRpc: 'JSON-RPC', cliWallet: 'CLI Wallet', clientLibraries: 'Client Libraries', + idsAndHashes: 'Transaction and Block IDs', development: 'Development', buildingDev: 'Building', testing: 'Testing', @@ -254,6 +256,7 @@ const ru: SidebarLabels = { jsonRpc: 'JSON-RPC', cliWallet: 'CLI-кошелёк', clientLibraries: 'Клиентские библиотеки', + idsAndHashes: 'Идентификаторы транзакций и блоков', development: 'Разработка', buildingDev: 'Сборка', testing: 'Тестирование', @@ -342,6 +345,7 @@ const zhCN: SidebarLabels = { jsonRpc: 'JSON-RPC', cliWallet: 'CLI 钱包', clientLibraries: '客户端库', + idsAndHashes: '交易与区块 ID', development: '开发', buildingDev: '构建', testing: '测试', @@ -482,6 +486,7 @@ function buildSidebar(t: SidebarLabels, prefix: string): DefaultTheme.SidebarIte { text: t.jsonRpc, link: p('/api/json-rpc') }, { text: t.cliWallet, link: p('/api/cli-wallet') }, { text: t.clientLibraries, link: p('/api/client-libraries') }, + { text: t.idsAndHashes, link: p('/api/ids-and-hashes') }, ], }, { diff --git a/docs/api/ids-and-hashes.md b/docs/api/ids-and-hashes.md new file mode 100644 index 0000000000..918f31515c --- /dev/null +++ b/docs/api/ids-and-hashes.md @@ -0,0 +1,30 @@ +# Transaction and block ids + +VIZ inherited its id types from Graphene (BitShares, then Steem). In the source they are declared +as `fc::ripemd160`, and older documentation and libraries call them "ripemd160 ids". That is only +the name of the 20-byte container they are stored in: **neither id is a ripemd160 digest.** +The node declares them through the alias `id160_type` to say so. + +| Value | How it is computed | Size | +|---|---|---| +| `transaction_id` | first 20 bytes of `sha256(packed transaction)`, signatures **not** included | 20 bytes | +| `block_id` | first 20 bytes of `sha224(packed block header)`, then bytes 0–3 replaced by the block number (big-endian) | 20 bytes | +| `transaction_merkle_root` | `ripemd160` of the root of a sha256 tree over the signed transactions | 20 bytes | + +The merkle root is the one value that really is a ripemd160. + +## Consequences for clients + +* To compute a transaction id, serialize the transaction **without** `signatures`, take sha256 and + keep the first 20 bytes. Do not run ripemd160 — the result would never match the node. +* The block number can be read straight from a block id: it is the first 4 bytes, big-endian. + `ref_block_num` / `ref_block_prefix` (TaPoS) use the low 16 bits of that number and bytes 4–7 of + the block id. +* The ids are 20 bytes in every API answer, in the block log and in account history. Changing the + hash (to a real ripemd160 or to a full 32-byte sha256) would be a hardfork that splits history in + two id formats, for no security gain: 160 bits is plenty for an identifier, and signatures are + made over the full sha256 digest, not over the id. + +Source: `transaction::id()` in `libraries/protocol/transaction.cpp`, `signed_block_header::id()` +and `signed_block::calculate_merkle_root()` in `libraries/protocol/block.cpp`, the types in +`libraries/protocol/include/graphene/protocol/types.hpp`. diff --git a/docs/consensus/block-processing.md b/docs/consensus/block-processing.md index e1065b77f9..f406632443 100644 --- a/docs/consensus/block-processing.md +++ b/docs/consensus/block-processing.md @@ -298,6 +298,7 @@ Classic DPOS path (~63 s) remains active as a fallback if confirmation messages ### Constraints - Only validators in the **current shuffled schedule** count toward the 2/3 threshold; off-schedule validators are skipped. +- **One vote per round (Jolteon safety rule).** A validator votes for a block of round *r* (round = the block's slot, `timestamp / CHAIN_BLOCK_INTERVAL`) only when *r* is above the highest round it has voted in. The validator plugin keeps that round per validator in `/block-vote-round.txt`, writes it **before** the vote leaves (a vote whose round cannot be persisted is not sent) and logs `Not voting for … already voted for … in round …` when a different block of the same round shows up. Unlike a one-signature-per-height rule, a validator may vote for a different block at the same height in a later round — that is what lets a split vote resolve instead of stalling. Two conflicting quorum certificates in one round need validators that vote twice; nodes log such votes as `Equivocation: …`. Re-broadcasting the same vote stays allowed. - During **emergency consensus** (`emergency_consensus_active = true`), `check_block_post_validation_chain()` returns immediately — LIB is advanced solely by the classic path to avoid deadlocking recovery. - The confirmation list is capped at **20 entries** (`CHAIN_MAX_BLOCK_POST_VALIDATION_COUNT`). Entries below the current LIB are pruned after every block. diff --git a/docs/node/configuration.md b/docs/node/configuration.md index 7a892d090e..98ff0d0ef5 100644 --- a/docs/node/configuration.md +++ b/docs/node/configuration.md @@ -197,6 +197,28 @@ logger.p2p.level = warn logger.p2p.appenders = p2p ``` +The `file` appender is configured to rotate by time (a new file every hour, kept for a +day), but the rotation only fires while the fc fiber queue is serviced, and `vizd` does not +service it after startup (measured 2026-10-20: a testnet node wrote one 37 MB file in nine +hours at `info`, with no hourly files; the mainnet node ran four days without a new one). +Do not count on it to bound a file, and be aware that at `debug` a node can write +gigabytes per hour. If all you need is the recent tail, use the optional capped appender: +a single file that keeps only the last `max_lines` lines (default 10000, `0` = unlimited). +It counts lines as it writes and compacts itself in place, so the file stays between +`max_lines` and about 1.1 x `max_lines`, with no timer involved. Nodes whose config has no +`log.capped_file_appender.*` section behave exactly as before, and the section itself is +safe to roll out ahead of the upgrade: an older binary ignores it and keeps logging to +stderr. If the configured path cannot be opened, the node still starts, prints one +`capped_file_appender: cannot open log file …` line to stderr, and simply leaves no file. + +```ini +log.capped_file_appender.node.filename = logs/node/node.log +log.capped_file_appender.node.max_lines = 10000 + +# Route whatever you want into it; the appender alone writes nothing. +logger.default.appenders = stderr, node +``` + --- ## Complete Reference diff --git a/docs/node/getting-started.md b/docs/node/getting-started.md index 50245fbc80..1a0eb98ffe 100644 --- a/docs/node/getting-started.md +++ b/docs/node/getting-started.md @@ -37,6 +37,7 @@ docker pull vizblockchain/vizd:latest ```bash docker run -d \ --name vizd \ + --restart unless-stopped \ -p 2001:2001 \ -p 8090:8090 \ -p 8091:8091 \ diff --git a/docs/plugins/node-admin.md b/docs/plugins/node-admin.md new file mode 100644 index 0000000000..da14286ea6 --- /dev/null +++ b/docs/plugins/node-admin.md @@ -0,0 +1,1171 @@ +# Node Admin API (`node_admin`) + +Administrative JSON-RPC endpoint for the node process itself: health, configuration, +plugin state, logs, the audit trail and P2P peer control. It is meant for operator tooling +and AI agents holding a bearer token — not for end users, and not as a replacement for the +public API. The same listener also serves an **MCP** (Model Context Protocol) endpoint over +HTTP and a small built-in web UI at `/ui/`. + +> **Mutating surface: P2P peers, snapshots, validator keys, webserver policy, `config.ini` +> and the process.** `admin.p2p.add_node`, `admin.p2p.add_trusted` and +> `admin.p2p.clear_banned` change whom the node dials; `admin.snapshot.create` and +> `admin.snapshot.restore` write a snapshot file or replace the node's in-memory state; +> `admin.validator.*` enable/disable validators and change their signing keys by signing +> on-chain transactions; `admin.webserver.*` mutating methods change rate limits, the stats +> switch and the IP allow/deny lists; `admin.config.*` and `admin.plugins.*` edit `config.ini` +> through a two-phase `plan`/`apply`; `admin.restart` asks the process to exit. They require a +> token with the `p2p`, `snapshot`, `chain`, `webserver`, `config`, `plugins` or `process` +> scope respectively. Peer actions are serialized onto the P2P thread; validator actions are +> signed by the `validator_guard` plugin and broadcast to the network; webserver policy +> changes apply to the very next request; config/plugin edits are read at startup and +> therefore need a restart. + +--- + +## Why a separate listener + +The public webserver API is normally published through nginx. Inside the node every such +request arrives **from 127.0.0.1**, so checking the source address on the public endpoint +protects nothing. `node_admin` therefore opens its own HTTP listener, bound to loopback by +default, that is never proxied. + +An endpoint on a non-loopback address is refused unless `admin-allow-remote = true` is set +explicitly. A bearer token on an open port is a different threat model, not a convenience. + +--- + +## Configuration + +```ini +plugin = node_admin + +admin-endpoint = 127.0.0.1:8099 +admin-token = agent:read:3f8c…(64 hex)…e1 +admin-audit-log = /var/lib/vizd/node-admin-audit.log +admin-log-file = node=/var/log/vizd/vizd.log +``` + +| Option | Default | Description | +|---|---|---| +| `admin-endpoint` | — | Listen address. Empty/absent: the plugin is inert. | +| `admin-token` | — | `label:scope[,scope]:sha256hex[:expires-unixtime]`. Repeatable. | +| `admin-allow-remote` | `false` | Permit a non-loopback `admin-endpoint`. | +| `admin-audit-log` | next to `config.ini` | Append-only audit log (0600, rotated at 16 MB). | +| `admin-log-file` | — | `name=path` entries that `admin.logs.tail` may read. Repeatable. | +| `admin-plan-ttl` | `120` | Seconds a `plan_id` stays valid before `apply` refuses it. | + +The plugin refuses to start if `admin-endpoint` is set but no token is configured: an +unauthenticated admin port is never a valid state. + +The Logs tab reads a **file**, not the container's stdout. Nodes that log only to stderr +inside Docker need an appender that writes the same lines to disk, and that file is what +`admin-log-file` names: + +```ini +[log.file_appender.node] +filename = /var/lib/vizd/logs/node.log + +[logger.default] +level = info +appenders = stderr,node + +admin-log-file = node=/var/lib/vizd/logs/node.log +``` + +Use the section form: flat `logger.default.*` keys are merged first and a later +`[logger.default]` section wins, so a flat line added on top of an existing section is +silently ignored. The file appender is configured to rotate hourly with one-day retention, +but that rotation does not actually happen in vizd: the chain runs on the fc fiber queue, +and the node's main thread lives in `boost/asio` after startup and never services it +(measured 2026-10-20 — one 37 MB file in nine hours, no hourly files). So the file is +bounded by neither time nor size; on `debug` it can reach gigabytes. For a hard limit use +the optional `capped_file` appender: one file holding only the last `max_lines` lines +(default 10000, `0` = unlimited). + +```ini +[log.capped_file_appender.node] +filename = /var/lib/vizd/logs/node.log +max_lines = 10000 +``` + +Point `admin-log-file` at the same path and the Logs tab reads it unchanged. The appender is +optional: nodes without the section, and older binaries that do not know the type, keep the +stock behaviour. + +### Tokens + +A token is 32 random bytes, for example `openssl rand -hex 32`. Only its SHA-256 is stored +in the configuration; comparison is constant-time. Scopes: + +* `read` — every method, including `admin.p2p.status` and the snapshot download route. Every token needs it. +* `p2p` — the mutating peer methods `admin.p2p.add_node`, `admin.p2p.add_trusted`, + `admin.p2p.clear_banned`. A token with only `p2p` (without `read`) cannot call anything. +* `snapshot` — `admin.snapshot.create` and `admin.snapshot.restore`. Listing and downloading + snapshots need only `read`. +* `webserver` — the mutating `admin.webserver.*` methods (limits, stats switch, IP lists, + reset, reload). `admin.webserver.status` and `admin.webserver.ips` need only `read`. +* `config` — `admin.config.plan` and `admin.config.apply` (editing allow-listed `config.ini` + keys). `admin.config.backups` needs only `read`. +* `plugins` — `admin.plugins.plan` and `admin.plugins.apply` (editing the `plugin =` list). +* `process` — `admin.restart` and `admin.objects.repair`. Both shut the process down; a + supervisor must start it again. Repair additionally leaves a marker that rewrites state at + the next startup, so the scope is state-write, not just availability. +* `chain` — `admin.validator.plan` and `admin.validator.apply` (enabling/disabling validators + and changing their signing keys). These calls are **only accepted while `validator_guard` + is loaded, started and configured with an active key** — that plugin holds the key that + signs the chain transaction, and it never exposes the key through this API. Without a live + guard, a `chain` token can call nothing: `admin.validator.list` stays readable with `read` + and reports why the mutating calls are unavailable. + +Revocation is editing the line and restarting. `expires-unixtime` (optional) makes the +token invalid after that moment without a restart. + +--- + +## Protocol + +`POST` with `Content-Type: application/json` and `Authorization: Bearer `. +Body ≤ 64 KB. Responses are JSON-RPC 2.0: + +```json +{"jsonrpc":"2.0","id":1,"result":{...}} +{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"forbidden", + "data":{"reason":"token 'agent' has no 'read' scope"}}} +``` + +| Code | Meaning | +|---|---| +| `-32700` | Parse error | +| `-32600` | Invalid request | +| `-32601` | Method not found | +| `-32602` | Invalid params | +| `-32001` | Unauthorized (HTTP 401) | +| `-32002` | Missing scope (HTTP 200) | +| `-32004` | Unknown/not allowed name (e.g. log file) | +| `-32005` | Rate limit: 20 requests/second per token (HTTP 429) | +| `-32099` | Internal error | + +Authentication is checked **before** the body is parsed. + +The non-`POST` routes are the built-in UI and snapshot downloads: + +* `GET /ui/` (and `GET /ui`) serves the built-in page described below. It carries no data and + no secret; every actual call the page makes is a normal authenticated `POST` back to `/`. +* `GET /snapshot/latest` returns the newest snapshot as a file attachment. `GET /snapshot/` + returns a named file from the snapshot directory. Both require `Authorization: Bearer …` + and the `read` scope — unlike `/ui/`, a snapshot is node state. `HEAD` works on both and + carries the same headers, including `Content-Length`. Any other method or path answers `405`. + +Download limits: the file must be a regular file directly in the snapshot directory (names +come from `admin.snapshot.status`; traversal is rejected), and files larger than **512 MB** +answer `413` — copy those with `scp` instead. + +--- + +## Methods + +### `admin.ping` + +```json +{"ok": true, "server_time": "2026-10-19T14:40:00Z"} +``` + +Liveness check; does not touch the chain database. + +### `admin.auth.me` + +No params. Returns the identity the presented token carries — its `label`, its `scopes` and +its `expires` (unixtime, `null` when the token does not expire). The token and its hash are +**never** returned. The built-in UI uses this to hide actions the token cannot perform; an +agent can use it to discover its own permissions before planning a call. + +```json +{"label":"ui-read","scopes":["read"],"expires":null} +``` + +### `admin.node_info` + +| Field | Meaning | +|---|---| +| `version`, `hardfork_count` | Build version and number of hardforks known to the binary | +| `chain_id` | Chain identifier | +| `head_block_num`, `head_block_id`, `head_block_time` | Current head | +| `head_age_sec` | Wall-clock age of the head block; `null` on an empty chain (head time there is 1970, so an "age" would be meaningless) | +| `last_irreversible_block_num` | Last irreversible block | +| `emergency_consensus_active`, `emergency_consensus_start_block` | Chain-wide emergency consensus state and the block that started it (`0` when inactive) | +| `emergency_key_configured` | The validator plugin has an emergency key loaded | +| `recovering` | Chain plugin is recovering shared memory | +| `uptime_sec` | Time since the admin listener started | +| `server_time` | UTC now | + +### `admin.diagnostics` + +Everything from `admin.node_info`, plus: + +| Field | Meaning | +|---|---| +| `data_dir`, `config_path` | Paths in use | +| `p2p_loaded`, `peers` | P2P plugin present, active connection count (read on the p2p thread) | +| `plugins_configured`, `plugins_observed` | `plugin =` entries in `config.ini`, and how many of those have their lifecycle state read directly (see `admin.plugins.list`) | +| `shared_memory_bytes` | Size of `shared_memory.bin` | +| `disk_total_bytes`, `disk_free_bytes` | Filesystem of `data_dir` (POSIX) | +| `rss_peak_kb` | Peak RSS (POSIX) | +| `loadavg` | 1/5/15-minute load average (POSIX) | + +Numbers are emitted as JSON numbers, not strings: fc's default formatter quotes int64 values +above 2^32-1 and every double, which would turn byte counts and load averages into text. + +### `admin.plugins.list` + +Lists the deduplicated plugins named by `plugin = …` lines in `config.ini`. A line may carry +several names (`plugin = chain p2p`), exactly as AppBase reads it, so each name gets its own +row. AppBase keeps its plugin +registry private (name-based lookup is protected and there is no enumeration API), so the +config file is the population this method can see; plugins enabled only on the command line +are not listed. + +Each entry carries `loaded`, `state` and `state_source`: + +* `state_source: "observed"` — the lifecycle state was read from the running plugin + (`chain`, `p2p`, `snapshot`, `webserver`, `validator`, `validator_guard`, `node_admin`). + `state` is one of `registered`, `initialized`, `started`, `stopped`; `loaded` is false only + while the plugin is still merely `registered`. +* `state_source: "inferred"` — AppBase initializes every `plugin =` line and aborts startup + when a name is unknown, so a configured plugin on a running node is loaded. `state` is + `null` rather than a guess. + +Two further fields exist for the operator panel: + +* `removable` — `false` for `chain` and `node_admin`, which `admin.plugins.plan` refuses to + remove anyway. A client must render those as non-toggleable, not as merely "off". +* `params` — the allow-listed keys that belong to this plugin (`p2p`, `webserver`, + `validator_guard`); an empty array means the plugin has no editable parameters and the + panel should say so instead of showing an empty form. Logging keys are grouped under + `logging`, which is not a plugin: they live in `admin.config.get`. +* `status_method` — the method that returns this plugin's **own** status (`admin.p2p.status`, + `admin.webserver.status`, `admin.snapshot.status`, `admin.validator.list` for + `validator_guard`), or `null` when the plugin exposes none. `null` is not an error: the + panel should say "no status" instead of drawing an empty drawer. + +The result also carries `known`: every plugin name the binary can load, which is the set +`admin.plugins.plan` validates `add` against. Configured names appear there too; a client +subtracts them to build the "add a plugin" selector. + +### `admin.plugins.status` + +`{"name":"p2p"}` returns the lifecycle state plus what the plugin is actually doing: + +| Field | Meaning | +|---|---| +| `configured` | A `plugin =` line for this name exists in `config.ini` | +| `state`, `state_source` | As in `admin.plugins.list`; `state` is `null` when the plugin type is not linked into this binary | +| `detail_available`, `detail_method` | Whether a dedicated status method exists and which one was called | +| `detail` | The verbatim result of that method (`admin.p2p.status`, `admin.webserver.status`, `admin.snapshot.status`, `admin.validator.list` for `validator_guard`) | +| `reason` | Why there is no detail: unobserved type, plugin not running, or no status API of its own | + +The detail method is called only for a **started** plugin: those handlers read +thread-owned state (peer map, connection counters) and would throw on a stopped one. + +A plugin whose type is not linked into this binary (`snapshot` on a build without it, say) +answers `state: null` with `reason` — not an empty object and not a fabricated "started". + +### `admin.objects.list` + +The chainbase object graph as the running process sees it: one row per index (object type), +its row count and the index revision, plus how much of the mapped file is used. + +```json +{"state_dir":"/var/lib/vizd/state","shared_memory_path":"/var/lib/vizd/state/shared_memory.bin", + "shared_memory_bytes":34183577600,"mapped_bytes":34183577600,"free_bytes":12648009654, + "revision":1234567,"head_block_num":1234567,"recovering":false, + "schema":{"file_present":true,"stored":15,"compiled":15,"match":true}, + "block_log":{"path":"/var/lib/vizd/state/block_log","present":true,"bytes":2147483648}, + "markers":[],"index_count":36,"total_objects":9876543, + "indexes":[{"type":"graphene::chain::account_object","type_id":7,"objects":812345, + "revision":1234567}], + "generated_at":"2026-10-20T13:40:00Z"} +``` + +Counts are read under the same weak read lock the chain API uses; if the block thread does +not yield in time the call returns the file facts plus `lock_error` instead of stalling. + +The block log is reported as a file or a directory — the chain plugin wipes it with +`remove_all`, so a directory is a legal shape there. + +`schema_version` and `block_log` live in the chain's state directory (`/state` by +default, next to `shared_memory.bin`), not in the application data dir: the chain plugin opens +the database with the state directory as its data dir. A log holding only chainbase's one-byte +placeholder counts as empty — `replay_available` is false and a `replay` repair is refused, +because it would reindex zero blocks. + +### `admin.objects.check` + +The same view, judged. `verdict` is `ok`, `warn`, `corrupted` or `unreadable`; the result +carries named `findings` (`severity`, `code`, `text`), counts, `replay_available` and +`recommended_repair` (`replay`, `resync` or `null`). + +| Code | Severity | What it means | +|---|---|---| +| `read_lock_timeout` | critical | Shared memory did not answer a read lock; the process is wedged | +| `state_dir_missing` / `shared_memory_missing` | critical | The node has no state directory or no image | +| `shared_memory_truncated` | critical | The file is smaller than the mapping: truncated or replaced under a running process | +| `shared_memory_larger_than_mapping` | warn | Something grew the file under this process — usually a second node on the same state dir | +| `stale_marker` | critical | `undo_all_in_progress`, `resize_in_progress`, `force_resync` or `force_replay` survived an interrupted operation | +| `schema_mismatch` | critical | `state/schema_version` is absent or differs from the binary's `CHAIN_SCHEMA_VERSION`; the next startup wipes and rebuilds | +| `revision_behind_head` | warn | Index revision differs from head; the next startup replays the gap. Normal after a crash | +| `shared_memory_exhausted` | critical | Less than 8 MiB free: the node cannot write another block | +| `shared_memory_low` | warn | Less than 5% free | +| `global_properties_count` / `validator_schedule_count` | critical | Exactly one row expected in each | +| `account_index_empty` | critical | Head is above zero but the account index is empty | +| `block_log_missing` | warn | A replay cannot rebuild state; only a full resync can | +| `recovery_in_progress` | warn | The chain plugin is running an automatic recovery | + +`corrupted` means hard evidence only (the criticals above); a revision mismatch after a +crash is `warn`, not corruption. `recommended_repair` is filled only when rewriting state can +fix the finding — exhausted shared memory is excluded, because the same size would fill again. + +**What the check cannot see.** Page-level corruption, checksums and chainbase's own +environment stamp can only be verified with the file closed, so this is a structural check of +a live process, not `fsck`. A clean verdict does not promise that a replay will succeed. + +### `admin.objects.repair` + +`{"mode":"replay"|"resync","confirm":"repair"}` (scope `process`) writes a marker next to +`shared_memory.bin`, replies, and quits after 300 ms — the same lifecycle as `admin.restart`. +The marker is honoured by the chain plugin at the next startup, before it maps shared memory: + +* `force_replay` — wipe state, keep the block log, reindex it from block 1. Cheaper than a + network resync and enough whenever the block log itself is intact. +* `force_resync` — wipe state **and** block log, come up empty and sync from peers. + +`confirm` must be exactly `repair` and the mode is re-validated against the files: `replay` +is refused with `-32000 no block log` when the log is absent or empty. If a `force_resync` +marker from the wedge watchdog is already present, the startup would perform the resync and +drop the replay request instead of replaying an empty log — so the handler clears the opposite +marker before writing its own, and names it in `removed_marker`. Without that step a confirmed +"replay (keeps blocks)" would silently drop the block log, because startup precedence is +`resync > replay`. A marker that cannot be cleared is a `-32000` refusal, not a promise the +node cannot keep. + +**Snapshot-mode nodes.** The two marker effects above describe a node that starts on the +normal path. A node started with `--replay-from-snapshot` never reaches it: the chain plugin +rebuilds state from the snapshot and replays the block log on top, so a `replay` request ends +up as a snapshot rebuild, and a `resync` request comes back from the snapshot instead of +genesis. The repair is still a full state rebuild — which is what an operator wants — but +the response says so instead of repeating the generic wording. `effect` is computed from the +options the node was actually started with, and `snapshot_replay` is `true` on such a node. + +The response carries `marker`, `removed_marker`, `effect` and `snapshot_replay`; the supervisor +must bring the node back (`--restart unless-stopped` / `Restart=always`), exactly as for a plain +restart. + +### `admin.config.get` + +Returns `config.ini` as ordered sections, preserving duplicate keys (needed for the +repeated `plugin` lines). **Values of secret-bearing keys are replaced with `***`**: +anything containing `private-key`, `private_key`, `password`, `secret`, `token`, `wif` or +`emergency`. + +```json +{"path":"/etc/vizd/config.ini","exists":true,"sections":[ + {"name":"","entries":[{"key":"plugin","value":"chain"}, + {"key":"private-key","value":"***"}]}]} +``` + +It also returns `allow`: the editable subset, ready to render. Each item is +`{key, group, section, type, value, defaulted, min, max, values, needs_restart}`. + +* `key` is the full address `admin.config.plan` accepts — `logger.default.level` for a key + inside `[logger.default]` — so a client never has to know which keys are sectioned. +* `value` is `null` and `defaulted` is `true` when the file does not set the key: the node + uses its code default, and the panel should show that instead of an empty field. + A value read from the file is returned **normalized** (`yes`/`on`/`1` → `true`), so it + matches the option list; `valid:false` means the line could not be normalized and should + be flagged. +* `type` is `boolean`, `unsigned_int` (with `min`/`max`) or `log_level` (with `values`). +* `group` is the plugin the key belongs to (`p2p`, `webserver`, `validator_guard`) or + `logging` for logger and appender settings. + +### `admin.logs.list` + +No params. Lists the files declared by `admin-log-file`, each with `name`, `path`, `exists` +and `size` (`null` when the file is missing). A configured appender that has not written yet +is still listed: "empty" and "not configured" are different facts. + +```json +{"logs":[{"name":"node","path":"/var/lib/vizd/logs/node.log","exists":true,"size":1048576}]} +``` + +### `admin.logs.tail` + +Params: `{"name":"node","bytes":16384,"lines":200}`. `name` must match an +`admin-log-file` entry; nothing outside that allow-list can be read. `bytes` is clamped to +64 KB and `lines` to 2000. WIF-looking strings and `key = value` secrets are masked. + +### `admin.audit.tail` + +Params: `{"lines":100}`. Returns the tail of the audit log. + +### `admin.p2p.status` + +No params. Requires only `read` (a token that may look can also look at peers). +Returns the same facts the node writes to its own log — `log_node_status()` every minute and +`log_peer_stats()` every five minutes — but on demand and in JSON: + +| Field | Meaning | +|---|---| +| `as_of` | UTC timestamp of the reading | +| `uptime_sec` | Node uptime | +| `node_status`, `fork_status` | `SYNC`/`FORWARD`, `NORMAL`/`LOOKING_RESOLUTION`/`MINORITY` | +| `paused`, `isolated_peers`, `catching_up` | Block processing paused, seed-only peer mode, catch-up after pause | +| `head_block_num`, `lib_block_num` | Our head and last irreversible block | +| `dlt_earliest`, `dlt_latest` | The DLT block range held locally (what this node can serve) | +| `connections`, `active`, `max_connections` | Open connections, of which fully active, and the limit | +| `known_peers`, `trusted_peers`, `banned_peers` | Known-peer set, operator-trusted endpoints, currently banned | +| `best_peer_head` | Highest head reported by any tracked peer | +| `best_peer_head_lag` | `our head − best peer head`; **negative means we are behind** | +| `peers_ahead` | Number of peers whose head is above ours | +| `peers[]` | Per-peer detail, sorted by endpoint | + +Each peer entry: `endpoint`, `node_id` (hex, empty before the hello), `lifecycle` +(`CONNECTING`/`HANDSHAKING`/`SYNCING`/`ACTIVE`/`DISCONNECTED`/`BANNED`), `node_status`, +`fork_status`, `incoming`, `trusted`, `exchange_enabled`, `fork_alignment`, `connected_sec`, +`peer_head`, `peer_lib`, `peer_dlt_earliest`, `peer_dlt_latest`, `head_lag` (ours − theirs), +`spam_strikes`, `ban_reason`, `ban_duration_sec`, `reconnect_backoff_sec`, +`next_reconnect_in_sec`. + +```json +{"jsonrpc":"2.0","id":40,"method":"admin.p2p.status"} +``` + +```json +{"jsonrpc":"2.0","id":40,"result":{ + "as_of":"2026-10-19T15:10:00Z","uptime_sec":86400, + "node_status":"FORWARD","fork_status":"NORMAL","paused":false, + "isolated_peers":false,"catching_up":false, + "head_block_num":22164,"lib_block_num":22150, + "dlt_earliest":1,"dlt_latest":22164, + "connections":3,"active":3,"max_connections":50, + "known_peers":12,"trusted_peers":1,"banned_peers":0, + "best_peer_head":22164,"best_peer_head_lag":0,"peers_ahead":0, + "peers":[{"endpoint":"1.2.3.4:1776","node_id":"02ab…","lifecycle":"ACTIVE", + "node_status":"FORWARD","fork_status":"NORMAL","incoming":false, + "trusted":true,"exchange_enabled":true,"fork_alignment":true, + "connected_sec":3600,"peer_head":22164,"peer_lib":22150, + "head_lag":0,"ban_reason":"","next_reconnect_in_sec":0}]}} +``` + +### `admin.p2p.add_node` + +Params: `{"endpoint":"1.2.3.4:1776"}` — IPv4 and port. Requires `read` **and** `p2p`. +Adds the endpoint to the known-peer set and dials it now (clearing any old backoff or ban on +that endpoint). The peer is not trusted. + +```json +{"endpoint":"1.2.3.4:1776","trusted":false,"status":"added"} +``` + +### `admin.p2p.add_trusted` + +Same params and response shape, `"trusted": true`. A trusted peer: + +* is **never soft-banned** — `soft_ban_peer()` disconnects it and retries after 30 s instead + of parking it for an hour; +* is **kept through the known-peer garbage collector**, which otherwise drops silent peers; +* is dialed immediately and re-dialed as needed. + +The trusted set lives in memory: it is **not persisted across restarts**. To pin a peer +permanently, keep it in `p2p-seed-node` (or in the seed list used together with +`p2p-isolated-peers`) in `config.ini`. + +### `admin.p2p.clear_banned` + +No params. Requires `read` and `p2p`. Clears every soft ban (lifecycle `BANNED` or a +non-empty `ban_reason`), resets the reconnect backoff and retries those peers immediately. + +```json +{"cleared":3,"status":"ok"} +``` + +Typical operator flow: + +```bash +# status first: what is wrong and with whom +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"admin.p2p.status"}' | jq '.result | {head_block_num, best_peer_head_lag, peers}' + +# add a peer and trust it +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"admin.p2p.add_trusted","params":{"endpoint":"1.2.3.4:1776"}}' + +# after a fork/ban storm: retry everyone +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":3,"method":"admin.p2p.clear_banned"}' +``` + +### `admin.snapshot.status` + +Params: `{"offset":0,"limit":20,"after":"snapshot-block-84360000.vizjson"}` — all optional. +Requires `read` and the `snapshot` plugin to be loaded. Returns one page of the snapshot files +in the configured `snapshot-dir`, newest block first, and reports what the plugin is doing. +`limit` is clamped to 200. + +Use `after` (the name of the last row you already have) instead of `offset` when paging a +directory that changes under the reader: a snapshot written between two pages shifts every +offset below it, so offsets repeat a row, and the retention sweep (24 h) shifts the other way +and makes offsets skip one. With `after` the page continues after that exact row. If the anchor +has already been pruned the reply carries `after_found: false` and falls back to `offset`. + +| Field | Meaning | +|---|---| +| `as_of` | UTC timestamp of the reading | +| `dir` | Snapshot directory in use | +| `head_block_num` | Current head; `null` while a restore is rebuilding state | +| `snapshot_count` | Number of files in the whole directory | +| `snapshot_total` | Same total, named explicitly for paging clients | +| `offset`, `limit` | The page that was returned | +| `after`, `after_found` | The cursor that was sent and whether it still exists | +| `has_more` | More files exist after this page | +| `in_progress`, `restoring`, `recovering` | A snapshot is being written, a restore is running, the chain plugin is recovering | +| `latest_path`, `latest_name` | Newest snapshot (highest block number); `latest_path` is an empty string and `latest_name` is `null` when there are none | +| `snapshots[]` | `name`, `path`, `block_num` (0 when the name does not carry a block), `size_bytes`, `modified` (unixtime), `modified_iso`, `latest` | + +```json +{"jsonrpc":"2.0","id":50,"method":"admin.snapshot.status","params":{"offset":0,"limit":20}} +``` + +```json +{"jsonrpc":"2.0","id":50,"result":{ + "as_of":"2026-10-19T15:30:00Z","dir":"/var/lib/vizd/snapshots", + "head_block_num":22164,"snapshot_count":1,"snapshot_total":1, + "offset":0,"limit":20,"has_more":false,"in_progress":false, + "restoring":false,"recovering":false, + "latest_path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "latest_name":"snapshot-block-22164.vizjson", + "snapshots":[{"name":"snapshot-block-22164.vizjson", + "path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "block_num":22164,"size_bytes":4404019, + "modified":1792423800,"modified_iso":"2026-10-19T15:30:00Z","latest":true}]}} +``` + +### `admin.snapshot.create` + +No params. Requires `read` and `snapshot`. Writes `snapshot-block-.vizjson` into +`snapshot-dir` **asynchronously** on the plugin's snapshot thread; P2P block processing is +paused for the duration of the database read and resumed before compression. Poll +`admin.snapshot.status` until `in_progress` is false. + +```json +{"jsonrpc":"2.0","id":51,"method":"admin.snapshot.create"} +``` + +```json +{"jsonrpc":"2.0","id":51,"result":{ + "status":"started","path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "note":"snapshot creation runs on the node's snapshot thread; poll admin.snapshot.status until in_progress is false"}} +``` + +`status` is `started`, `exists` (a file for this head is already on disk), or `busy` +(another creation/restore is running). On an empty chain (head `0`) the call fails with +`-32000` and writes nothing. + +### `admin.snapshot.restore` + +Params: `{}` (restore the latest), `{"latest":true}`, `{"name":"snapshot-block-22164.vizjson"}` +or `{"path":"/var/lib/vizd/snapshots/"}`. Requires `read` and `snapshot`. + +The restore is **in-process**, not a process restart: P2P block processing is paused, shared +memory is closed and wiped, the snapshot is imported, `dlt_block_log` is replayed, peers are +reset and P2P resumes. The OS process and its uptime are unchanged, so the listener keeps +answering during the operation (with `restoring: true` and a `null` head). `path`, if given, +must resolve to a regular file directly inside the snapshot directory. + +```json +{"jsonrpc":"2.0","id":52,"method":"admin.snapshot.restore","params":{"latest":true}} +``` + +```json +{"jsonrpc":"2.0","id":52,"result":{ + "status":"started","snapshot":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "snapshot_name":"snapshot-block-22164.vizjson","block_num":22164, + "process_restarted":false, + "note":"in-process restore: P2P is paused, shared memory is wiped, the snapshot is imported, dlt_block_log is replayed and P2P resumes. The OS process and its uptime are unchanged. Progress: admin.snapshot.status (restoring)."}} +``` + +A call that cannot start answers `-32000` (no snapshots, a creation is in progress, the file +is not readable); invalid names, traversal and files outside the snapshot directory answer +`-32602` before any state is touched. If the import itself fails, `do_snapshot_load` has +already wiped the database and requested shutdown — inspect the node log; the admin API does +not pretend the restore succeeded. + +--- + +## Validator key methods (`chain` scope) + +Decision q#3051=D: the operator manages validators through `node_admin`, but the **active +key never enters the admin API**. Transactions are built and signed inside the +`validator_guard` plugin, which is configured with the signing and active WIFs of each +validator: + +```ini +plugin = validator_guard +validator-guard-validator = ["val1", "5J…signing…", "5J…active…"] +validator-guard-validator = ["val2", "5J…signing…", "5J…active…"] +``` + +The guard must be loaded, started and hold at least one configured validator; otherwise the +mutating methods answer `-32002 forbidden / validator-guard is not configured`, and +`admin.validator.list` reports `guard.reason`. A configured validator must exist **on chain** +before it can be planned; creating a new validator is not offered here (it charges the +validator declaration fee and changes the set of block producers). + +### `admin.validators.active` + +No params, `read` only. Returns the names in the current validator schedule (duplicates from +the repeat factor are collapsed), the number of scheduled slots, the emergency consensus +state and whether the validator plugin is running in this process. **No keys are returned** — +this is public chain data. Use `admin.validator.list` for the validators this node actually +operates. + +```json +{"as_of":"2026-10-19T15:40:00Z","head_block_num":22164,"count":21, + "num_scheduled":21,"validators":["val1","val2"], + "emergency_consensus_active":false,"emergency_consensus_start_block":0, + "validator_plugin_loaded":true} +``` + +### `admin.validator.list` + +Scope `read`. No params. Returns: + +```json +{"guard":{"loaded":true,"started":true,"available":true}, + "validators":[{"name":"val1","on_chain":true,"enabled":true,"hold":false, + "active_authority":true,"scheduled_in":2, + "signing_key":"VIZ6…","configured_key":"VIZ6…"}]} +``` + +* `enabled` — the on-chain signing key is not null. +* `hold` — the operator disabled this validator through this API; the guard's auto-restore + is suppressed for it until an `enable`/`set_key` succeeds (see below). +* `active_authority` — the guard's active key is in the account's active authority. `false` + means a transaction signed by the guard will be rejected by the chain; the same warning is + logged at guard startup. +* `scheduled_in` — position (1-based) of the next 3 scheduled slots this validator produces, + `null` when it is not in them. + +### `admin.validator.plan` + +Scope `chain`. Params: `{"actions":[{"name":"val1","action":"disable"},…]}`. `action` is +`enable` (restore the guard's configured signing key), `disable` (null the signing key and +set the operator hold) or `set_key` (requires `signing_key`, a base58 public key). Every name +must be configured in the guard and exist on chain; duplicates are refused. + +The reply carries `plan_id`, `kind:"validators"`, `transactions` (how many chain +transactions the plan would send) and a `diff` per action: `from`/`to` signing keys, +`hold_from`/`hold_to`, `tx_needed`. An action that is already satisfied is omitted; a plan +with no effective change is refused with `-32602 no changes`. **Nothing is signed or +broadcast yet.** Plans are single-use, bound to the token label and expire after +`admin-plan-ttl`, exactly like config plans. Capturing the current key in the plan is what +makes a stale plan safe: if the chain moved in between, `apply` refuses that action instead +of overwriting somebody else's update. + +### `admin.validator.apply` + +Scope `chain`. Params: `{"plan_id":"…"}`. Signs one `validator_update` per changed action with +the guard's active key, puts it into the local pending pool and broadcasts it to peers. The +reply lists per-action `status`: `applied` (with `tx_id`), `already` (the chain reached the +desired state on its own) or `failed` (with `error`). `applied`, `unchanged` and `failed` +counts are given too. + +**The operator hold follows the outcome, never the intent.** A successful `disable` writes +the validator into `/validator-guard-holds.txt`; the guard's periodic auto-restore +skips held validators, and the file is re-read at startup, so a restart does not silently +re-enable it either (disable-on-shutdown does not create a hold — it is a normal shutdown +action). A successful `enable` or `set_key` removes the hold. A failed disable leaves no +latch: the validator stays as it was. + +Transactions are built under the chain's read lock and broadcast after it is released, the +same rule as every other chain write from this plugin. No restart is needed. + +--- + +## Webserver policy methods + +These methods manage the public API plugin: per-IP request counters, the stats log, rate +limits and the IP lists. Their semantics, defaults and log format are documented in +[Webserver — Request Accounting and Rate Limiting](./webserver.md#request-accounting-and-rate-limiting). + +**Changes are live.** The plugin keeps this state in memory; a change applies to the next +request the webserver receives, with no restart and no re-reading of `config.ini`. Every +change is also persisted atomically to `webserver-admin-state` +(`/webserver-admin.json` by default), which is loaded at startup and therefore +overrides the `config.ini` seed. The `reload` method re-reads that file for manual edits. + +| Method | Scope | Params | Purpose | +|--------|-------|--------|---------| +| `admin.webserver.status` | `read` | none | Limits, totals, IP lists and tracked-IP count. | +| `admin.webserver.ips` | `read` | `{"limit":200}` (max 5000) | Per-IP rows: `total`, `minute`, `hour`, `day`, `blocked`, `limited`, `last_seen`, `whitelisted`, `blacklisted`, sorted by total. | +| `admin.webserver.limits` | `webserver` | `{"enabled":bool,"per_minute":N,"per_hour":N,"per_day":N}` | Omitted fields keep their value. | +| `admin.webserver.stats` | `webserver` | `{"enabled":bool}` | Turn request accounting on/off. Limits are controlled separately. | +| `admin.webserver.whitelist_add` / `whitelist_remove` | `webserver` | `{"ip":"1.2.3.4"}` | Whitelisted addresses bypass limits but are still counted. | +| `admin.webserver.blacklist_add` / `blacklist_remove` | `webserver` | `{"ip":"1.2.3.4"}` | Blacklisted addresses get `403`; enforced even with stats off. | +| `admin.webserver.reset` | `webserver` | none | Zero the counters; keep lists and limits. | +| `admin.webserver.reload` | `webserver` | none | Re-read the state file from disk; returns the applied status. | + +Adding an IP to one list removes it from the other. An address must be a single IPv4 or IPv6 +literal; anything else answers `-32602`. + +```json +{"jsonrpc":"2.0","id":53,"method":"admin.webserver.limits","params":{"per_minute":600}} +``` + +```json +{"jsonrpc":"2.0","id":53,"result":{ + "status":"applied", + "limits":{"enabled":true,"per_minute":600,"per_hour":10000,"per_day":100000}, + "stats_enabled":true, + "note":"applied to the live webserver on the next request and persisted to its admin state file; config.ini is unchanged."}} +``` + +A node without the `webserver` plugin loaded answers `-32004 webserver unavailable`. The +public webserver rejects blacklisted clients with `403` and rate-limited clients with +`429` + `Retry-After: 60`; those responses are produced by the webserver, not by this API. + +--- + +## Config and plugin editing (two-phase) + +`config.ini` is edited through a **plan** that returns the exact diff and writes nothing, and +an **apply** that re-validates the file and replaces it atomically. This makes an agent's +intent reviewable before it touches a production node and keeps a crash mid-apply from +leaving a truncated file. + +### Plan semantics + +* `admin.config.plan {"set":{"key":value,…}}` validates every key against the **allow-list** + below, normalizes values (`yes/on/1` → `true`; integers are range-checked) and returns + `plan_id`, `kind:"config"`, `diff` (`[{key, from, to}]`), `config_path`, `needs_restart:true` + and `expires_at`. **Nothing is written.** A plan with no effective change is refused with + `-32602`. +* `admin.plugins.plan {"add":[…],"remove":[…]}` validates plugin names against the set the + binary can load, refuses duplicates, absent names and removals of `chain` and `node_admin`, + and returns `diff` of `[{op:"add"|"remove", name}]`. +* A plan is **single-use** (consumed on the first `apply`, successful or not), + **bound to the token label** that created it (`-32002` for any other token) and **expires** + after `admin-plan-ttl` seconds (`-32004 plan expired`). Up to 64 plans are kept; the + oldest is dropped when a new one arrives. `admin.plan.cancel` discards a plan explicitly. +* `apply` recomputes the SHA-256 of `config.ini` and refuses with `-32000 config changed … + nothing was written` if the file moved after the plan was made. It then normalizes and + re-validates every value again — an apply never trusts the plan blindly. + +### Atomic write and backups + +Both appliers edit the file as **raw lines**, so comments, ordering, spacing and CRLF are +preserved. A top-level key that is absent is inserted before the first `[section]`; an +existing top-level key is changed in place. An allow-listed key whose spec names a section +(see the logging row in the table below) is edited inside that section, and inserted +directly under its header when the section exists but the key does not. + +Each successful apply writes one backup next to the config — `.bak-` +(with a `-n` suffix on collision) — then a temp file in the same directory, `fsync`s it, +applies the original file's permissions, `rename`s it over `config.ini` and `fsync`s the +directory. A failed write leaves the original file untouched. Old backups are never pruned +by the node; they contain secrets (tokens, keys), so manage them like the config itself. + +`admin.config.backups` (scope `read`) lists `.bak-*`, newest first, with size and +mtime. Rolling back by hand is copying a backup back and restarting. + +### Allow-list + +The admin API edits only these keys. Everything else — identity, keys, data dir, endpoints, +`plugin`, `shared-file-size` — is deliberately out of reach of a bearer token. + +| Keys | Type | Range | +|---|---|---| +| `p2p-max-connections` | uint | 1–10000 | +| `dlt-peer-max-disconnect-hours` | uint | 1–8760 | +| `dlt-mempool-max-tx` | uint | 1–1000000 | +| `dlt-mempool-max-bytes` | uint | 1024–1073741824 | +| `dlt-mempool-max-tx-size` | uint | 256–67108864 | +| `dlt-mempool-max-expiration-hours` | uint | 1–720 | +| `dlt-peer-exchange-max-per-reply` | uint | 0–1000 | +| `dlt-peer-exchange-max-per-subnet` | uint | 0–1000 | +| `dlt-peer-exchange-min-uptime-sec` | uint | 0–86400 | +| `dlt-stats-interval-sec` | uint | 10–86400 | +| `p2p-isolated-peers`, `auto-resync-on-wedge` | bool | — | +| `webserver-thread-pool-size` | uint | 1–4096 | +| `webserver-cache-enabled` | bool | — | +| `webserver-cache-size` | uint | 0–10000000 | +| `webserver-stats-enabled`, `webserver-limits-enabled` | bool | — | +| `webserver-limit-m`, `webserver-limit-h`, `webserver-limit-d` | uint | 1–100000000 | +| `webserver-stats-log-max-size` | uint | 1024–1073741824 | +| `webserver-stats-log-keep` | uint | 1–1000 | +| `webserver-stats-interval` | uint | 1–86400 | +| `validator-guard-enabled`, `validator-guard-disable`, `validator-guard-disable-on-shutdown` | bool | — | +| `validator-guard-interval`, `validator-guard-shutdown-grace` | uint | 1–3600 | +| `validator-guard-disable-max` | uint | 0–1000 | +| `logger.default.level`, `logger.p2p.level` | log level | `all`, `debug`, `info`, `warn`, `error`, `off` | +| `log.capped_file_appender.node.max_lines` | uint | 0–10000000 | +| `log.capped_file_appender.node.flush` | bool | — | + +The logging keys are addressed with their section: `logger.default.level` edits `level` +inside `[logger.default]`, not a top-level option. A section that is absent from +`config.ini` is an error rather than an insert — the node never declares it, so the value +would be silently ignored at the next start. Appender filenames and rotation windows stay +operator-only: a typo'd path is a lost log, not a tunable. + +### Example + +```json +{"jsonrpc":"2.0","id":80,"method":"admin.config.plan", + "params":{"set":{"p2p-max-connections":200,"webserver-limit-m":600}}} + +{"jsonrpc":"2.0","id":80,"result":{ + "plan_id":"9f2c41a0d3b54e8770c5f1a2b3c4d5e6","kind":"config", + "config_path":"/var/lib/vizd/config.ini","needs_restart":true, + "diff":[{"key":"p2p-max-connections","from":"64","to":"200"}, + {"key":"webserver-limit-m","from":null,"to":"600"}], + "expires_at":"2026-10-19T14:42:00Z", + "hint":"nothing was written yet; apply with admin.config.apply {plan_id}…"}} +``` + +```json +{"jsonrpc":"2.0","id":81,"method":"admin.config.apply", + "params":{"plan_id":"9f2c41a0d3b54e8770c5f1a2b3c4d5e6"}} + +{"jsonrpc":"2.0","id":81,"result":{ + "applied":true,"kind":"config", + "config_path":"/var/lib/vizd/config.ini", + "backup":"/var/lib/vizd/config.ini.bak-1760884860", + "needs_restart":true, + "restart_hint":"config.ini is read at startup: call admin.restart {confirm:\"restart\"}…"}} +``` + +Plugin example: + +```json +{"jsonrpc":"2.0","method":"admin.plugins.plan", + "params":{"add":["block_info"],"remove":[]}} +{"jsonrpc":"2.0","method":"admin.plugins.apply","params":{"plan_id":"…"}} +``` + +Both appliers answer `-32004 config unavailable` when `config.ini` cannot be read, and +`-32000 write failed` when the atomic replace fails (the original file is left untouched). + +--- + +## Restart and supervision + +`admin.restart {"confirm":"restart"}` (scope `process`) asks `appbase` to quit: the handler +replies first, then a detached thread calls `quit()` after 300 ms so the HTTP response +reaches the client. The process exits with code 0. + +**The plugin cannot start the node again.** That is the supervisor's job — the container's +`--restart unless-stopped` or systemd `Restart=always`. With neither, the node simply stays +down; the reply says so in `hint`, and that is deliberate: an admin endpoint that forks a new +node process would be a privilege-escalation path out of the process's own lifecycle. + +Because `config.ini` and the plugin list are read at startup, `admin.config.apply` and +`admin.plugins.apply` take effect only after this restart. `confirm` must be exactly the word +`restart`; anything else is `-32602`. A second call while shutdown is pending answers +`-32000 restart already requested`. + +--- + +## MCP endpoint (Streamable HTTP) + +Decision q#3052=B: the MCP server lives **inside this plugin**, not in a sidecar. The same +loopback listener, the same bearer tokens and the same audit log serve the Model Context +Protocol over HTTP; there is nothing extra to install on the host. + +MCP requests are ordinary JSON-RPC 2.0 `POST`s to the admin endpoint: + +| Method | Purpose | +|---|---| +| `initialize` | Protocol handshake. Supported versions: `2024-11-05`, `2025-03-26`, `2025-06-18` (echoed when requested, otherwise `2025-06-18`). Advertises only the `tools` capability. | +| `notifications/initialized` | Notification (no `id`); answered with HTTP 202 and an empty body. | +| `ping` | MCP keepalive; returns `{}`. | +| `tools/list` | The tools below with JSON-Schema `inputSchema`. | +| `tools/call` | `{"name":"admin.node_info","arguments":{…}}` — executes the matching `admin.*` method. | + +Tools are 1:1 with the admin methods: `admin.ping`, `admin.auth.me`, `admin.node_info`, +`admin.diagnostics`, `admin.plugins.list`, `admin.plugins.status`, `admin.objects.list`, +`admin.objects.check`, `admin.objects.repair`, `admin.config.get`, `admin.logs.list`, +`admin.logs.tail`, `admin.audit.tail`, `admin.validators.active`, +`admin.p2p.status`, `admin.p2p.add_node`, `admin.p2p.add_trusted`, `admin.p2p.clear_banned`, +`admin.snapshot.status`, `admin.snapshot.create`, `admin.snapshot.restore`, +`admin.webserver.status`, `admin.webserver.ips`, `admin.webserver.limits`, +`admin.webserver.stats`, `admin.webserver.whitelist_add`, `admin.webserver.whitelist_remove`, +`admin.webserver.blacklist_add`, `admin.webserver.blacklist_remove`, `admin.webserver.reset`, +`admin.webserver.reload`, `admin.validator.list`, `admin.validator.plan`, +`admin.validator.apply`, `admin.config.plan`, `admin.config.apply`, `admin.config.backups`, +`admin.plugins.plan`, `admin.plugins.apply`, `admin.plan.cancel`, `admin.restart` +(forty-one). +The peer actions additionally require the `p2p` scope, the snapshot actions the `snapshot` +scope, validator operations the `chain` scope (and a live `validator_guard` with an active +key), the webserver actions the `webserver` scope, config/plugin edits the `config` or +`plugins` scope, and restart and state repair the `process` scope, exactly as over plain +JSON-RPC. Tool execution errors are returned **inside** the +result (`"isError": true` with the message in `content[0].text`), as MCP expects; protocol +errors (unknown tool, bad params, missing scope) remain JSON-RPC errors. + +```json +{"jsonrpc":"2.0","id":7,"result":{"tools":[ + {"name":"admin.node_info","description":"Chain id, head block, hardfork, uptime and head age.", + "inputSchema":{"type":"object","properties":{},"additionalProperties":false}}]}} +``` + +```json +{"jsonrpc":"2.0","id":8,"result":{ + "content":[{"type":"text","text":"{\"head_block_num\":1234,…}"}],"isError":false}} +``` + +The endpoint is sessionless: no `Mcp-Session-Id` is issued, and `GET` on anything except +`/ui/` and the `/snapshot/…` routes (the optional SSE stream included) answers `405`. Clients +that require SSE are not supported; JSON responses are. + +Client configuration (Streamable HTTP transport) points at the same loopback address: + +```json +{"mcpServers":{"viz-node":{"type":"http","url":"http://127.0.0.1:8099", + "headers":{"Authorization":"Bearer "}}}} +``` + +--- + +## Built-in UI (`/ui/`) + +A single static page, embedded in the binary, served by the same listener: + +```text +http://127.0.0.1:8099/ui/ +``` + +It is plain HTML + vanilla JS — no build step, no external assets, no frameworks. The CSP +allows only the page's own inline style/script and same-origin `fetch`; everything else is +`'none'`. + +The page derives its JSON-RPC base from its own URL, so it also works when an operator +publishes the listener behind a reverse-proxy prefix instead of the root: with nginx +`location /admin/ { proxy_pass http://127.0.0.1:9099/; }` the page is at `/admin/ui/` and +calls `/admin/`. At the root (`/ui/`) the base is `/`, as before. + +**Authentication.** The page holds no secret of its own. On first use the operator pastes a +bearer token; it is kept in `sessionStorage` (per tab, cleared when the tab closes) and sent +as `Authorization: Bearer …` on every JSON-RPC call back to the same listener. The page +itself is served unauthenticated on purpose: it is identical for everybody, contains no data, +and a browser cannot attach a bearer header to a navigation anyway. Tokens are never put in +the URL (that would land in history and access logs). + +**What it shows** + +The page is organised as seven tabs; the last one is remembered per tab session. + +* **Status** — node state, emergency consensus (`ACTIVE` with the starting block, or `normal`), + whether an emergency key is configured, head/LIB, head age, version, uptime, peer counts, + best peer head and our lag behind it, bans and flags. The snapshot tab's own state card + spells out `idle` ("no snapshot job is running") versus `creating` and `restoring`. +* **Peers** — a table of every tracked peer: direction, lifecycle, head, lag, node/fork + status, flags (`trusted`, `exchange`, `aligned`), connection age, ban/retry note. With the + `p2p` scope an action panel adds a peer by `IP:port`, adds it as trusted, or clears bans. +* **Snapshots** — state, directory, total count and the newest file; the newest **20** files + with **Load more** paging through `offset`/`limit`, block, size, age and per-row + **Download** / **Restore**, plus **Create snapshot** and **Restore latest**. A download + request disables its button and shows a radial progress spinner until the response body + arrives, so repeated clicks cannot start parallel downloads. Downloads use the + authenticated `/snapshot/` route and are saved by the browser under the file's own + name. +* **Logs** — a selector over `admin-log-file` entries, a **Play/Pause** control and a + scrolling pane. It starts **paused**: pressing Play polls `admin.logs.tail` every two + seconds and appends only the new lines at the bottom, colouring them by severity. When the + operator scrolls up, the pane stops auto-scrolling; scrolling back to the bottom resumes + it. +* **Plugins** — the plugins listed in `config.ini` with their observed state and how many + editable parameters each one has, a **Disable** button per row (`chain` and `node_admin` + show a disabled **protected** button instead) and a selector that adds a plugin the binary + can load. Every change goes through `plugins.plan` → a confirmation that spells out the + consequence → `plugins.apply`. Below the table, **Parameters** renders the allow-listed + keys grouped by plugin and **Logging** renders the logger and appender keys; each group + saves through `config.plan`/`config.apply`. Values that come from code (the key is absent + from `config.ini`) are marked `code default` rather than shown as blank. After any apply a + banner says the file changed and offers **Restart node**, which calls `admin.restart` after + two confirmations and works only with the `process` scope. A **Status** button on every row + whose plugin has a status API calls `admin.plugins.status` and shows the result verbatim + above the table; rows without one have no button, so the absence is visible rather than an + empty panel. +* **Objects** — the chainbase view: shared-memory path and size, mapped/free/reserved bytes, + index revision versus head, schema versions, block-log size, repair markers, and a table of + object types with row counts and per-index revision, largest first. **Check** runs + `admin.objects.check` and paints the verdict (`ok` / `warn` / `corrupted` / `unreadable`) + with each finding as `SEVERITY [code] text`; a recommended repair mode + preselects the selector. **Restart with repair** sends `admin.objects.repair` after two + confirmations that spell out the difference between replay (keeps blocks) and resync + (drops them), and needs the `process` scope. Like Plugins, this tab is outside the 5-second + timer so a pending verdict is not repainted under the operator. +* **Validators** — the names in the current validator schedule (public chain data, no keys), + the schedule size and the emergency state; below it, the validators this node operates via + `validator_guard`, when the plugin is live. + +**Scopes drive the page.** On load the page calls `admin.auth.me` and hides every action the +token cannot perform: without `snapshot` the Create/Restore buttons and the per-row Restore +disappear, without `p2p` the peer action panel disappears, without `plugins` the Disable/Add +controls disappear, without `config` the parameter inputs are locked, and without `process` +the Restart and Repair buttons are disabled. A read-only token therefore sees a +dashboard with listing and downloads only, and the page says so instead of offering buttons +that would be refused with `-32002`. + +The status view refreshes every five seconds while the tab is visible. + +**What it does not do.** No token management and no deletion of snapshot files. Writes are +the three peer actions, snapshot creation, the in-process restore, `config.ini` edits through +the plan/apply pair, a restart request and a state-repair request (which is a marker plus a +restart — the actual rebuild happens at the next startup, inside the chain plugin, not in the +admin plugin). It is a thin view over the same JSON-RPC contract documented above — anything it can +do, `curl` and an MCP client can do as well, and every call lands in the audit log under the +token's label (the page's own `GET /ui/` is audited with outcome `ui`, downloads as +`snapshot_download`). + +**Token for the page.** Use a dedicated token with the scopes you want the page to have; a +`read`-only token gives a live, read-only dashboard with snapshot download and listing, +adding `p2p` enables the peer action buttons, and adding `snapshot` enables **Create** and +**Restore**: + +```ini +admin-token = ui:read,snapshot,p2p: +``` + + +--- + +## Audit + +Every request — including failed authentication, parse errors and rate limiting — appends +one JSON line: + +```json +{"ts":"2026-10-19T14:40:00Z","rid":"17","token":"agent","method":"admin.node_info", + "params":{},"outcome":"ok","code":0,"ms":2,"remote":"127.0.0.1:51234"} +``` + +`token` is the label, never the token or its hash. `remote` is the real peer address of the +connection (not `X-Forwarded-For`: this listener is never behind a proxy, so that header +would let a client pick its own attribution). `params` are masked with the same redaction +rules as the log tail. The file is created `0600` and rotated to `.1` at 16 MB. + +--- + +## Security notes + +* The listener is loopback-only by default; do not put it behind nginx. +* Never send the token in a query string — it would land in proxy/access logs. +* Treat the token as a credential to the node process. Use one token per agent so the + audit log attributes calls correctly, and give each the smallest scope it needs. +* `admin.config.get` masks secrets by key name, not by value shape. A secret stored under + an innocuous key would be printed; do not invent such keys. +* The admin API never returns WIF keys. Besides the audit log, the methods that write to disk + are `admin.snapshot.create` (into the configured snapshot directory) and + `admin.config.*`/`admin.plugins.*` apply (the config plus one `.bak-*` per apply). +* `admin.snapshot.restore` **replaces all chain state** with the chosen file. It pauses block + processing and wipes shared memory; a wrong or stale snapshot is destructive. Treat the + `snapshot` scope as equivalent to write access to the node's state, and prefer a snapshot + taken at or below the peers' LIB when the node is expected to re-sync. +* Snapshot downloads are served by the admin listener only, never by the public webserver. + Do not copy them to a world-readable path; a snapshot contains full chain state and, + depending on the build, account metadata. +* `GET /ui/` is unauthenticated by design and is the same bytes for every caller. It grants + no access: without a token every call it makes gets `401`. It does not answer `OPTIONS` + and sends no CORS headers, so another origin cannot drive it through a browser. +* Peer actions are in-memory. `add_trusted` and `add_node` survive until the process + restarts; permanent pins belong in the P2P seed configuration, not in the admin API. +* The `webserver` scope can change the availability of the public API (a too-strict limit or + a wrong blacklist entry rejects legitimate clients). A token with it is a policy + credential, not just a read credential; changes also persist across restarts through + `webserver-admin-state`. +* The `config` and `plugins` scopes edit `config.ini` on disk. The allow-list keeps identity, + keys, endpoints and `plugin` out of reach, but the keys that are editable still change node + behaviour at the next restart. `apply` re-checks the file hash, so an edit made by someone + else after the plan is never silently overwritten; the backup is the rollback path. +* `admin.config.backups` lists files that contain secrets (tokens, `private-key` lines). They + are created with the original file's permissions; do not copy them to a world-readable path. +* The `process` scope stops the node. Without a supervisor the node does not come back; the + admin API deliberately cannot restart itself outside the process lifecycle. +* `admin.objects.repair` is a write credential disguised as a maintenance action. + `mode=replay` discards all state and rebuilds it from the block log; + `mode=resync` **also deletes the block log**, and the node can then only resync from peers. + Run `admin.objects.check` first, and treat the scope as equivalent to deleting the state + directory by hand. + +--- + +## Related + +* [Plugin overview](./overview.md) +* [Webserver](./webserver.md) — the public API this plugin deliberately does not use + +--- + +## Tests + +`plugins/node_admin/tests/acceptance.sh` starts a throwaway node, drives the listener with +curl and checks this contract: authentication and scopes, config/log masking, the log +allow-list, status codes, audit, rate limit, P2P peer actions (including the `p2p`-scope +denial), snapshot status/listing/download (including `HEAD`, traversal and missing-file +cases, and the `snapshot`-scope denial on create/restore), per-plugin status (a started +plugin with its own view, an unlinked type answering `state: null` with a reason, a started +plugin without a status API, and the missing-name refusal), chainbase objects (index count +matches the array, largest first, schema matches the binary, a clean verdict on a fresh node, +and the repair refusals — read scope, missing `confirm`, unknown mode, and `replay` with an +empty block log, which must leave no marker behind), MCP +`initialize`/`tools/list`/`tools/call`, webserver stats/limits (burst → `429`, whitelist +bypass, blacklist `403`, hot limits change, persistence and reload), config/plugin +plan-and-apply (allow-list, scope and token binding, single use, expiry, stale-hash refusal, +backups, plugin protection), validator key operations (the list stays readable and names the +missing plugin, `plan` is refused without `chain` and again without a live guard; after the +restart the guard is loaded and started but has no active-key validator, so `plan` stays +refused), restart (`confirm` guard, scope guard, clean exit 0 and a +supervisor-style start that reads the edited config back), and the `/ui/` page. It +starts the webserver plugin on a third port and needs a built `vizd` and three free ports, +so it is not part of CTest. + +The signing path itself cannot run in that script: a fresh chain has no second validator, and +the genesis `committee` account has an empty active authority. It is exercised by +`plugins/node_admin/tests/validator-e2e.sh`, which builds its own scratch chain (a temp dir and +three ports; `--keep` leaves it behind for inspection). It stages a testnet one-slot chain +through HF9/HF12 — `viz` is registered, vested and voted in the first blocks, because votes weigh +vesting shares, HF9 deletes validators that never signed a block and HF12 excludes `committee` +from selection — then creates two more validators: `backup` carries the operator actions and +`cold` lets a batch plan name two different validators. Checks: list (`on_chain`, `enabled`, +`active_authority`), disable/enable/set-key with a transaction id, the hold file surviving a node +restart, a stale plan refused with `chain state changed` and nothing signed, a two-action plan +applying both actions, the producing validator rotating its own signing key, and the contract +corners (duplicate, unknown, bad key, no-op → `-32602`). + +Two staging decisions are load-bearing. Operator actions never target the only *scheduled* +validator: the schedule is rebuilt only at the end of an applied block, so nulling its key leaves +a stale schedule pointing at a keyless validator and bricks a one-slot chain. The self-rotation +section is a regression check for a fixed node bug: a block header is validated against the key +committed at head, so the block that carries the update must be signed with the old key and the +new key takes effect for the next block. The producer used to read the signing key from the +session-visible validator object, so a pending `validator_update` that rotated the producing +validator's key made it sign with the new key, `validate_block_header` rejected the block, the +pending pool was cleared and the update was silently lost. The plugin now keeps a committed-key +snapshot (seeded at startup, refreshed from applied block contents) and production signs with +that key. + +`tests/node_admin/` holds two CTest targets (Boost.Test). `node_admin_util` covers the pure +helpers: secret masking, token parsing, the config parser, the allow-list and normalization, and +the raw-line editors (spacing, CRLF, sections, plugin add/remove guards). + +`node_admin_objects` covers the verdict branches of `admin.objects.check` in +`objects_check.hpp`, which is deliberately free of fc and chainbase so every arm can be driven +by a hand-built state: healthy, crash-lag (warn, not corrupted), truncation (replay), no block +log (resync), exhaustion alone (no repair offered — the same size fills again), exhaustion next +to another critical (replay), stale markers, schema rules, core index cardinality above and at +genesis, the 5% free-space boundary, a wedged read lock (unreadable, and no repair on the +strength of a lock that was never answered), and recovery/block-log warnings. The judge is what +invites an operator to wipe state, so a false `corrupted` and a false `ok` are both defects and +both have a test. diff --git a/docs/plugins/overview.md b/docs/plugins/overview.md index 4cf910b15f..fd669caece 100644 --- a/docs/plugins/overview.md +++ b/docs/plugins/overview.md @@ -35,6 +35,7 @@ VIZ Ledger uses the **AppBase** plugin framework. Each plugin has a lifecycle (` | `snapshot` | Recommended | `chain` | — | | `block_archive` | Optional | `chain` | — | | `validator_guard` | Recommended for validators | `chain`, `p2p` | — | +| `node_admin` | Optional (operator/AI agent) | `chain` | Its own loopback listener | ### API diff --git a/docs/plugins/prediction-market-api.md b/docs/plugins/prediction-market-api.md index 6942017088..c208fe5b50 100644 --- a/docs/plugins/prediction-market-api.md +++ b/docs/plugins/prediction-market-api.md @@ -75,13 +75,15 @@ build its filter chips without hard-coding a taxonomy. The meta object: | `get_account_leverage_positions` | `account, from, limit` | `pm_leverage_position_object[]` | | `get_market_leverage_positions` | `market_id, from, limit` | `pm_leverage_position_object[]` | | `get_creator_ban` | `account` | `pm_creator_ban_object` (or error if none) | -| `get_oracle` | `owner` | `pm_oracle` (object + `reliability_score` + workload/latency reads) | +| `get_oracle` | `owner` | `pm_oracle` (object + `reliability_score` + workload/latency/no-contest reads) | | `list_oracles` | `from, limit` | `pm_oracle_object[]` | | `list_markets_in_dispute_window` | `oracle, from, limit` | `pm_market_card[]` | | `list_oracle_disputes` | `oracle, from, limit` | `pm_oracle_dispute[]` (computed) | `list_markets_in_dispute_window` drills into the `markets_in_dispute_window` gauge: this oracle's resolved (`status 3`, `payout_status 1`) markets that still carry **no dispute row** — i.e. the ones a challenger could still dispute within `pm_dispute_grace_sec` of the announcement. `list_oracle_disputes` drills into the two dispute gauges: this oracle's currently **open** disputes (`status 0`), each tagged with a `stage` — `awaiting_response` (the oracle has not answered yet) or `awaiting_decision` (answered, now with the resolver/committee) — derived from `oracle_response_time`, plus a `market_card` for rendering. Both walk only the oracle's own set (`by_oracle_status` / `by_auto_close`), so they stay O(this oracle), not a global scan. They pair with the stored gauges on `get_oracle` (below): the gauge is the count, these methods are the list. +Every `pm_market_card` (here and in the market list/detail methods) carries **`settlement_charge`**: the no-contest penalty settlement would charge right now — the fee frozen at announcement under HF16 (`no_contest_fee_frozen`) or the current median for pre-fork rows, capped by the oracle's insurance — and `0` unless the market is void-announced (`status 3`, payout pending, no dispute filed) with stake on it. `get_oracle` aggregates the same computation for the whole oracle: `no_contest_pending_count` / `_stake` / `_fee_nominal` / `_fee_capped`. Both are display-only reads; the charge itself happens in consensus at settlement. + ### Leverage previews (Boost) Read-only quotes that call the **same in-node margin math** the evaluators use, so a preview matches what the corresponding `pm_leverage_*` op would compute at the head block. They are non-consensus estimates (reserves move between the read and the broadcast — always send the on-chain slippage guards). @@ -109,7 +111,7 @@ Read-only quotes that call the **same in-node margin math** the evaluators use, | `get_lazy_deposit` | `account` | `pm_lazy_deposit_object` | | `get_lazy_allocations` | `from, limit` | `pm_lazy_allocation_object[]` | | `get_market_lazy_allocation` | `market_id` | `pm_lazy_allocation_object` (or error if none) | -| `get_pm_chain_properties` | — | `chain_properties_pm` (median, v5) | +| `get_pm_chain_properties` | — | `chain_properties_pm` (median, v5) + `pm_no_contest_fee_per_market` (derived) | `get_lazy_allocations` lists the lazy pool's per-market allocation records (for a pool dashboard); `get_market_lazy_allocation` fetches the one for a given market. Oracle penalty stamps need no separate method — they ship on `pm_oracle_object` (`penalty_stamps`, `last_penalty_stamp_time`) via `get_oracle`. @@ -159,7 +161,7 @@ These wrap raw objects with values derived at read time (non-consensus). ``` Each ratio defaults to a full `10000` until the oracle has the relevant history (optimistic when unproven). `timely` is the P5 lateness signal: a late-but-delivered resolve still counts as `markets_resolved` (so it earns full `accuracy`), and `timely` is what separates a chronically-late oracle from a punctual one. `avg_resolution_time` is deliberately **not** scored — it measures latency from betting close, not deadline overrun, and can't be normalized without the market length. Weights are non-consensus; tune freely. - **Workload gauges** (stored on `pm_oracle_object`, O(1)-maintained across the op/cron transitions, seeded once on upgrade): `markets_in_dispute_window`, `disputes_awaiting_response`, `disputes_awaiting_decision` — the live counts an Oracle Console badges; drill into the lists with `list_markets_in_dispute_window` / `list_oracle_disputes`. -- **Computed-on-read** (time-dependent, so not stored): `markets_awaiting_resolution` + `oldest_unresolved_age` (this oracle's `status 1` markets past betting close, and the age of the oldest — one `by_oracle_status` walk), and `resolution_time_p50` / `resolution_time_p95` (percentiles read off the oracle's 8-bucket latency histogram `resolution_time_hist`, upper bucket edge where the cumulative count first crosses the percentile). +- **Computed-on-read** (time-dependent, so not stored): `markets_awaiting_resolution` + `oldest_unresolved_age` (this oracle's `status 1` markets past betting close, and the age of the oldest — one `by_oracle_status` walk), `resolution_time_p50` / `resolution_time_p95` (percentiles read off the oracle's 8-bucket latency histogram `resolution_time_hist`, upper bucket edge where the cumulative count first crosses the percentile), and `no_contest_pending_count` / `no_contest_pending_stake` / `no_contest_fee_nominal` / `no_contest_fee_capped` — this oracle's void-announced markets still inside the dispute grace with stake (one `by_oracle_finalized` walk over the open obligations; the oracle's resolved history is not read). The price per market is the HF16 frozen fee when the row has one, otherwise the legacy median computation — the same branch `settle_market(win < 0)` takes; `_capped` is `min(nominal, insurance)` because settlement caps each market. **`pm_market_weight_sums`** — per side/outcome `bets_sum` and `weight_sum` (weight sums are computed by scanning bets, since they are not stored): ``` diff --git a/docs/plugins/webserver.md b/docs/plugins/webserver.md index 06dc5d4f99..fa24e449ff 100644 --- a/docs/plugins/webserver.md +++ b/docs/plugins/webserver.md @@ -23,9 +23,21 @@ json_rpc::plugin | `webserver-thread-pool-size` | `256` | Worker threads for handling HTTP and WebSocket requests. | | `webserver-cache-enabled` | `true` | Enable the response cache. | | `webserver-cache-size` | `10000` | Maximum number of cached responses. Cache is evicted entirely when this limit is reached. | +| `webserver-stats-enabled` | `false` | Enable per-IP request accounting, the stats log, and (unless overridden) the rate limits. | +| `webserver-limits-enabled` | value of `webserver-stats-enabled` | Enforce the per-IP rate limits. Limits need the counters from `webserver-stats-enabled`. | +| `webserver-limit-m` | `1000` | Maximum requests per IP per minute. | +| `webserver-limit-h` | `10000` | Maximum requests per IP per hour. | +| `webserver-limit-d` | `100000` | Maximum requests per IP per day. | +| `webserver-stats-log` | `/webserver-stats.log` | Stats log path; rotated by size. | +| `webserver-stats-log-max-size` | `10485760` (10 MiB) | Rotate the stats log when it exceeds this size. | +| `webserver-stats-log-keep` | `5` | Number of rotated files (`.1` … `.N`) to keep. | +| `webserver-stats-interval` | `60` | Seconds between snapshot lines written to the stats log. | +| `webserver-admin-state` | `/webserver-admin.json` | Live state (limits, IP lists, stats switch) persisted by `node_admin`. | At least one of `webserver-http-endpoint` or `webserver-ws-endpoint` must be set for the plugin to do anything. Both can be enabled simultaneously. +The stats and limit options are the **startup seed** only. Once the node is running, the live values are owned by the plugin and can be changed through `node_admin` (see [Managing stats and limits at runtime](#managing-stats-and-limits-at-runtime)); every change is applied to the next request and persisted to `webserver-admin-state`. + --- ## Minimal config.ini @@ -79,6 +91,70 @@ HTTP and WebSocket servers each run on a dedicated `io_service` instance. Incomi --- +## Request Accounting and Rate Limiting + +Off by default. Set `webserver-stats-enabled = true` to count requests per client IP and enable the limits (or set `webserver-limits-enabled` explicitly to decouple the two). + +### Limits + +| Window | Default | +|--------|---------| +| minute | 1000 requests | +| hour | 10000 requests | +| day | 100000 requests | + +The windows are fixed (the minute bucket resets at the minute boundary, the hour at the hour, the day at midnight UTC), and every request is checked against all three. A request over any limit is rejected: + +- HTTP: `429 Too Many Requests`, `Retry-After: 60`, body `rate limit exceeded`. +- WebSocket: JSON-RPC error `-32006 rate limit exceeded`. + +This is deliberate protection against scraping and parser traffic: consumers that need more throughput should run their own node. + +### Lists + +- **Whitelist** — addresses that bypass the limits. They are still counted and still appear in the stats. +- **Blacklist** — addresses that are rejected with `403 Forbidden` (HTTP) or `-32005 forbidden` (WebSocket). The blacklist wins over the whitelist. It is enforced even when stats are disabled. +- Adding an address to one list removes it from the other. + +### What the stats log contains + +Every `webserver-stats-interval` seconds the plugin appends one line per tracked IP plus a summary: + +```text +2026-10-19T12:00:00Z ip=203.0.113.7 total=4210 minute=320 hour=4210 day=4210 blocked=0 limited=3 whitelisted=0 blacklisted=0 last_seen=1760875200 +2026-10-19T12:00:00Z summary total_requests=4210 blocked_requests=0 limited_requests=3 tracked_ips=1 +``` + +The file is rotated by size: `` becomes `.1`, older files shift to `.2` … `.webserver-stats-log-keep`, and the oldest is deleted. + +### Client address + +The plugin uses the TCP peer address of the connection. Put behind nginx, every request appears to come from the proxy (`127.0.0.1`), so per-IP limits become global limits and the per-IP report shows a single row. Either expose the API directly, whitelist the proxy and rate-limit at the proxy layer (`limit_req`), or enable PROXY protocol support at the proxy/network layer. + +### Managing stats and limits at runtime + +The `webserver` scope on a `node_admin` token controls the mutating methods; reads only need `read`: + +| Method | Purpose | +|--------|---------| +| `admin.webserver.status` | Limits, totals, IP lists, tracked IP count | +| `admin.webserver.ips` | Per-IP counters (`limit` up to 5000), sorted by request count | +| `admin.webserver.limits` | Change `enabled`, `per_minute`, `per_hour`, `per_day` (omitted fields keep their value) | +| `admin.webserver.stats` | Turn accounting on/off (`{"enabled": true|false}`) | +| `admin.webserver.whitelist_add` / `whitelist_remove` | Edit the whitelist (`{"ip": "..."}`) | +| `admin.webserver.blacklist_add` / `blacklist_remove` | Edit the blacklist (`{"ip": "..."}`) | +| `admin.webserver.reset` | Zero the counters, keep the lists and limits | +| `admin.webserver.reload` | Re-read `webserver-admin-state` from disk | + +Each of these is also exposed as an MCP tool of the same name. **Changes are applied to the live plugin immediately — no restart and no re-reading of `config.ini`.** They are written to `webserver-admin-state` (atomic temp-file + rename), which is loaded at the next startup and therefore overrides the `config.ini` seed. `config.ini` is never rewritten. `reload` exists for operators who edited the JSON file by hand; normal changes do not need it. + +```json +{"jsonrpc":"2.0","id":1,"method":"admin.webserver.limits", + "params":{"per_minute":600,"per_hour":8000}} +``` + +--- + ## WebSocket Subscriptions WebSocket clients can register callbacks: @@ -117,7 +193,7 @@ This allows browser-based wallets and dApps to call the JSON-RPC endpoint direct ## Security - **Bind to localhost** (`127.0.0.1`) and use a reverse proxy (nginx/Caddy) for public exposure. Binding to `0.0.0.0` exposes the RPC directly to the network. -- The plugin has no built-in authentication or rate limiting. Apply those at the reverse proxy layer. +- The plugin has no built-in authentication. It has optional per-IP rate limiting and IP lists (see [Request Accounting and Rate Limiting](#request-accounting-and-rate-limiting)), but those are blunt controls — keep authentication and coarse filtering at the reverse proxy layer. - Mutating methods (`network_broadcast_api`) are blocked from cache poisoning by design, but they remain callable from any connected client — restrict access at the network level if needed. --- diff --git a/docs/prediction-markets/pm-no-contest-fee-freeze.md b/docs/prediction-markets/pm-no-contest-fee-freeze.md new file mode 100644 index 0000000000..5706de68ee --- /dev/null +++ b/docs/prediction-markets/pm-no-contest-fee-freeze.md @@ -0,0 +1,153 @@ +# HF16 — no-contest fee freeze: activation checklist and backfill semantics + +Status: **implemented, not scheduled.** The fork is registered in both configs +(`CHAIN_NUM_HARDFORKS = 16`, `CHAIN_VERSION = 4.2.0`) and gated by its activation time plus the +validator quorum: a production binary applies it once that time is reached, and nothing runs before +then. This note is the operator-facing checklist — what is frozen, how the two states on a market are +distinguished, what changes in the object layout and the snapshot, how to verify the activation, and +why no seed pass is needed for rows that are already inside the dispute window when the fork lands. + +## 1. What the fork changes + +**The problem.** `pm_no_contest` only declares the void and opens the `pm_dispute_grace_sec` window; +the penalty is computed at **settlement**. Before HF16 the amount was +`floor(pm_dispute_fee × pm_no_contest_penalty_percent / 10000)` read from the median +`chain_properties_pm` **at settlement time**. Both inputs are median-voted, so a parameter vote +inside the dispute window changed the bill after the oracle had already announced the void: the +oracle announced one number and paid another, with no way to know the final price at announcement. + +**The fix: freeze the number at announcement.** `pm_no_contest_evaluator` computes the same formula +from the median of the **announcing block** and stores it on the market as +`pm_market_object::no_contest_fee`. `settle_market(win < 0)` charges exactly that amount instead of +re-reading the median. The insurance cap is unchanged and still applies per settlement: +`penalty = min(frozen_fee, oracle.insurance)`. + +**Two states, one flag.** `pm_market_object::no_contest_fee_frozen` says whether the amount was +frozen at all: + +* `true` — the void was announced **under HF16**; settlement charges `no_contest_fee` verbatim. +* `false` — the row was announced **before HF16** (or imported from a pre-HF16 snapshot); settlement + keeps the legacy median computation, exactly the pre-fork behaviour. + +The flag is separate from the amount on purpose. A frozen fee of **0** (the penalty percent was voted +to 0 at announcement) must stay 0 even if a later vote raises the percent — "frozen zero" and "never +frozen" are different states, and inferring legacy-ness from `fee == 0` would silently re-price the +former. A market announced under HF16 with no bets also records `frozen = true, fee = 0`; that is +inert, because settlement only charges when `settlement.stake_total > 0`. + +**Attribution counter.** `pm_oracle_object::no_contest_slashed_total` is a monotone total of +no-contest penalties charged to the oracle, incremented in the **same** `modify()` that grows +`total_insurance_slashed`. It gives the no-contest class exact attribution without a virtual +operation, and it accumulates on both paths (a legacy row that settles after activation is still a +no-contest slash). It is forward-accumulating from HF16: penalties charged before the fork are **not** +backfilled — the aggregate delta is the only surviving record, and subtracting it later would be a +guess. Display/attribution only; consensus never reads it. + +**Object layout.** Both fields are **appended at the end** of their structs and to the corresponding +`FC_REFLECT`: + +* `pm_market_object`: `share_type no_contest_fee; bool no_contest_fee_frozen = false;` +* `pm_oracle_object`: `share_type no_contest_slashed_total;` + +Snapshot import is `contains()`-guarded for all three, so a pre-HF16 snapshot imports with defaults +(`0` / `false`) — which is precisely the "legacy row" state the settlement expects. Snapshot export is +generic (`EXPORT_INDEX`) and needs no change. `apply_hardfork` has no case for HF16 (`default: break` +is correct): there is no singleton to create and nothing to recompute on activation. + +**What ships without the fork.** These read-only additions are useful before activation and are not +gated — they derive from the same state the settlement will read: + +* `get_oracle` (and the oracle block in `get_market_full`) gains `no_contest_pending_count`, + `no_contest_pending_stake`, `no_contest_fee_nominal`, `no_contest_fee_capped` — the price of the + voids this oracle has announced and that are still inside the dispute window (status 3, payout + pending, no dispute filed) and carry stake. Nominal and insurance-capped figures are both reported, + because a cap hit would otherwise hide the nominal cost. Computed on read, per-block cached. +* The market card gains `settlement_charge`: the amount that would be slashed if this market settled + as a void **now** (0 for anything that is not a pending no-contest with stake). +* `get_pm_chain_properties` keeps every existing field and adds + `pm_no_contest_fee_per_market = dispute_fee × penalty_percent / 10000` — the nominal unit clients + and scripts otherwise recompute by hand. The RPC now returns a variant; JSON clients see the same + object plus one key. + +## 2. How the fork is registered and activated + +* **Registration is unconditional in both configs.** `0-preamble.hf` sets `CHAIN_NUM_HARDFORKS` to 16; + `hardfork.d/16.hf` defines `CHAIN_HARDFORK_16` / `CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK` and + `CHAIN_HARDFORK_16_VERSION`; `database_hardfork.cpp` registers `_hardfork_times[16]` / + `_hardfork_versions[16]` for every build. The arrays are sized `[CHAIN_NUM_HARDFORKS + 1]`. What gates + the fork is the timestamp plus quorum, not the build flavour — the same rule as HF15, and for the + same reason: the shelter testnet is a **production-config** deployment, so a testnet-only fork could + never activate there. See `pm-audit-fix-upgrade.md` §2 for the full argument. +* **Activation: 2026-10-31 00:00:00 UTC** (`CHAIN_HARDFORK_16_TIME` = 1793404800 in both configs, set + by the owner on 2026-10-20). The validator announcement must name this moment, and 17 of 21 + validators must run the 4.2.0 binary before it. Keep the value in the **future**: + a past timestamp activates the fork in whatever block quorum is reached, with no announceable + moment. Change it together with the announcement text, never after it is published. +* **Version, not revision.** `CHAIN_VERSION` moves to `4.2.0` in **both** `config.hpp` and + `config_testnet.hpp`; the middle component is the hardfork version the validators vote, and + `database_hardfork.cpp` asserts it equals `_hardfork_versions[CHAIN_NUM_HARDFORKS]`. (The two configs + previously carried 4.1.2 and 4.1.0 — both move to the same 4.2.0.) +* **Activation checklist.** (1) Confirm or replace the provisional date and announce it. (2) Measure + the population in §4 on a fresh snapshot and settle the question there (expected answer: no action). + (3) Ship the image; validator votes are automatic. (4) Confirm activation from the node log and run + the checks in §3. (5) Note the emergency-consensus trap from `pm-audit-fix-upgrade.md` §3: on a chain + in emergency consensus no fork can be scheduled at all, and the compiled time is a dead knob. + `testnet_plugin --testnet-hardfork 16` forces the fork at startup on a production-config testnet for + verification; the plugin does nothing unless both loaded and given a target. +* **Rollback.** Before the timestamp, redeploying the previous image is safe — the fork stays pending. + After activation the marker is chain state: do not roll back (a pre-HF16 binary would settle pending + no-contests from the median while the chain says otherwise). Roll forward. + +## 3. Verification + +**Before activation** (the read-only additions work on the running chain): + +* `get_pm_chain_properties` contains `pm_no_contest_fee_per_market`, numerically equal to + `pm_dispute_fee × pm_no_contest_penalty_percent / 10000`. +* For an oracle with a void in its dispute window and stake on it, `get_oracle` reports + `no_contest_pending_count ≥ 1` and `no_contest_fee_nominal = count × unit`; the matching market + card reports the per-market `settlement_charge`. +* No market carries `no_contest_fee_frozen = true` yet — the flag is written only under HF16. + +**After activation:** + +* a market voided by `pm_no_contest` immediately shows `no_contest_fee_frozen = true` and + `no_contest_fee` equal to the announcing block's unit; +* vote `pm_dispute_fee` / `pm_no_contest_penalty_percent` while that market sits in its dispute window: + the eventual slash equals the frozen amount, not the new median; +* a market announced **before** activation with `frozen = false` settles from the then-current median, + i.e. pre-fork behaviour is preserved for it; +* `oracle.no_contest_slashed_total` grows by exactly the charged amount, in step with + `total_insurance_slashed`; +* the usual post-redeploy invariants: SHARES delta 0, TOKEN delta equal to the chain's legacy anchor, + `restarts 0`, listings non-empty. + +**Consensus regression.** `tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp` (BUILD_TESTNET) +runs one chain with three markets announced around the fork point — pre-fork legacy, post-fork frozen, +post-fork frozen at zero — moves the median afterwards, and checks that the insurance drop and +`no_contest_slashed_total` equal `8u + 1u + 0` for the legacy/frozen/zero legs respectively. +Measured 2026-10-19 (BUILD_TESTNET, `consensus_sim_tests --run_test=pm_no_contest_fee_three_states`): +insurance `5 000 000 → 4 100 000` raw, i.e. exactly `9u`; the test passed with no errors. + +## 4. Rows already in the dispute window at activation + +**No seed pass, and a seed pass would be wrong.** A row that was announced before the fork has no +frozen amount; it must keep the pre-fork rule (median at settlement), because that is the rule under +which it was announced. Writing a frozen amount into it at activation would change the oracle's bill +retroactively — the very defect HF16 removes. The `false` flag is the marker that makes this +automatic: no migration code, no snapshot rewriting, and an old snapshot restores into exactly the +state the settlement expects. + +The residual exposure is therefore bounded and explicit: rows announced pre-fork **and** still inside +the dispute window at activation can still be re-priced by a vote that lands between activation and +their settlement. Their number is measurable from the chain; if it is non-zero and the network wants +no retroactive repricing at all, the only clean response is to wait out those windows before any +parameter vote — not a migration. + +**Measurement before scheduling.** Walk the oracles' pending no-contest rows (status 3, payout +pending, `resolved_outcome = -1`) and count those with `bets_sum > 0`, plus the stake they carry; +`get_oracle` reports exactly this per oracle (`no_contest_pending_count` / `_stake`), so no new +instrument is needed. Measured on the live production-config testnet on **2026-10-19**: 112 markets in +the dispute window, **0** of them carrying stake — the population that could be affected by a vote +inside the window was empty at that moment. Re-measure on a fresh snapshot right before scheduling; +the number moves with the oracle population and says nothing about the future. diff --git a/docs/protocol/operations/overview.md b/docs/protocol/operations/overview.md index 9b6d7d201a..e35fc2d21f 100644 --- a/docs/protocol/operations/overview.md +++ b/docs/protocol/operations/overview.md @@ -127,6 +127,7 @@ Virtual operations are generated by the blockchain itself during block processin | 103 | `pm_early_exit_claim_paid_operation` | Early-exit deferred claim paid at settlement | [Prediction Markets](./prediction-markets.md) | | 104 | `pm_lp_payout_operation` | LP income paid at settlement | [Prediction Markets](./prediction-markets.md) | | 105 | `set_agent_permission_operation` | Grant, replace or revoke an agent key (HF15) | [Agent access](./agent-access.md) | +| 106 | `validator_equivocation_operation` | Validator double-vote proven (HF16 QC) | [Virtual Ops](../virtual-operations.md) | --- diff --git a/docs/protocol/virtual-operations.md b/docs/protocol/virtual-operations.md index b0ac23d5b7..8adbf44f59 100644 --- a/docs/protocol/virtual-operations.md +++ b/docs/protocol/virtual-operations.md @@ -377,6 +377,13 @@ See [Prediction Market Operations](./operations/prediction-markets.md). (IDs 91 All PM money movement is strictly zero-sum (no emission); settlement conserves `Σ out == Σ bets + LP principal + forfeit_pool`. + +### Consensus (HF16 QC) + +| ID | Operation | Trigger | +|----|-----------|---------| +| 106 | `validator_equivocation_operation` | A block carried proof that `validator` signed votes for two different blocks (`first_block_id`, `second_block_id`) in the same `round`. Penalised like a missed block (`total_missed++`, `penalty_percent += validator_miss_penalty_percent` for `validator_miss_penalty_duration`); at most once per round (`last_equivocation_round`) | + --- See also: [Operations Overview](./operations/overview.md), [Awards](./operations/awards.md), [Committee](./operations/committee.md). diff --git a/libraries/chain/chain_properties_evaluators.cpp b/libraries/chain/chain_properties_evaluators.cpp index 0e3e130ff5..4750e73a36 100644 --- a/libraries/chain/chain_properties_evaluators.cpp +++ b/libraries/chain/chain_properties_evaluators.cpp @@ -99,17 +99,30 @@ namespace graphene { namespace chain { FC_ASSERT( _db.has_hardfork(CHAIN_HARDFORK_4), "versioned_chain_properties_update_evaluator not enabled until HF 4" ); _db.get_account(o.owner); // verify owner exists + // #6737: protocol validate() allows up to 3 years of 1 s blocks; until HF16 the old + // one-year-of-3-s-blocks bound stays consensus (the stored props are checked after the + // visit, so a rejected op leaves nothing behind - the whole op is rolled back). + const auto check_year_bound = [&](const chain_properties& p) { + if (_db.has_hardfork(CHAIN_HARDFORK_16)) return; + FC_ASSERT(p.inflation_recalc_period <= CHAIN_BLOCKS_PER_YEAR, + "inflation_recalc_period must not exceed one year in blocks before HF16"); + FC_ASSERT(p.distribution_epoch_length <= uint32_t(CHAIN_BLOCKS_PER_YEAR), + "distribution_epoch_length must not exceed one year in blocks before HF16"); + }; + const auto &idx = _db.get_index().indices().get(); auto itr = idx.find(o.owner); if (itr != idx.end()) { _db.modify(*itr, [&](validator_object& w) { o.props.visit(chain_properties_update(_db, w.props)); + check_year_bound(w.props); }); } else { _db.create([&](validator_object& w) { w.owner = o.owner; w.created = _db.head_block_time(); o.props.visit(chain_properties_update(_db, w.props)); + check_year_bound(w.props); }); } } diff --git a/libraries/chain/database.cpp b/libraries/chain/database.cpp index d94caabedd..e81bf20388 100644 --- a/libraries/chain/database.cpp +++ b/libraries/chain/database.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -1939,9 +1940,8 @@ namespace graphene { namespace chain { // have the full signed_block from the peer — fixes that. // _repair_child_prev_links reconnects the start_block root // so fetch_branch_from can walk the full slave chain. - if (!_fork_db.is_known_block(new_block.id())) { - _fork_db.insert_as_base(new_block); - } + // insert_as_base also fills an existing data-less anchor. + _fork_db.insert_as_base(new_block); return false; } // Block is at or before head but on a different fork. @@ -2104,6 +2104,29 @@ namespace graphene { namespace chain { } _maybe_warn_multiple_production(new_head->num); + // HF16: fork_db keeps the first block at a height as its head, so a + // same-height sibling never reached the certificate comparison below: + // a TC replacement of a QC-less head (Jolteon) was stored and never + // applied, every next producer built one more sibling and the chain + // stalled for good (21-validator stand, netem 300 ms: 5 blocks #194). + // Offer the pushed block as the candidate when it proves a later + // round; a forged certificate fails in switch_forks and is reverted. + // It may also sit LOWER than head: after a TC the proposal extends the + // highest certified block, below a late-QC head and its parent. + if (has_hardfork(CHAIN_HARDFORK_16) && + !get_dynamic_global_properties().emergency_consensus_active && + new_head && !new_head->anchor_only && + new_head->id != new_block.id() && + new_block.block_num() <= new_head->num && // equal, or below a late-QC head + new_block.block_num() > get_dynamic_global_properties().last_irreversible_block_num && + block_cert_round(new_block) > block_cert_round(new_head->data)) { + auto pushed = _fork_db.fetch_block(new_block.id()); + if (pushed && !pushed->invalid) { + _fork_db.set_head(pushed); + new_head = pushed; + } + } + // If the block we just pushed directly extends our database head, // it is a simple linear extension regardless of what fork_db thinks // is the longest chain. This handles the case where fork_db's _head @@ -2126,7 +2149,24 @@ namespace graphene { namespace chain { //Only switch forks if new_head is actually higher than head bool should_switch = false; try { - if (has_hardfork(CHAIN_HARDFORK_12)) { + // HF16: the branch whose tip carries the certificate of the + // later round wins, even at the same height. The certificate + // is only claimed here; switch_forks applies the block and + // validate_block_qc rejects a forged one, which reverts the + // switch. Equal rounds fall through to the HF12 rule. + int cert_cmp = 0; + if (has_hardfork(CHAIN_HARDFORK_16) && + !get_dynamic_global_properties().emergency_consensus_active) { + const auto head_blk = fetch_block_by_id(head_block_id()); + if (head_blk) { + const uint32_t new_r = block_cert_round(new_head->data); + const uint32_t head_r = block_cert_round(*head_blk); + cert_cmp = new_r > head_r ? 1 : (new_r < head_r ? -1 : 0); + } + } + if (cert_cmp != 0) { + should_switch = cert_cmp > 0; + } else if (has_hardfork(CHAIN_HARDFORK_12)) { // HF12: Vote-weighted chain comparison with +10% longer-chain bonus if (new_head->data.block_num() >= head_block_num() && _fork_db.is_known_block(head_block_id())) { @@ -2695,11 +2735,13 @@ namespace graphene { namespace chain { fc::time_point_sec when, const account_name_type &validator_owner, const fc::ecc::private_key &block_signing_private_key, - uint32_t skip /* = 0 */ + uint32_t skip /* = 0 */, + const fc::optional &tc, + const fc::optional &qc_override ) { signed_block result; try { - result = _generate_block(when, validator_owner, block_signing_private_key, skip); + result = _generate_block(when, validator_owner, block_signing_private_key, skip, tc, qc_override); } FC_CAPTURE_AND_RETHROW((validator_owner)) return result; @@ -2710,7 +2752,9 @@ namespace graphene { namespace chain { fc::time_point_sec when, const account_name_type &validator_owner, const fc::ecc::private_key &block_signing_private_key, - uint32_t skip + uint32_t skip, + const fc::optional &tc, + const fc::optional &qc_override ) { // Apply any deferred resize BEFORE the lockless reads below. // This is critical: get_slot_at_time(), get_validator(), find_account() @@ -2742,9 +2786,12 @@ namespace graphene { namespace chain { const auto &validator_obj = get_validator(validator_owner); - if (!(skip & skip_validator_signature)) - FC_ASSERT(validator_obj.signing_key == - block_signing_private_key.get_public_key()); + // NOTE: the signing-key check is deliberately NOT done here. At this point + // the session-visible validator object may already contain a pending + // validator_update that changes our own signing key, while the block header + // is validated against the pre-block (committed) key by + // validate_block_header(). The check is done below, after + // _pending_tx_session.reset() has restored committed head state. // Lockless hint: does the validator account exist? // op_guard coordinates with resize only — it does NOT exclude writers. @@ -2797,6 +2844,9 @@ namespace graphene { namespace chain { size_t total_block_size = max_block_header_size; signed_block pending_block; + // Signing key of our validator as committed at the head block, captured + // while no pending session is active (see the assert below). + public_key_type committed_signing_key; with_strong_write_lock([&]() { // @@ -2857,6 +2907,12 @@ namespace graphene { namespace chain { } _pending_tx_session.reset(); + + // Committed head state is visible again. Capture the key here, under the + // same write lock: a concurrent push_transaction() after the lock is + // released could otherwise re-apply a pending key change and make the + // post-lock read see uncommitted state again. + committed_signing_key = get_validator(validator_owner).signing_key; }); // We have temporarily broken the invariant that @@ -2872,10 +2928,61 @@ namespace graphene { namespace chain { const auto &validator = get_validator(validator_owner); + // The header of THIS block is validated against the validator signing key + // committed at the previous (head) block — a pending validator_update that + // changes our own key takes effect only for the NEXT block. Verify against + // the committed object (visible again after the session reset above), not + // against the session-visible one used at the top of this function. + if (!(skip & skip_validator_signature)) + FC_ASSERT(committed_signing_key == block_signing_private_key.get_public_key(), + "Block signing key does not match the signing key committed at head " + "(a pending validator_update must not change the key used to sign " + "the current block)", + ("validator", validator_owner) + ("committed_key", committed_signing_key) + ("signing_key", block_signing_private_key.get_public_key())); + if (validator.running_version != CHAIN_VERSION) { pending_block.extensions.insert(block_header_extensions(CHAIN_VERSION)); } + // HF16: hand the turn over only on a certified parent. The validator + // plugin waits for the QC before calling us; reaching here without + // one is a bug, and the block would be rejected anyway. + if (block_needs_parent_qc(validator_owner)) { + fc::optional cert = qc_override; + if (!cert) cert = parent_qc_for_next_block(validator_owner); + FC_ASSERT(cert.valid(), "No QC for head #${n} yet: cannot hand over the turn", + ("n", head_block_num())); + FC_ASSERT(!(qc_override && tc), "The late-QC fallback carries no TC"); + pending_block.extensions.insert(block_header_extensions(*cert)); + if (tc) pending_block.extensions.insert(block_header_extensions(*tc)); + + // Double-vote proofs: newest round per validator that the chain has + // not punished yet; a few per block keeps headers small. + std::lock_guard guard(_block_votes_mutex); + const uint32_t own_round = round_of(when); + std::map pick; + for (auto it = _pending_equivocations.begin(); it != _pending_equivocations.end(); ) { + const auto &p = it->second; + const auto *v = find_validator(p.validator); + if (!v || p.round <= v->last_equivocation_round || p.round + 1200 < own_round) { + it = _pending_equivocations.erase(it); // punished already or too old + continue; + } + auto &slot = pick[p.validator]; + if (!slot || slot->round < p.round) slot = &p; + ++it; + } + size_t n = 0; + for (const auto &kv : pick) { + if (n++ >= 5) break; + pending_block.extensions.insert(block_header_extensions(*kv.second)); + } + } + FC_ASSERT(!tc || block_needs_parent_qc(validator_owner), + "A TC goes only next to a parent QC"); + const auto &hfp = get_hardfork_property_object(); // Emergency committee validator should NOT vote for future hardforks @@ -3059,7 +3166,7 @@ namespace graphene { namespace chain { return fc::time_point_sec(); } - auto interval = CHAIN_BLOCK_INTERVAL; + auto interval = block_interval(); const dynamic_global_property_object &dpo = get_dynamic_global_properties(); if (head_block_num() == 0) { @@ -3084,7 +3191,26 @@ namespace graphene { namespace chain { return 0; } return (when - first_slot_time).to_seconds() / - CHAIN_BLOCK_INTERVAL + 1; + block_interval() + 1; + } + + uint32_t database::block_interval() const { + return has_hardfork(CHAIN_HARDFORK_16) ? CHAIN_BLOCK_INTERVAL_HF16 : CHAIN_BLOCK_INTERVAL; + } + + uint32_t database::round_of(fc::time_point_sec t) const { + const auto& dgp = get_dynamic_global_properties(); + const auto a = dgp.round_anchor_time; + if (a == fc::time_point_sec()) { + return t.sec_since_epoch() / CHAIN_BLOCK_INTERVAL; + } + if (t <= a) { + // Parents/QCs just before the anchor. Only one re-anchor exists (HF16), so the + // segment before it is 3 s; a later re-anchoring HF must keep its own previous + // interval here instead of CHAIN_BLOCK_INTERVAL. + return dgp.round_anchor_round - (a.sec_since_epoch() - t.sec_since_epoch()) / CHAIN_BLOCK_INTERVAL; + } + return dgp.round_anchor_round + (t.sec_since_epoch() - a.sec_since_epoch()) / block_interval(); } void database::shares_sender_recalc_energy(const account_object &sender, asset tokens) { @@ -3143,7 +3269,8 @@ namespace graphene { namespace chain { } void database::update_bandwidth_reserve_candidates() { - if ((head_block_num() % CHAIN_BLOCKS_PER_HOUR ) != 0) return; + // HF16: keep the hourly cadence in wall time (3x more blocks per hour at 1 s). + if ((head_block_num() % (CHAIN_BLOCKS_PER_HOUR * (CHAIN_BLOCK_INTERVAL / block_interval()))) != 0) return; uint32_t bandwidth_reserve_candidates = 1; const auto &gprops = get_dynamic_global_properties(); const validator_schedule_object &consensus = get_validator_schedule_object(); @@ -3329,7 +3456,8 @@ namespace graphene { namespace chain { } } } - if ((head_block_num() % COMMITTEE_REQUEST_PROCESSING ) != 0) return; + // HF16: same wall-time cadence as with 3 s slots. + if ((head_block_num() % (COMMITTEE_REQUEST_PROCESSING * (CHAIN_BLOCK_INTERVAL / block_interval()))) != 0) return; uint32_t committee_payment_request_count = 1; const auto &idx4_count = get_index().indices().get(); auto itr3 = idx4_count.lower_bound(4); @@ -5917,6 +6045,14 @@ namespace graphene { namespace chain { const validator_object &signing_validator = validate_block_header(skip, next_block); + // HF16: must run before current_validator is set to this block's + // producer below — the handoff rule compares against the parent's. + validate_block_qc(next_block, skip); + apply_equivocation_proofs(next_block, skip); + for (const auto &e : next_block.extensions) { + if (e.which() == 3) remember_block_qc(e.get()); + } + _current_block_num = next_block_num; _current_trx_in_block = 0; _current_virtual_op = 0; @@ -6069,6 +6205,10 @@ namespace graphene { namespace chain { break; } + case 3: // block_qc_certificate — verified in validate_block_qc() + case 4: // block_timeout_certificate — verified in validate_block_qc() + case 5: // block_equivocation_proof — verified in apply_equivocation_proofs() + break; default: FC_ASSERT(false, "Unknown extension in block header"); } @@ -6258,7 +6398,14 @@ namespace graphene { namespace chain { uint32_t missed_blocks = 0; if (head_block_time() != fc::time_point_sec()) { missed_blocks = get_slot_at_time(b.timestamp); - assert(missed_blocks != 0); + // assert() is compiled out in release builds: a slot of 0 (block time + // before the first slot, e.g. post-HF16 1 s blocks replayed while the + // node still applies the 3 s interval) wrapped missed_blocks to 2^32-1 + // and the miss loop below ran for hours (21-validator stand, replay with + // the fork forced only after replay: block numbers past #20M, 7 GB log). + FC_ASSERT(missed_blocks != 0, + "Block #${n} time ${t} is before the first slot ${s} (interval ${i} s)", + ("n", b.block_num())("t", b.timestamp)("s", get_slot_time(1))("i", block_interval())); missed_blocks--; for (uint32_t i = 0; i < missed_blocks; ++i) { const auto &validator_missed = get_validator(get_scheduled_validator(i + 1)); @@ -6416,18 +6563,23 @@ namespace graphene { namespace chain { dgp.current_reserve_ratio * CHAIN_BANDWIDTH_PRECISION * CHAIN_BANDWIDTH_AVERAGE_WINDOW_SECONDS) / - CHAIN_BLOCK_INTERVAL; + block_interval(); }); if (!(skip & skip_undo_history_check)) { + // HF16 (1 s blocks, Q#3108=A): the undo window is counted in blocks, so keep it equal in TIME — + // 3× the blocks, otherwise a LIB stall halts the chain after 2.8 h instead of 8.3 h. + const uint32_t max_undo = has_hardfork(CHAIN_HARDFORK_16) + ? CHAIN_MAX_UNDO_HISTORY * (CHAIN_BLOCK_INTERVAL / CHAIN_BLOCK_INTERVAL_HF16) + : CHAIN_MAX_UNDO_HISTORY; CHAIN_ASSERT( - _dgp.head_block_number - _dgp.last_irreversible_block_num < CHAIN_MAX_UNDO_HISTORY, + _dgp.head_block_number - _dgp.last_irreversible_block_num < max_undo, undo_database_exception, "The database does not have enough undo history to support a blockchain with so many missed blocks. " "Please add a checkpoint if you would like to continue applying blocks beyond this point.", ("last_irreversible_block_num", _dgp.last_irreversible_block_num) ("head", _dgp.head_block_number) - ("max_undo", CHAIN_MAX_UNDO_HISTORY)); + ("max_undo", max_undo)); } // === HARDFORK 12: EMERGENCY CONSENSUS MODE ACTIVATION === @@ -6452,7 +6604,7 @@ namespace graphene { namespace chain { // dgp.time is advanced to b.timestamp), so it is identical on // every node and during every replay. No block_log fetch, // config flag, wall-clock time, or skip-flag gates it. - uint32_t gap_sec = (missed_blocks + 1) * CHAIN_BLOCK_INTERVAL; + uint32_t gap_sec = (missed_blocks + 1) * block_interval(); { if (gap_sec >= CHAIN_EMERGENCY_CONSENSUS_TIMEOUT_SEC) { @@ -6974,6 +7126,509 @@ namespace graphene { namespace chain { //create block post validation object with current shuffled validators //remove old block post validation objects //remove old blocks from post validation list if it is full (CHAIN_MAX_BLOCK_POST_VALIDATION_COUNT) + uint32_t database::block_qc_quorum(uint32_t num_scheduled) { + const uint32_t two_thirds_plus = num_scheduled * 2 / 3 + 1; + const uint32_t threshold = uint32_t(uint64_t(num_scheduled) * CHAIN_IRREVERSIBLE_THRESHOLD / CHAIN_100_PERCENT); + return std::max(two_thirds_plus, threshold); + } + + bool database::verify_block_vote(uint32_t block_num, const block_id_type &block_id, uint32_t round, + const account_name_type &validator, const signature_type &signature) const { + const auto &wso = get_validator_schedule_object(); + bool scheduled = false; + for (int i = 0; i < wso.num_scheduled_validators; i += CHAIN_BLOCK_VALIDATOR_REPEAT) { + if (wso.current_shuffled_validators[i] == validator) { scheduled = true; break; } + } + if (!scheduled) return false; + + const auto *v = find_validator(validator); + if (!v || v->signing_key == public_key_type()) return false; + + const auto digest = protocol::block_vote_digest(get_chain_id(), block_num, block_id, round); + try { + return public_key_type(fc::ecc::public_key(signature, digest)) == v->signing_key; + } catch (const fc::exception &) { + return false; // unrecoverable signature + } + } + + bool database::verify_timeout_vote(uint32_t round, uint32_t high_qc_round, + const account_name_type &validator, const signature_type &signature) const { + // A QC seen while timing out round r is from an earlier round. + if (high_qc_round >= round) return false; + const auto &wso = get_validator_schedule_object(); + bool scheduled = false; + for (int i = 0; i < wso.num_scheduled_validators; i += CHAIN_BLOCK_VALIDATOR_REPEAT) { + if (wso.current_shuffled_validators[i] == validator) { scheduled = true; break; } + } + if (!scheduled) return false; + + const auto *v = find_validator(validator); + if (!v || v->signing_key == public_key_type()) return false; + + const auto digest = protocol::block_timeout_digest(get_chain_id(), round, high_qc_round); + try { + return public_key_type(fc::ecc::public_key(signature, digest)) == v->signing_key; + } catch (const fc::exception &) { + return false; // unrecoverable signature + } + } + + bool database::push_timeout_vote(uint32_t round, uint32_t high_qc_round, + const account_name_type &validator, const signature_type &signature) { + // Same window as block votes: bounded memory for valid-but-absurd rounds. + static constexpr uint32_t MAX_VOTE_AHEAD_ROUNDS = 64; + static constexpr uint32_t MAX_VOTE_ROUND_BEHIND = 1200; // ~1 h at 3 s + const uint32_t head_round = round_of(head_block_time()); + if (round + MAX_VOTE_ROUND_BEHIND < head_round) return false; + // Bound "ahead" by the wall-clock round, not head's: timeout votes exist + // precisely while head is stuck. Measured from head, every vote is refused + // ~64 s into a stall, no TC can form any more and the chain never recovers, + // even after the network heals (21-validator stand, netem 300 ms, 22.10). + // The vote pool is off-chain state, so a local clock bound is fine here. + const uint32_t now_round = std::max(head_round, + round_of(fc::time_point_sec(fc::time_point::now()))); + if (round > now_round + MAX_VOTE_AHEAD_ROUNDS) return false; + + if (!verify_timeout_vote(round, high_qc_round, validator, signature)) return false; + + const auto &wso = get_validator_schedule_object(); + const uint32_t quorum = block_qc_quorum(uint32_t(wso.num_scheduled_validators) / CHAIN_BLOCK_VALIDATOR_REPEAT); + + std::lock_guard guard(_block_votes_mutex); + + const uint32_t floor_round = head_round > MAX_VOTE_ROUND_BEHIND ? head_round - MAX_VOTE_ROUND_BEHIND : 0; + for (auto it = _timeout_votes.begin(); it != _timeout_votes.end() && it->first < floor_round; ) + it = _timeout_votes.erase(it); + for (auto it = _timeout_certs.begin(); it != _timeout_certs.end() && it->first < floor_round; ) + it = _timeout_certs.erase(it); + + // One timeout vote per (validator, round); a repeat (even with another + // high_qc_round) is ignored, the first one stands. + auto &votes = _timeout_votes[round]; + if (votes.count(validator)) return false; + protocol::block_timeout_vote tv; + tv.high_qc_round = high_qc_round; + tv.signature = signature; + votes.emplace(validator, tv); + + if (votes.size() >= quorum && _timeout_certs.find(round) == _timeout_certs.end()) { + protocol::block_timeout_certificate tc; + tc.round = round; + tc.votes.insert(votes.begin(), votes.end()); + ilog("TC formed for round ${r}: ${k}/${q} timeout votes", ("r", round)("k", votes.size())("q", quorum)); + _timeout_certs.emplace(round, std::move(tc)); + } + return true; + } + + void database::apply_equivocation_proofs(const signed_block &b, uint32_t skip) { + std::vector proofs; + for (const auto &e : b.extensions) + if (e.which() == 5) proofs.push_back(&e.get()); + if (proofs.empty()) return; + FC_ASSERT(has_hardfork(CHAIN_HARDFORK_16), "Double-vote proof in block #${n} before HF16", ("n", b.block_num())); + FC_ASSERT(proofs.size() <= 5, "Block #${n} carries ${k} double-vote proofs, at most 5", + ("n", b.block_num())("k", proofs.size())); + + const uint32_t own_round = round_of(b.timestamp); + std::set seen; + const auto &props = get_validator_schedule_object().median_props; + for (const auto *p : proofs) { + FC_ASSERT(seen.insert(p->validator).second, "Two double-vote proofs for ${v} in one block", ("v", p->validator)); + FC_ASSERT(p->first.block_id != p->second.block_id, "Double-vote proof for ${v}: same block twice", ("v", p->validator)); + FC_ASSERT(p->round < own_round && p->round + 1200 >= own_round, + "Double-vote proof for ${v}: round ${r} outside the window", ("v", p->validator)("r", p->round)); + const auto &w = get_validator(p->validator); + FC_ASSERT(p->round > w.last_equivocation_round, + "Double vote of ${v} in round ${r} is already punished", ("v", p->validator)("r", p->round)); + if (!(skip & skip_validator_signature)) { + FC_ASSERT(verify_block_vote(p->first.block_num, p->first.block_id, p->round, p->validator, p->first.signature) && + verify_block_vote(p->second.block_num, p->second.block_id, p->round, p->validator, p->second.signature), + "Double-vote proof for ${v} does not verify", ("v", p->validator)); + } + + // Same punishment as a missed block (owner 20.10): penalty for the + // configured duration and one more total_missed. + modify(w, [&](validator_object &v) { + v.last_equivocation_round = p->round; + v.total_missed++; + v.penalty_percent += props.validator_miss_penalty_percent; + v.counted_votes = (fc::uint128_t(v.votes) - (fc::uint128_t(v.votes) * + std::min(v.penalty_percent, uint32_t(CHAIN_100_PERCENT)) / CHAIN_100_PERCENT)).to_uint64(); + }); + create([&](validator_penalty_expire_object &wpe) { + wpe.validator = p->validator; + wpe.penalty_percent = props.validator_miss_penalty_percent; + wpe.expires = b.timestamp + fc::seconds(props.validator_miss_penalty_duration); + }); + push_virtual_operation(protocol::validator_equivocation_operation( + p->validator, p->round, p->first.block_id, p->second.block_id)); + wlog("Validator ${v} punished for a double vote in round ${r} (block #${n})", + ("v", p->validator)("r", p->round)("n", b.block_num())); + } + } + + void database::remember_block_qc(const protocol::block_qc_certificate &cert) { + std::lock_guard guard(_block_votes_mutex); + // Same rule as the tally path: per height keep the QC with the HIGHEST round. A re-proposal after a + // TC carries a newer-round QC; keeping the first (possibly for an equivocating twin) pinned the height. + auto held = _block_qcs.find(cert.block_num); + if (held != _block_qcs.end()) { + if (held->second.round >= cert.round) return; + _block_qcs.erase(held); + } + block_qc qc; + qc.block_num = cert.block_num; + qc.block_id = cert.block_id; + qc.round = cert.round; + qc.signatures.assign(cert.signatures.begin(), cert.signatures.end()); + _block_qcs.emplace(cert.block_num, std::move(qc)); + } + + fc::optional database::get_timeout_cert(uint32_t round) const { + std::lock_guard guard(_block_votes_mutex); + auto it = _timeout_certs.find(round); + if (it == _timeout_certs.end()) return {}; + return it->second; + } + + bool database::block_needs_parent_qc(const account_name_type &producer) const { + if (!has_hardfork(CHAIN_HARDFORK_16)) return false; + const auto &dgp = get_dynamic_global_properties(); + if (dgp.emergency_consensus_active) return false; // committee alone cannot form a QC + if (dgp.head_block_number < 1) return false; + const auto &parent_producer = dgp.current_validator; // producer of head + if (parent_producer == account_name_type()) return false; + // No exemption for a validator's own series: the Jolteon commit rule + // needs every block certified, or a series could build past a parent + // that never gathered a quorum and LIB would stall on it. + (void)producer; + // First block after an emergency: committee blocks never get a QC + // (validators re-enter with fresh keys), so their child is exempt. + if (parent_producer == CHAIN_EMERGENCY_VALIDATOR_ACCOUNT) return false; + // Child of the HF16 activation block (the round anchor, the last 3 s slot): + // nothing below it carries a QC, so if its own QC does not form (votes lost to + // a network-wide restart, a hiccup at the switch) a TC cannot rescue it either + // (a TC needs the head's parent certified) and the chain stalls for good. + // 21-validator stand, 22.10: head #65 waited for a QC over 100+ slots. + if (dgp.round_anchor_time != fc::time_point_sec() && dgp.time <= dgp.round_anchor_time) return false; + return true; + } + + fc::optional database::parent_qc_for_next_block(const account_name_type &producer) const { + fc::optional result; + if (!block_needs_parent_qc(producer)) return result; + const auto qc = get_block_qc(head_block_num()); + if (!qc || qc->block_id != head_block_id()) return result; + + protocol::block_qc_certificate cert; + cert.block_num = qc->block_num; + cert.block_id = qc->block_id; + cert.round = qc->round; + // Re-verify against current state: the schedule or a signing key may + // have changed since the votes were tallied (round boundary), and the + // block must carry only signatures the consensus check will accept. + for (const auto &sv : qc->signatures) { + if (verify_block_vote(cert.block_num, cert.block_id, cert.round, sv.first, sv.second)) + cert.signatures.emplace(sv.first, sv.second); + } + const auto &wso = get_validator_schedule_object(); + if (cert.signatures.size() < block_qc_quorum(uint32_t(wso.num_scheduled_validators) / CHAIN_BLOCK_VALIDATOR_REPEAT)) + return result; + result = std::move(cert); + return result; + } + + fc::optional database::grandparent_qc_for_next_block( + const account_name_type &producer, uint32_t own_round) const { + fc::optional result; + if (!block_needs_parent_qc(producer) || head_block_num() < 2) return result; + const uint32_t head_round = round_of(head_block_time()); + if (head_round + 1 != own_round) return result; // rounds must run on, no gap + const auto head = fetch_block_by_number(head_block_num()); + if (!head) return result; + const protocol::block_qc_certificate *gp = nullptr; + for (const auto &e : head->extensions) + if (e.which() == 3) gp = &e.get(); + if (!gp || gp->block_id != head->previous || gp->round + 1 != head_round) return result; + + protocol::block_qc_certificate cert; + cert.block_num = gp->block_num; + cert.block_id = gp->block_id; + cert.round = gp->round; + for (const auto &sv : gp->signatures) { + if (verify_block_vote(cert.block_num, cert.block_id, cert.round, sv.first, sv.second)) + cert.signatures.emplace(sv.first, sv.second); + } + const auto &wso = get_validator_schedule_object(); + if (cert.signatures.size() < block_qc_quorum(uint32_t(wso.num_scheduled_validators) / CHAIN_BLOCK_VALIDATOR_REPEAT)) + return result; + result = std::move(cert); + return result; + } + + uint32_t database::block_cert_round(const signed_block &b) { + // Highest round a block proves: its parent QC, or a TC it carries + // (a TC for round r outranks any QC below r). Validity is checked + // when the block is applied. + uint32_t r = 0; + for (const auto &e : b.extensions) { + if (e.which() == 3) // block_qc_certificate + r = std::max(r, e.get().round); + else if (e.which() == 4) // block_timeout_certificate + r = std::max(r, e.get().round); + } + return r; + } + + void database::validate_block_qc(const signed_block &next_block, uint32_t skip) const { + const protocol::block_qc_certificate *cert = nullptr; + const protocol::block_timeout_certificate *tc = nullptr; + for (const auto &e : next_block.extensions) { + if (e.which() == 3) { // block_qc_certificate (index in block_header_extensions) + FC_ASSERT(cert == nullptr, "Block #${n} carries two parent QCs", ("n", next_block.block_num())); + cert = &e.get(); + } else if (e.which() == 4) { // block_timeout_certificate + FC_ASSERT(tc == nullptr, "Block #${n} carries two TCs", ("n", next_block.block_num())); + tc = &e.get(); + } + } + if (!has_hardfork(CHAIN_HARDFORK_16)) { + FC_ASSERT(cert == nullptr, "Parent QC in block #${n} before HF16", ("n", next_block.block_num())); + FC_ASSERT(tc == nullptr, "TC in block #${n} before HF16", ("n", next_block.block_num())); + return; + } + if (block_needs_parent_qc(next_block.validator)) { + FC_ASSERT(cert != nullptr, + "Block #${n} by ${w} carries no QC of its parent (produced by ${p})", + ("n", next_block.block_num())("w", next_block.validator) + ("p", get_dynamic_global_properties().current_validator)); + } + // A TC only makes sense next to the parent QC it is checked against. + FC_ASSERT(cert != nullptr || tc == nullptr, "Block #${n} carries a TC without a parent QC", + ("n", next_block.block_num())); + if (cert == nullptr) return; + + const uint32_t parent_round = round_of(head_block_time()); + if (cert->block_id == next_block.previous) { + FC_ASSERT(cert->block_num + 1 == next_block.block_num(), + "Parent QC certifies #${qn}, not the parent", ("qn", cert->block_num)); + // Round = slot of the certified block. The parent is head here. + FC_ASSERT(cert->round == parent_round, + "Parent QC round ${r} is not the parent's slot", ("r", cert->round)); + } else { + // Late-QC fallback: QC of the grandparent, three consecutive rounds + // (grandparent r-2, parent r-1, this block r). The parent must itself + // carry this very QC, so nothing certified is skipped. Safety rests on + // one QC per round and the commit rule needing consecutive rounds. + const uint32_t own_round = round_of(next_block.timestamp); + FC_ASSERT(tc == nullptr, "The late-QC fallback carries no TC"); + FC_ASSERT(parent_round + 1 == own_round && cert->round + 1 == parent_round && + cert->block_num + 2 == next_block.block_num(), + "QC #${qn} round ${r} is neither the parent's nor a consecutive grandparent's", + ("qn", cert->block_num)("r", cert->round)); + const auto parent = fetch_block_by_id(next_block.previous); + FC_ASSERT(parent.valid(), "Parent of block #${n} not found", ("n", next_block.block_num())); + bool parent_carries = false; + for (const auto &e : parent->extensions) + if (e.which() == 3) { + const auto &pc = e.get(); + parent_carries = pc.block_id == cert->block_id && pc.round == cert->round; + } + FC_ASSERT(parent_carries && parent->previous == cert->block_id, + "Grandparent QC #${qn} is not the one the parent carries", ("qn", cert->block_num)); + } + const auto &wso = get_validator_schedule_object(); + const uint32_t scheduled = uint32_t(wso.num_scheduled_validators) / CHAIN_BLOCK_VALIDATOR_REPEAT; + FC_ASSERT(cert->signatures.size() <= scheduled, "Parent QC has more signatures than scheduled validators"); + FC_ASSERT(cert->signatures.size() >= block_qc_quorum(scheduled), + "Parent QC has ${k} signatures, quorum is ${q}", + ("k", cert->signatures.size())("q", block_qc_quorum(scheduled))); + + // TC(r): a quorum gave up on round r, i.e. on a QC-less block that was + // built on our parent. Jolteon: r lies strictly between the parent's + // round and ours, and our parent QC is at least as high as every QC a + // timeout voter reported (nobody saw a newer certified block we skip). + if (tc != nullptr) { + const uint32_t own_round = round_of(next_block.timestamp); + FC_ASSERT(tc->round > cert->round && tc->round < own_round, + "TC round ${t} not between parent round ${p} and block round ${o}", + ("t", tc->round)("p", cert->round)("o", own_round)); + FC_ASSERT(tc->votes.size() <= scheduled, "TC has more votes than scheduled validators"); + FC_ASSERT(tc->votes.size() >= block_qc_quorum(scheduled), + "TC has ${k} votes, quorum is ${q}", + ("k", tc->votes.size())("q", block_qc_quorum(scheduled))); + for (const auto &tv : tc->votes) { + FC_ASSERT(tv.second.high_qc_round <= cert->round, + "TC vote by ${v} saw QC round ${h}, above the parent QC round ${p}", + ("v", tv.first)("h", tv.second.high_qc_round)("p", cert->round)); + } + } + + if (skip & skip_validator_signature) return; + for (const auto &sv : cert->signatures) { + FC_ASSERT(verify_block_vote(cert->block_num, cert->block_id, cert->round, sv.first, sv.second), + "Parent QC carries an invalid vote by ${v}", ("v", sv.first)); + } + if (tc != nullptr) { + for (const auto &tv : tc->votes) { + FC_ASSERT(verify_timeout_vote(tc->round, tv.second.high_qc_round, tv.first, tv.second.signature), + "TC carries an invalid timeout vote by ${v}", ("v", tv.first)); + } + } + } + + bool database::push_block_vote(uint32_t block_num, const block_id_type &block_id, uint32_t round, + const account_name_type &validator, const signature_type &signature) { + // Bounds first: a scheduled validator's valid signature over an + // absurd (height, round) must still not buy unbounded memory. + static constexpr uint32_t MAX_VOTE_AHEAD_BLOCKS = 64; + // Round = slot (block timestamp / interval). Accept a window around + // head: far-past rounds are below LIB anyway, far-future ones are junk. + static constexpr uint32_t MAX_VOTE_ROUND_BEHIND = 1200; // ~1 h at 3 s + if (block_num == 0 || block_id == block_id_type()) return false; + if (protocol::block_header::num_from_id(block_id) != block_num) return false; + + const auto &dpo = get_dynamic_global_properties(); + const uint32_t head_round = round_of(head_block_time()); + if (round + MAX_VOTE_ROUND_BEHIND < head_round) return false; + if (round > head_round + MAX_VOTE_AHEAD_BLOCKS) return false; + // With a tiny schedule (one validator: testnet, consensus_sim) the + // classic LIB reaches head itself, yet head still needs a QC before + // the next block can be produced. Settle only what lies below head. + const uint32_t lib = std::min(dpo.last_irreversible_block_num, + dpo.head_block_number > 0 ? dpo.head_block_number - 1 : 0); + // HF16 diagnostics (21-validator stand): a vote for head that is turned away + // is logged once per block, so a QC that never forms names its cause. + static uint32_t head_reject_logged = 0; + const auto reject_head = [&](const char *why) { + if (block_num == dpo.head_block_number && head_reject_logged != block_num) { + head_reject_logged = block_num; + wlog("Vote for head #${n} from ${w} round ${r} rejected: ${why} (LIB ${l}, head round ${h})", + ("n", block_num)("w", validator)("r", round)("why", why)("l", lib)("h", head_round)); + } + return false; + }; + if (block_num <= lib) return reject_head("at or below LIB"); // already final + if (block_num > dpo.head_block_number + MAX_VOTE_AHEAD_BLOCKS) return false; + + if (!verify_block_vote(block_num, block_id, round, validator, signature)) return reject_head("signature/schedule check failed"); + + const auto &wso = get_validator_schedule_object(); + const uint32_t quorum = block_qc_quorum(uint32_t(wso.num_scheduled_validators) / CHAIN_BLOCK_VALIDATOR_REPEAT); + + std::lock_guard guard(_block_votes_mutex); + + // Forget everything at or below LIB: those heights are settled. + for (auto it = _block_vote_tallies.begin(); it != _block_vote_tallies.end() && std::get<0>(it->first) <= lib; ) + it = _block_vote_tallies.erase(it); + for (auto it = _block_vote_by_validator.begin(); it != _block_vote_by_validator.end(); ) { + if (it->second.first <= lib) { _block_vote_sigs.erase(it->first); it = _block_vote_by_validator.erase(it); } + else ++it; + } + // Keep certificates a little below LIB: the next block header (and + // a lagging peer) may still need the parent's QC. + for (auto it = _block_qcs.begin(); it != _block_qcs.end() && it->first + MAX_VOTE_AHEAD_BLOCKS <= lib; ) + it = _block_qcs.erase(it); + + // One vote per (validator, round). A second, different id in the same + // round is equivocation: never counted, logged as evidence (both + // signatures verify against the same signing key). + const auto vkey = std::make_pair(validator, round); + auto prev = _block_vote_by_validator.find(vkey); + if (prev != _block_vote_by_validator.end()) { + if (prev->second.second != block_id) { + wlog("Equivocation: validator ${v} voted for two blocks in round ${r}: #${an} ${a} and #${n} ${b}", + ("v", validator)("r", round)("an", prev->second.first)("a", prev->second.second) + ("n", block_num)("b", block_id)); + // Both signatures verified against the same key: keep the pair as + // a proof for the next block we produce. + auto sig = _block_vote_sigs.find(vkey); + if (sig != _block_vote_sigs.end() && _pending_equivocations.size() < 256 && + !_pending_equivocations.count(vkey)) { + protocol::block_equivocation_proof proof; + proof.validator = validator; + proof.round = round; + proof.first = protocol::block_vote_evidence{prev->second.first, prev->second.second, sig->second}; + proof.second = protocol::block_vote_evidence{block_num, block_id, signature}; + _pending_equivocations.emplace(vkey, std::move(proof)); + } + } + return false; + } + _block_vote_by_validator.emplace(vkey, std::make_pair(block_num, block_id)); + _block_vote_sigs.emplace(vkey, signature); + + auto &tally = _block_vote_tallies[std::make_tuple(block_num, round, block_id)]; + if (tally.votes.empty()) tally.first_seen = fc::time_point::now(); + tally.votes.emplace(validator, signature); + + // One QC per height, but the HIGHEST round wins: after a TC the height is + // rebuilt in a later round, and keeping the first QC forever made every + // rebuild uncertifiable (21-validator stand, double signer: QC for one twin + // of #546 in round 649, fork choice took another, then 40+ rebuilds of #546 + // with 15/15 votes each and no QC; chain stuck for 20+ min). + auto held_qc = _block_qcs.find(block_num); + if (tally.votes.size() >= quorum && (held_qc == _block_qcs.end() || held_qc->second.round < round)) { + if (held_qc != _block_qcs.end()) _block_qcs.erase(held_qc); + block_qc qc; + qc.block_num = block_num; + qc.block_id = block_id; + qc.round = round; + qc.signatures.assign(tally.votes.begin(), tally.votes.end()); + qc.formed_after = fc::time_point::now() - tally.first_seen; + ilog("QC formed for #${n} ${id} round ${r}: ${k}/${q} votes, ${ms} ms after first vote", + ("n", block_num)("id", block_id)("r", round)("k", qc.signatures.size())("q", quorum) + ("ms", qc.formed_after.count() / 1000)); + _block_qcs.emplace(block_num, std::move(qc)); + } + return true; + } + + uint32_t database::highest_qc_on_main_branch(uint32_t max_num) const { + std::vector> certified; + { + std::lock_guard guard(_block_votes_mutex); + for (auto it = _block_qcs.rbegin(); it != _block_qcs.rend(); ++it) { + if (it->first <= max_num) certified.emplace_back(it->first, it->second.block_id); + } + } + // 2-chain rule: a certified block is final only when its child at the + // next height is certified too, both on our branch. One QC is not + // enough once voting has rounds: a validator that voted for X need + // not see X's QC, may vote for Y in a later round, and a second QC + // at the same height becomes possible. Requiring a certified child + // (whose header carries X's QC) closes that window. Highest first. + for (size_t i = 0; i + 1 < certified.size(); ++i) { + const auto &child = certified[i]; + const auto &parent = certified[i + 1]; + if (child.first != parent.first + 1) continue; + if (find_block_id_for_num(child.first) != child.second || + find_block_id_for_num(parent.first) != parent.second) continue; + // Jolteon commit rule: the two certified blocks must also be in + // consecutive ROUNDS (round = slot). A child after skipped slots + // was built on a timeout certificate, and a parent certified + // before that timeout is not yet safe to commit. + const auto pb = fetch_block_by_id(parent.second); + const auto cb = fetch_block_by_id(child.second); + if (!pb || !cb) continue; + // Compare rounds, not timestamps: the slot is 1 s after HF16, and a + // compile-time 3 s step never matched there, so QC-LIB was always 0 + // and LIB fell back to post-validation (stand: lag exactly 14 blocks). + if (round_of(cb->timestamp) != round_of(pb->timestamp) + 1) continue; + return parent.first; + } + return 0; + } + + fc::optional database::get_block_qc(uint32_t block_num) const { + std::lock_guard guard(_block_votes_mutex); + auto it = _block_qcs.find(block_num); + if (it == _block_qcs.end()) return fc::optional(); + return it->second; + } + void database::create_block_post_validation(uint32_t block_num, block_id_type block_id, const account_name_type& validator_account){ const dynamic_global_property_object &dpo = get_dynamic_global_properties(); //remove blocks if they height is less than last irreversible block @@ -7097,6 +7752,26 @@ namespace graphene { namespace chain { : 0; } + // === HARDFORK 16: LIB FROM QUORUM CERTIFICATES === + // A QC is >2/3 of scheduled validators signing one block id at one + // height; the one-vote-per-height guard makes a conflicting QC at + // the same height impossible without equivocation, so the certified + // block and its ancestors are final. Take the highest certified + // height that is on OUR main branch (a QC for a block we do not + // have, or for a sibling, proves nothing about our chain). + // Capped at HEAD-1, same reason as the emergency cap: committing + // HEAD would drop the undo session of the block being applied. + // Node-local, like the pre-DLT post-validation LIB it replaces: + // no evaluator reads LIB, so state stays deterministic. + if (has_hardfork(CHAIN_HARDFORK_16) && !dpo.emergency_consensus_active && + dpo.head_block_number > 1) { + const uint32_t qc_lib = highest_qc_on_main_branch(dpo.head_block_number); + if (qc_lib > new_last_irreversible_block_num) { + new_last_irreversible_block_num = qc_lib; + } + } + // === END HARDFORK 16 === + if (_debug_block_production) ilog("DEBUG_CRASH: update_last_irreversible_block: new_lib=${lib} old_lib=${old} head=${h}", ("lib", new_last_irreversible_block_num)("old", dpo.last_irreversible_block_num)("h", dpo.head_block_number)); diff --git a/libraries/chain/database_hardfork.cpp b/libraries/chain/database_hardfork.cpp index 54fa0fd116..1b858e806e 100644 --- a/libraries/chain/database_hardfork.cpp +++ b/libraries/chain/database_hardfork.cpp @@ -105,11 +105,16 @@ inline u256 to256(const fc::uint128_t &t) { // HF15 (PM audit fixes) is registered in BOTH configs — production included, because the // shelter testnet runs the production config and a testnet-only fork could never // activate there. The gate is the activation time (15.hf) plus the validator quorum, - // not the build flavour; CHAIN_NUM_HARDFORKS is 15 everywhere so the arrays are sized - // [CHAIN_NUM_HARDFORKS + 1] = [16] and these indices are in bounds. + // not the build flavour; CHAIN_NUM_HARDFORKS is 16 everywhere so the arrays are sized + // [CHAIN_NUM_HARDFORKS + 1] = [17] and these indices are in bounds. _hardfork_times[CHAIN_HARDFORK_15] = fc::time_point_sec(CHAIN_HARDFORK_15_TIME); _hardfork_versions[CHAIN_HARDFORK_15] = CHAIN_HARDFORK_15_VERSION; + // HF16 (no-contest fee freeze) — same registration rule as HF15: unconditional in both + // configs, gated by the time in 16.hf plus validator quorum. + _hardfork_times[CHAIN_HARDFORK_16] = fc::time_point_sec(CHAIN_HARDFORK_16_TIME); + _hardfork_versions[CHAIN_HARDFORK_16] = CHAIN_HARDFORK_16_VERSION; + const auto &hardforks = get_hardfork_property_object(); FC_ASSERT( hardforks.last_hardfork <= CHAIN_NUM_HARDFORKS, @@ -1041,6 +1046,41 @@ inline u256 to256(const fc::uint128_t &t) { // on get. All balances start at zero (empty pool). create([](pm_lazy_pool_object&) {}); break; + case CHAIN_HARDFORK_16: + // #6737: this block is the last 3 s slot (dgpo.time is already this block's + // timestamp: update_global_dynamic_data runs before process_hardforks), every + // later slot is CHAIN_BLOCK_INTERVAL_HF16. Re-anchor rounds here, using the + // pre-switch formula for this block's own round. See database::round_of(). + { + const uint32_t r = round_of(get_dynamic_global_properties().time); + modify(get_dynamic_global_properties(), [&](dynamic_global_property_object& p) { + p.round_anchor_time = p.time; + p.round_anchor_round = r; + }); + } + { + // #6737, Q#3108=A: block-denominated chain props keep their wall-time + // meaning at 1 s slots. The median is recomputed from every validator's + // own props each schedule round, so scaling only median_props would be + // undone within one round: scale every stored vote, then the median. + const auto scale = [](chain_properties& c) { + const auto x3 = [](uint32_t v) { + return uint32_t(std::min(uint64_t(v) * 3, std::numeric_limits::max())); + }; + c.inflation_recalc_period = x3(c.inflation_recalc_period); + c.distribution_epoch_length = x3(c.distribution_epoch_length); + c.pm_batch_epoch_blocks = x3(c.pm_batch_epoch_blocks); + c.pm_reveal_window_blocks = x3(c.pm_reveal_window_blocks); + }; + const auto& vidx = get_index().indices(); + for (auto it = vidx.begin(); it != vidx.end(); ++it) { + modify(*it, [&](validator_object& v) { scale(v.props); }); + } + modify(get_validator_schedule_object(), [&](validator_schedule_object& w) { + scale(w.median_props); + }); + } + break; default: break; } diff --git a/libraries/chain/fork_database.cpp b/libraries/chain/fork_database.cpp index c54c8f1fef..fc8aa09240 100644 --- a/libraries/chain/fork_database.cpp +++ b/libraries/chain/fork_database.cpp @@ -31,6 +31,17 @@ namespace graphene { auto &id_index = _index.get(); auto existing = id_index.find(b.id()); if (existing != id_index.end()) { + // An anchor (insert_anchor_block) holds only id/num. Now that the + // real block is here, fill it in: otherwise the LIB writer of a + // non-DLT node finds no data for this block and asserts on + // block_log append (21-validator QC stand, #6741: a node that + // joined at head #1 anchored #1, then rejected every #2). + if ((*existing)->anchor_only) { + id_index.modify(existing, [&](item_ptr &it) { + it->data = std::move(b); + it->anchor_only = false; + }); + } // Already in linked index — still repair any child blocks whose // prev pointer is null (inserted via start_block before we knew // this ancestor). diff --git a/libraries/chain/hardfork.d/0-preamble.hf b/libraries/chain/hardfork.d/0-preamble.hf index a7dfc85d97..db34891595 100644 --- a/libraries/chain/hardfork.d/0-preamble.hf +++ b/libraries/chain/hardfork.d/0-preamble.hf @@ -52,10 +52,11 @@ FC_REFLECT((graphene::chain::hardfork_property_object), CHAINBASE_SET_INDEX_TYPE( graphene::chain::hardfork_property_object, graphene::chain::hardfork_property_index) #define CHAIN_STARTUP_HARDFORKS 0 -// HF15 (the PM audit fixes) is registered in BOTH configs — production included, because the -// shelter testnet runs the production config (config_testnet.hpp would change CHAIN_ID/CHAIN_NAME -// and drop the validator quorum to 1) and could not activate a testnet-only fork. What gates it is -// the activation time in 15.hf plus the 17/21 validator quorum, not the build flavour. -// Keep this in sync with 15.hf and with CHAIN_VERSION: database_hardfork.cpp asserts -// CHAIN_HARDFORK_VERSION == _hardfork_versions[CHAIN_NUM_HARDFORKS]. -#define CHAIN_NUM_HARDFORKS 15 +// HF15 (the PM audit fixes) and HF16 (no-contest fee freeze) are registered in BOTH configs — +// production included, because the shelter testnet runs the production config (config_testnet.hpp +// would change CHAIN_ID/CHAIN_NAME and drop the validator quorum to 1) and could not activate a +// testnet-only fork. What gates them is the activation time in 15.hf/16.hf plus the 17/21 +// validator quorum, not the build flavour. +// Keep this in sync with the highest .hf file and with CHAIN_VERSION: database_hardfork.cpp +// asserts CHAIN_HARDFORK_VERSION == _hardfork_versions[CHAIN_NUM_HARDFORKS]. +#define CHAIN_NUM_HARDFORKS 16 diff --git a/libraries/chain/hardfork.d/15.hf b/libraries/chain/hardfork.d/15.hf index 66029c8017..8649c8a8a8 100644 --- a/libraries/chain/hardfork.d/15.hf +++ b/libraries/chain/hardfork.d/15.hf @@ -37,7 +37,8 @@ #define CHAIN_HARDFORK_15_TIME 1791158400 // 2026-10-05 00:00:00 UTC #define CHAIN_HARDFORK_15_VERSION hardfork_version( version(4, 1, 0) ) #endif -// Registration itself is unconditional in both configs (CHAIN_NUM_HARDFORKS is 15 everywhere), so -// the fork can never be "unknown" to a binary that carries it; what gates it is the time above plus -// the 17/21 validator quorum. The stale-state migration runs separately — see the checklist. +// Registration itself is unconditional in both configs (the hardfork arrays are sized +// [CHAIN_NUM_HARDFORKS + 1]), so the fork can never be "unknown" to a binary that carries it; what +// gates it is the time above plus the 17/21 validator quorum. The stale-state migration runs +// separately — see the checklist. #endif diff --git a/libraries/chain/hardfork.d/16.hf b/libraries/chain/hardfork.d/16.hf new file mode 100644 index 0000000000..5a41305e45 --- /dev/null +++ b/libraries/chain/hardfork.d/16.hf @@ -0,0 +1,48 @@ +// 16 Hardfork — freeze the no-contest price at announcement. Today pm_no_contest only declares the +// void and opens the pm_dispute_grace_sec window; the penalty is computed at settlement from the +// median chain_properties_pm of THAT moment. A parameter vote inside the window therefore changes +// the price retroactively: the oracle announces one number and pays another. This fork records the +// nominal fee on the market (pm_market_object::no_contest_fee) in pm_no_contest_evaluator and makes +// settle_market(win < 0) charge exactly that; the oracle gets a monotone no_contest_slashed_total +// counter in the same modify() that grows total_insurance_slashed (exact attribution of the class +// without a virtual operation). The insurance cap stays where it was — per settlement. +// See docs/prediction-markets/pm-no-contest-fee-freeze.md. +#ifndef CHAIN_HARDFORK_16 +#define CHAIN_HARDFORK_16 16 +// The same fork under the name the fix itself uses (the gates read this one, so the pair is +// deliberately not two independent facts: they are defined together and must stay equal). +#ifndef CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK +#define CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK 16 +#endif +#ifdef BUILD_TESTNET +// Testnet-config activation (CHAIN_NAME "VIZTEST"). Testnet has a single validator +// (CHAIN_HARDFORK_REQUIRED_VALIDATORS=1), so this timestamp IS the whole coordination: the +// validator's auto-vote in _generate_block reaches quorum by itself and the fork applies on the +// first block at or after this time. +#define CHAIN_HARDFORK_16_TIME 1793404800 // 2026-10-31 00:00:00 UTC (same as production) +#define CHAIN_HARDFORK_16_VERSION hardfork_version( version(4, 2, 0) ) +#else +// Production-config activation. This branch is what the shelter testnet runs too — that node is a +// PRODUCTION-config deployment (config_testnet.hpp would change CHAIN_ID/CHAIN_NAME and 17/21 +// validator quorum to 1), so the only lever for a production-config testnet is this timestamp. +// 17 of 21 validators must still vote the version above, which the node does on its own +// (database.cpp _generate_block injects hardfork_version_vote while current_hardfork_version < +// CHAIN_HARDFORK_VERSION); the fork then applies on the first block at or after this time. +// +// SET by the owner (2026-10-20): 2026-10-31 00:00:00 UTC is the mainnet date, and the validator +// announcement must name this value. It MUST stay in the FUTURE for the announced moment to exist at +// all — with a past date the fork activates in whatever block the 17th validator happens to upgrade +// in, with no announceable moment (same warning as 14.hf/15.hf). Change this value together with +// the announcement text, never after one is published. +#define CHAIN_HARDFORK_16_TIME 1793404800 // 2026-10-31 00:00:00 UTC +#define CHAIN_HARDFORK_16_VERSION hardfork_version( version(4, 2, 0) ) +#endif +// Registration itself is unconditional in both configs (the hardfork arrays are sized +// [CHAIN_NUM_HARDFORKS + 1]), so the fork can never be "unknown" to a binary that carries it; what +// gates it is the time above plus +// the 17/21 validator quorum. Rows that were already inside the dispute window at activation carry +// no_contest_fee_frozen == false, which the settlement treats as "legacy row" and prices from the +// current median — exactly the pre-fork behaviour, so no seed pass is needed. The flag is separate +// from the amount on purpose: a frozen fee of 0 (penalty percent voted to 0) must not fall back to +// the median and pick up a later vote; 0-amount and never-frozen are different states. +#endif diff --git a/libraries/chain/include/graphene/chain/database.hpp b/libraries/chain/include/graphene/chain/database.hpp index 58da045c5c..adb57a5f65 100644 --- a/libraries/chain/include/graphene/chain/database.hpp +++ b/libraries/chain/include/graphene/chain/database.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include namespace graphene { namespace chain { @@ -310,14 +311,26 @@ namespace graphene { namespace chain { const fc::time_point_sec when, const account_name_type &validator_owner, const fc::ecc::private_key &block_signing_private_key, - uint32_t skip + uint32_t skip, + // HF16: TC of the skipped round when building past a QC-less head + const fc::optional &tc = + fc::optional(), + // HF16 late-QC fallback: certificate to carry instead of QC(head) + const fc::optional &qc_override = + fc::optional() ); signed_block _generate_block( const fc::time_point_sec when, const account_name_type &validator_owner, const fc::ecc::private_key &block_signing_private_key, - uint32_t skip + uint32_t skip, + // HF16: TC of the skipped round when building past a QC-less head + const fc::optional &tc = + fc::optional(), + // HF16 late-QC fallback: certificate to carry instead of QC(head) + const fc::optional &qc_override = + fc::optional() ); void pop_block(); @@ -426,6 +439,16 @@ namespace graphene { namespace chain { */ uint32_t get_slot_at_time(fc::time_point_sec when) const; + /** Slot length in seconds for slots after the head (HF16: 3 -> 1). */ + uint32_t block_interval() const; + + /** + * Consensus round of a slot time, contiguous across the HF16 switch: + * t/3 up to the switch block S, then S/3 + (t - S). Rounds of QC/TC must use this, + * never t / CHAIN_BLOCK_INTERVAL, or round r-1 does not exist on the first 1 s slot. + */ + uint32_t round_of(fc::time_point_sec t) const; + void shares_sender_recalc_energy(const account_object &receiver, asset tokens); asset create_vesting(const account_object &to_account, asset tokens); @@ -648,6 +671,86 @@ namespace graphene { namespace chain { void check_block_post_validation_chain(); + // ── Block votes and quorum certificates (QC) ───────────────── + // In-memory, node-local: votes are gossiped (dlt_block_vote_message), + // not stored in chainbase, so they never enter undo sessions. + // Step 0 of the QC plan: verify, tally, detect a quorum and + // equivocation, and expose the certificate. Under HF16 the + // highest QC on our branch also advances LIB (node-local, like the + // pre-DLT post-validation LIB); parent_qc in the header is next. + struct block_qc { + uint32_t block_num = 0; + block_id_type block_id; + uint32_t round = 0; + std::vector> signatures; + fc::microseconds formed_after; // first vote -> quorum, for measurement + }; + + /// Verify and tally a vote. Caller holds at least a weak read lock. + /// Returns true when the vote is valid and new (worth relaying). + bool push_block_vote(uint32_t block_num, const block_id_type &block_id, uint32_t round, + const account_name_type &validator, const signature_type &signature); + + /// The certificate formed for a height, if any (node-local view). + fc::optional get_block_qc(uint32_t block_num) const; + + /// Highest height H such that H and H+1 (both <= max_num) are certified + /// on our main branch (2-chain finality); 0 if none. Feeds LIB under HF16. + uint32_t highest_qc_on_main_branch(uint32_t max_num) const; + + /// HF16 header rule: a handoff block carries a valid QC of its parent. + void validate_block_qc(const signed_block &next_block, uint32_t skip) const; + + /// Round of the certificate a block claims to carry (0 = none). Used by + /// HF16 fork choice; validity is checked when the block is applied. + static uint32_t block_cert_round(const signed_block &b); + + /// Votes needed for a QC with the given number of scheduled validators: + /// strictly more than 2/3 and not below the irreversibility threshold. + static uint32_t block_qc_quorum(uint32_t num_scheduled); + + /// Consensus check of one vote against current state: validator is + /// scheduled, has a signing key, and the signature recovers to it. + bool verify_block_vote(uint32_t block_num, const block_id_type &block_id, uint32_t round, + const account_name_type &validator, const signature_type &signature) const; + + /// Consensus check of one timeout vote: validator is scheduled, has a + /// signing key, and the signature over block_timeout_digest recovers to it. + bool verify_timeout_vote(uint32_t round, uint32_t high_qc_round, + const account_name_type &validator, const signature_type &signature) const; + + /// Verify and tally a timeout vote (validator gave up on `round`). + /// Returns true when the vote is valid and new (worth relaying). + bool push_timeout_vote(uint32_t round, uint32_t high_qc_round, + const account_name_type &validator, const signature_type &signature); + + /// HF16 late-QC fallback. QC(head) has not formed in time, but head is of + /// round own_round-1 and certifies its own parent of round own_round-2: + /// then a block of own_round may carry that grandparent QC instead, so a + /// late quorum costs one slot of finality rather than the slot itself. + fc::optional grandparent_qc_for_next_block( + const account_name_type &producer, uint32_t own_round) const; + + /// Keep a parent QC that arrived inside a validated block header, so a + /// node that missed the votes still knows the height is certified + /// (LIB, and a producer building past a QC-less child of it). + void remember_block_qc(const protocol::block_qc_certificate &cert); + + /// HF16: check double-vote proofs a block carries and punish each validator + /// like for a missed block (once per validator and round, newest first). + void apply_equivocation_proofs(const signed_block &b, uint32_t skip); + + /// The timeout certificate formed for a round, if any (node-local view). + fc::optional get_timeout_cert(uint32_t round) const; + + /// HF16: does a block by `producer` on top of head need the parent's QC? + bool block_needs_parent_qc(const account_name_type &producer) const; + + /// HF16: the parent certificate `producer` would put into its next + /// block, built from the node-local QC of head and re-verified against + /// state; empty when not needed or not formed yet. + fc::optional parent_qc_for_next_block(const account_name_type &producer) const; + /** * Compare two fork branches by vote weight (HF12 logic). * Sums wit_obj.votes (on-chain stake) for all unique validators in each branch, @@ -725,6 +828,25 @@ namespace graphene { namespace chain { std::unique_ptr _my; fork_database _fork_db; + + struct block_vote_tally { + fc::time_point first_seen; + std::map votes; + }; + // (block_num, round, block_id) -> votes for exactly that triple + std::map, block_vote_tally> _block_vote_tallies; + // (validator, round) -> (height, id) it voted for. Round = slot: one vote + // per round, so a second id in the same round is equivocation. + std::map, std::pair> _block_vote_by_validator; + // Signature of each counted vote (same key), kept to build double-vote proofs + std::map, signature_type> _block_vote_sigs; + // Double votes seen and not yet carried in a block (one per validator, round) + std::map, protocol::block_equivocation_proof> _pending_equivocations; + std::map _block_qcs; + // HF16 timeouts: round -> (validator -> vote); one vote per (validator, round) + std::map> _timeout_votes; + std::map _timeout_certs; + mutable std::mutex _block_votes_mutex; fc::time_point_sec _hardfork_times[CHAIN_NUM_HARDFORKS + 1]; protocol::hardfork_version _hardfork_versions[CHAIN_NUM_HARDFORKS + 1]; diff --git a/libraries/chain/include/graphene/chain/global_property_object.hpp b/libraries/chain/include/graphene/chain/global_property_object.hpp index 6bca532ab5..38f265645f 100644 --- a/libraries/chain/include/graphene/chain/global_property_object.hpp +++ b/libraries/chain/include/graphene/chain/global_property_object.hpp @@ -195,6 +195,17 @@ namespace graphene { * by epoch) would become unreachable. */ uint64_t pm_batch_settle_bet_cursor = 0; + + /** + * Round anchor (#6737, Q#3109=A): the consensus round of time t is + * round_anchor_round + (t - round_anchor_time) / block_interval(). + * Rounds are stored in QC/TC, so they must never be renumbered: every hardfork that + * changes the slot length re-anchors at the block that applies it (its actual time, + * not the scheduled one: the fork applies once 17/21 voted, possibly later). + * Zero time = never re-anchored, round = t / CHAIN_BLOCK_INTERVAL (pre-HF16). + */ + fc::time_point_sec round_anchor_time; + uint32_t round_anchor_round = 0; }; typedef multi_index_container < @@ -248,5 +259,7 @@ FC_REFLECT((graphene::chain::dynamic_global_property_object), (pm_oracle_gauges_seeded) (pm_batch_settle_cursor) (pm_batch_settle_bet_cursor) + (round_anchor_time) + (round_anchor_round) ) CHAINBASE_SET_INDEX_TYPE(graphene::chain::dynamic_global_property_object, graphene::chain::dynamic_global_property_index) diff --git a/libraries/chain/include/graphene/chain/pm_objects.hpp b/libraries/chain/include/graphene/chain/pm_objects.hpp index 4b01f68eb1..67bacf5dda 100644 --- a/libraries/chain/include/graphene/chain/pm_objects.hpp +++ b/libraries/chain/include/graphene/chain/pm_objects.hpp @@ -95,6 +95,13 @@ namespace graphene { namespace chain { // guaranteed zero-initialized — the generic fc::array ctor leaves data // indeterminate, which would be non-deterministic. p50/p95 are derived on read in the API. fc::array resolution_time_hist; + + // HF16: monotone total of no-contest penalties charged to this oracle — incremented in the + // same modify() that grows total_insurance_slashed, so the no-contest class has exact + // attribution without a virtual operation. Forward-accumulating from activation; rows + // settled before HF16 are NOT backfilled (the aggregate delta is the only record, and + // subtracting it now would be a guess). Display/attribution only — never gates consensus. + share_type no_contest_slashed_total; }; struct by_owner; @@ -199,6 +206,17 @@ namespace graphene { namespace chain { // no-reveal penalty, refunded 80% otherwise). GC never sees status-0 rows by construction // (retention strictly exceeds the worst-case reveal deadline), so no GC adjustment needed. uint32_t open_commits = 0; + // HF16 (no-contest fee freeze): the nominal penalty fixed in pm_no_contest_evaluator at + // the moment the oracle announces the void, from the THEN-current median chain_properties_pm. + // settle_market(win < 0) charges exactly this instead of re-reading the median, so a + // parameter vote inside the pm_dispute_grace_sec window can no longer change the price + // retroactively. The flag is separate from the amount on purpose: a frozen fee of 0 + // (penalty percent voted to 0) must not fall back to the median and pick up a later vote; + // 0-amount and never-frozen are different states. `false` = row announced before HF16 — + // settlement keeps the legacy median computation for it (no seed pass needed; see 16.hf). + // Not display-only: consensus reads both at settlement. + share_type no_contest_fee; + bool no_contest_fee_frozen = false; }; struct by_creator; @@ -931,7 +949,7 @@ FC_REFLECT((graphene::chain::pm_oracle_object), (avg_resolution_time)(penalty_stamps)(bans_received)(last_penalty_stamp_time) (auto_accept_creator)(auto_accept_resolver)(auto_accept)(banned_by)(active_markets) (markets_in_dispute_window)(disputes_awaiting_response)(disputes_awaiting_decision) - (resolved_late_count)(resolution_time_hist)) + (resolved_late_count)(resolution_time_hist)(no_contest_slashed_total)) CHAINBASE_SET_INDEX_TYPE(graphene::chain::pm_oracle_object, graphene::chain::pm_oracle_index) FC_REFLECT((graphene::chain::pm_market_object), @@ -941,7 +959,7 @@ FC_REFLECT((graphene::chain::pm_market_object), (liquidity_fee_earned)(forfeit_pool)(time_penalty_type)(time_penalty_value)(penalty_curve_type) (allow_early_resolution)(allow_cancellation)(allow_batch)(allow_instant_bet)(endogeneity_tier)(current_epoch) (dispute_mode)(dispute_resolver)(dispute_penalty_percent)(decision_url)(decision_reason) - (deferred_claim_count)(open_commits)) + (deferred_claim_count)(open_commits)(no_contest_fee)(no_contest_fee_frozen)) CHAINBASE_SET_INDEX_TYPE(graphene::chain::pm_market_object, graphene::chain::pm_market_index) FC_REFLECT((graphene::chain::pm_outcome_object), diff --git a/libraries/chain/include/graphene/chain/validator_objects.hpp b/libraries/chain/include/graphene/chain/validator_objects.hpp index 44b2a7dbb1..67632fe230 100644 --- a/libraries/chain/include/graphene/chain/validator_objects.hpp +++ b/libraries/chain/include/graphene/chain/validator_objects.hpp @@ -105,6 +105,10 @@ namespace graphene { namespace chain { // HF13: Validator reward sharing /// Fraction of block reward shared with stakeholders; basis points (0-CHAIN_100_PERCENT). uint16_t sharing_rate = 0; + + /// HF16: round of the last double vote punished (a proof must be newer, + /// so each (validator, round) is punished once and proofs cannot repeat). + uint32_t last_equivocation_round = 0; /// Accumulated TOKEN (VIZ) pending distribution to stakeholders at epoch end. /// Stored in TOKEN atomic units; converted to SHARES via create_vesting() at epoch end. /// validator_reward_operation for the validator carries only the validator's own share. @@ -310,7 +314,7 @@ FC_REFLECT( (id)(owner)(created)(url)(votes)(penalty_percent)(counted_votes)(schedule)(virtual_last_update)(virtual_position)(virtual_scheduled_time)(total_missed) (last_aslot)(last_confirmed_block_num)(current_run)(last_supported_block_num)(signing_key)(props) (last_work)(running_version)(hardfork_version_vote)(hardfork_time_vote) - (sharing_rate)(pending_stakeholder_reward)) + (sharing_rate)(pending_stakeholder_reward)(last_equivocation_round)) CHAINBASE_SET_INDEX_TYPE(graphene::chain::validator_object, graphene::chain::validator_index) diff --git a/libraries/chain/pm_evaluator.cpp b/libraries/chain/pm_evaluator.cpp index d5eec112da..6629fad0e6 100644 --- a/libraries/chain/pm_evaluator.cpp +++ b/libraries/chain/pm_evaluator.cpp @@ -557,7 +557,7 @@ void pm_commit_bet_evaluator::do_apply(const pm_commit_bet_operation& o) { uint32_t reveal_window = mp.pm_reveal_window_blocks; uint32_t cur_block = db.head_block_num(); uint32_t epoch_end = ((cur_block / epoch_blocks) + 1) * epoch_blocks; - time_point_sec reveal_dl = now + fc::seconds((int64_t)(epoch_end - cur_block + reveal_window) * CHAIN_BLOCK_INTERVAL); + time_point_sec reveal_dl = now + fc::seconds((int64_t)(epoch_end - cur_block + reveal_window) * db.block_interval()); db.create([&](pm_commit_object& c) { c.market = mkt.id; @@ -1040,12 +1040,29 @@ void pm_no_contest_evaluator::do_apply(const pm_no_contest_operation& o) { ora.markets_in_dispute_window++; // enters disputable window (status3, payout1, no dispute) ora.last_active_time = now; }); + // HF16: fix the price of the void HERE, from the median of the announcing block. Settlement then + // charges this number instead of re-reading the median — otherwise a parameter vote inside the + // dispute window changes the bill after the oracle announced it. Correct base: bets require + // status == 1 (pm_place_bet), and the modify below moves the market to 3, so bets_sum is final. + const bool freeze_fee = db.has_hardfork(CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK); + share_type frozen_fee(0); // every path that writes no_contest_fee goes through the freeze branch + if (freeze_fee && mkt.bets_sum.value > 0) { + const auto& mp = median(db); + frozen_fee = share_type((int64_t)(fc::uint128_t((uint64_t)mp.pm_dispute_fee.amount.value) + * (uint64_t)mp.pm_no_contest_penalty_percent / 10000).lo); + } db.modify(mkt, [&](pm_market_object& m) { m.status = 3; m.payout_status = 1; m.resolved_outcome = -1; m.result_expiration = now; // start the dispute/settle grace from here from_string(m.decision_reason, o.reason); // NO-CONTEST rationale, readable via get_market + if (freeze_fee) { + // The flag marks "announced under HF16" even when the amount is 0: a frozen 0 must not + // fall back to the median later and pick up a vote that happened inside the window. + m.no_contest_fee_frozen = true; + m.no_contest_fee = frozen_fee; + } }); } diff --git a/libraries/chain/pm_process_markets.cpp b/libraries/chain/pm_process_markets.cpp index 5113a86c24..b47e63d59d 100644 --- a/libraries/chain/pm_process_markets.cpp +++ b/libraries/chain/pm_process_markets.cpp @@ -766,19 +766,29 @@ namespace pm_detail { // carries the oracle's no-contest penalty and `uncovered` the forfeit pool (dual use, // documented on the object) — kept as two separate floor divisions so the amounts // match the one-shot implementation exactly. - const auto& mp = median(db); share_type penalty(0); if (st.stake_total.value > 0) { const auto& oidx = db.get_index().indices().get(); auto oit = oidx.find(mkt.oracle); if (oit != oidx.end()) { - int64_t want = (int64_t)(fc::uint128_t((uint64_t)mp.pm_dispute_fee.amount.value) - * fc::uint128_t((uint64_t)mp.pm_no_contest_penalty_percent) / fc::uint128_t(10000)).lo; + // HF16: the price was fixed by pm_no_contest_evaluator in the announcing + // block; charge exactly it. Rows announced before the fork carry + // no_contest_fee_frozen == false and keep the legacy median computation — + // that is why no seed pass is needed at activation. + int64_t want; + if (db.has_hardfork(CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK) && mkt.no_contest_fee_frozen) { + want = mkt.no_contest_fee.value; + } else { + const auto& mp = median(db); + want = (int64_t)(fc::uint128_t((uint64_t)mp.pm_dispute_fee.amount.value) + * fc::uint128_t((uint64_t)mp.pm_no_contest_penalty_percent) / fc::uint128_t(10000)).lo; + } penalty = share_type(want > oit->insurance.value ? oit->insurance.value : want); if (penalty.value > 0) db.modify(*oit, [&](pm_oracle_object& o) { o.insurance -= penalty; o.total_insurance_slashed += penalty; + o.no_contest_slashed_total += penalty; // HF16: exact class attribution }); } } diff --git a/libraries/network/dlt_p2p_messages.cpp b/libraries/network/dlt_p2p_messages.cpp index 559f74ece3..4997110713 100644 --- a/libraries/network/dlt_p2p_messages.cpp +++ b/libraries/network/dlt_p2p_messages.cpp @@ -21,6 +21,8 @@ const dlt_message_type_enum dlt_transaction_message::type = dlt_transact const dlt_message_type_enum dlt_soft_ban_message::type = dlt_soft_ban_message_type; const dlt_message_type_enum dlt_gap_fill_request::type = dlt_gap_fill_request_type; const dlt_message_type_enum dlt_gap_fill_reply::type = dlt_gap_fill_reply_type; +const dlt_message_type_enum dlt_block_vote_message::type = dlt_block_vote_message_type; +const dlt_message_type_enum dlt_timeout_vote_message::type = dlt_timeout_vote_message_type; } // namespace network } // namespace graphene diff --git a/libraries/network/dlt_p2p_node.cpp b/libraries/network/dlt_p2p_node.cpp index bb59ee25f1..66a04cf4ad 100644 --- a/libraries/network/dlt_p2p_node.cpp +++ b/libraries/network/dlt_p2p_node.cpp @@ -309,6 +309,10 @@ static dlt_hello_message unpack_hello_compat(const message& msg) { if (ds.remaining() >= sizeof(node_id_t)) { fc::raw::unpack(ds, hello.node_id); } + // capabilities (uint32) follows node_id; absent on peers that predate it. + if (ds.remaining() >= sizeof(uint32_t)) { + fc::raw::unpack(ds, hello.capabilities); + } // Any remaining bytes are future protocol fields — ignored for forward compat. return hello; } @@ -641,7 +645,9 @@ inline uint32_t frame_msg_type(const std::vector& frame) { // stall a peer's sync or leave a request unanswered. inline bool is_droppable_when_full(uint32_t msg_type) { return msg_type == dlt_fork_status_message_type - || msg_type == dlt_transaction_message_type; + || msg_type == dlt_transaction_message_type + || msg_type == dlt_block_vote_message_type // superseded by the QC / next round + || msg_type == dlt_timeout_vote_message_type; // superseded by the TC / next round } } // anonymous namespace @@ -1018,6 +1024,12 @@ bool dlt_p2p_node::on_message(peer_id peer, const message& msg) { case dlt_gap_fill_reply_type: on_dlt_gap_fill_reply(peer, msg.as()); break; + case dlt_block_vote_message_type: + on_dlt_block_vote(peer, msg.as()); + break; + case dlt_timeout_vote_message_type: + on_dlt_timeout_vote(peer, msg.as()); + break; default: auto ep_it_unk = _peer_states.find(peer); auto ep_unk = (ep_it_unk != _peer_states.end()) ? std::string(ep_it_unk->second.endpoint) : std::to_string(peer); @@ -1056,6 +1068,7 @@ dlt_hello_message dlt_p2p_node::build_hello_message() const { hello.fork_status = _fork_status; hello.node_status = _node_status; hello.node_id = _node_id; // identify ourselves so NAT peers can dedup by node, not IP + hello.capabilities = DLT_CAP_BLOCK_VOTE | DLT_CAP_TIMEOUT_VOTE; return hello; } @@ -1088,6 +1101,18 @@ bool dlt_p2p_node::check_fork_alignment(const dlt_hello_message& hello, return true; } + // ── We are empty, peer serves the chain from genesis ──────── + // Mirror of the rule above. A node started from genesis (fresh testnet, no snapshot) + // has no block to recognise the peer by, so every peer was "not fork-aligned": exchange + // stayed off, blocks #1..#2 were never requested and every later block was unlinkable. + // Only nodes that were connected while #1 was being produced ever got the chain (6 of 21 + // on the HF16 QC stand, #6741). Every block will still be fully validated from genesis on + // apply, so accepting the peer costs nothing; a peer that cannot serve from #1 (a DLT node + // with a pruned range) still needs a snapshot and is not accepted here. + if (_delegate->get_head_block_num() == 0 && hello.dlt_earliest_block <= 1) { + return true; + } + uint32_t our_earliest = _delegate->get_dlt_earliest_block(); uint32_t our_latest = _delegate->get_dlt_latest_block(); @@ -1138,6 +1163,7 @@ void dlt_p2p_node::on_dlt_hello(peer_id peer, const dlt_hello_message& hello) { // Persist node_id — used for dedup and peer-exchange identity. state.node_id = hello.node_id; + state.peer_capabilities = hello.capabilities; // ── Self-connection guard ─────────────────────────────────────────────── // The hello carries OUR node_id ⇒ this socket ends at our own listener. @@ -2502,20 +2528,103 @@ void dlt_p2p_node::broadcast_block(const signed_block& block) { void dlt_p2p_node::broadcast_block_post_validation( const block_id_type& block_id, const std::string& witness_account, - const signature_type& validator_signature) { - // For now, send as fork_status message with block_id - dlt_fork_status_message msg; - msg.fork_status = _fork_status; - msg.head_block_id = block_id; - msg.head_block_num = graphene::protocol::block_header::num_from_id(block_id); - if (_delegate) { - msg.lib_block_id = _delegate->get_lib_block_id(); - msg.lib_block_num = _delegate->get_lib_block_num(); - msg.dlt_earliest_block = _delegate->get_dlt_earliest_block(); - msg.dlt_latest_block = _delegate->get_dlt_latest_block(); + const signature_type& validator_signature, + uint32_t round) { + // The signature is a block vote (block_vote_digest; round = slot of the + // block) made by the validator plugin. Until the DLT redesign is fully QC-aware it travels as + // dlt_block_vote_message; before this the call sent a fork_status and the + // signature never left the node, so LIB fell back to the producer rule. + dlt_block_vote_message msg; + msg.block_num = graphene::protocol::block_header::num_from_id(block_id); + msg.block_id = block_id; + msg.round = round; + msg.validator = witness_account; + msg.signature = validator_signature; + process_block_vote(msg, INVALID_PEER_ID); +} + +void dlt_p2p_node::on_dlt_block_vote(peer_id peer, const dlt_block_vote_message& msg) { + process_block_vote(msg, peer); +} + +void dlt_p2p_node::process_block_vote(const dlt_block_vote_message& msg, peer_id from) { + static constexpr int64_t DLT_BLOCK_VOTE_SEEN_SEC = 120; + const auto now = fc::time_point::now(); + + // Prune the relay-dedup map. Votes older than two minutes are far below LIB + // at any block interval we run, so re-accepting one is harmless (the pool + // already counted it or dropped it as stale). + for (auto it = _block_votes_seen.begin(); it != _block_votes_seen.end(); ) { + if (now - it->second > fc::seconds(DLT_BLOCK_VOTE_SEEN_SEC)) it = _block_votes_seen.erase(it); + else ++it; + } + const auto key = fc::sha256::hash(fc::raw::pack(msg)); + if (!_block_votes_seen.emplace(key, now).second) return; // already handled + + // Verification lives in the chain: scheduled validator + signature over + // block_vote_digest. An invalid vote is neither counted nor relayed, so a + // peer cannot use us to amplify garbage. + if (!_delegate || !_delegate->accept_block_vote(msg)) { + if (from != INVALID_PEER_ID) { + dlog(DLT_LOG_DGRAY "Block vote #${n} by ${v} from peer ${p} not accepted" DLT_LOG_RESET, + ("n", msg.block_num)("v", msg.validator)("p", from)); + } + return; } - msg.node_status = _node_status; - send_to_all_our_fork_peers(message(msg)); + + // Gossip to capable fork peers only: a peer without DLT_CAP_BLOCK_VOTE + // would score the message as an unknown packet against us. + gossip_to_capable(message(msg), DLT_CAP_BLOCK_VOTE, from); +} + +void dlt_p2p_node::gossip_to_capable(const message& out, uint32_t cap, peer_id from) { + std::set sent_to_node_ids; + static const node_id_t zero_id; + std::vector targets; + for (const auto& item : _peer_states) { + const auto& st = item.second; + if (item.first == from) continue; + if (st.lifecycle_state != DLT_PEER_LIFECYCLE_ACTIVE || !st.exchange_enabled) continue; + if (!(st.peer_capabilities & cap)) continue; + if (st.node_id != zero_id && !sent_to_node_ids.insert(st.node_id).second) continue; + targets.push_back(item.first); + } + for (auto id : targets) send_message(id, out); +} + +void dlt_p2p_node::broadcast_timeout_vote(uint32_t round, uint32_t high_qc_round, + const std::string& validator, const signature_type& signature) { + dlt_timeout_vote_message msg; + msg.round = round; + msg.high_qc_round = high_qc_round; + msg.validator = validator; + msg.signature = signature; + process_timeout_vote(msg, INVALID_PEER_ID); +} + +void dlt_p2p_node::on_dlt_timeout_vote(peer_id peer, const dlt_timeout_vote_message& msg) { + process_timeout_vote(msg, peer); +} + +void dlt_p2p_node::process_timeout_vote(const dlt_timeout_vote_message& msg, peer_id from) { + static constexpr int64_t DLT_TIMEOUT_VOTE_SEEN_SEC = 120; + const auto now = fc::time_point::now(); + for (auto it = _timeout_votes_seen.begin(); it != _timeout_votes_seen.end(); ) { + if (now - it->second > fc::seconds(DLT_TIMEOUT_VOTE_SEEN_SEC)) it = _timeout_votes_seen.erase(it); + else ++it; + } + const auto key = fc::sha256::hash(fc::raw::pack(msg)); + if (!_timeout_votes_seen.emplace(key, now).second) return; // already handled + + // Invalid votes are neither counted nor relayed (no amplification). + if (!_delegate || !_delegate->accept_timeout_vote(msg)) { + if (from != INVALID_PEER_ID) { + dlog(DLT_LOG_DGRAY "Timeout vote r${r} by ${v} from peer ${p} not accepted" DLT_LOG_RESET, + ("r", msg.round)("v", msg.validator)("p", from)); + } + return; + } + gossip_to_capable(message(msg), DLT_CAP_TIMEOUT_VOTE, from); } void dlt_p2p_node::broadcast_transaction(const signed_transaction& trx) { @@ -3439,6 +3548,26 @@ void dlt_p2p_node::soft_ban_peer(peer_id peer, const std::string& reason) { auto it = _peer_states.find(peer); if (it == _peer_states.end()) return; + // A trusted peer is never soft-banned: we drop the connection and retry shortly instead. + // Trusted nodes are declared by the operator (admin.p2p.add_trusted), and turning one + // bad packet into an hour of silence defeats the purpose of trusting it. + if (_trusted_peers.count(it->second.endpoint)) { + wlog(DLT_LOG_ORANGE "Trusted peer ${ep}: not banning (${r}); disconnecting and retrying" DLT_LOG_RESET, + ("ep", it->second.endpoint)("r", reason)); + it->second.lifecycle_state = DLT_PEER_LIFECYCLE_DISCONNECTED; + it->second.state_entered_time = fc::time_point::now(); + it->second.ban_reason.clear(); + it->second.ban_duration_sec = 0; + it->second.reconnect_backoff_sec = dlt_peer_state::INITIAL_RECONNECT_BACKOFF_SEC; + it->second.next_reconnect_attempt = fc::time_point::now() + fc::seconds(30); + auto conn_it = _connections.find(peer); + if (conn_it != _connections.end()) { + try { if (conn_it->second) conn_it->second->close(); } catch (...) {} + _connections.erase(conn_it); + } + return; + } + wlog(DLT_LOG_RED "Soft-banning peer ${ep} for ${d}s (reason: ${r})" DLT_LOG_RESET, ("ep", it->second.endpoint)("d", BAN_DURATION_SEC)("r", reason)); @@ -3466,6 +3595,179 @@ void dlt_p2p_node::soft_ban_peer(peer_id peer, const std::string& reason) { } } +// ── Operator control (admin API) ───────────────────────────────────── + +namespace { + +const char* lifecycle_name(dlt_peer_lifecycle_state s) { + switch (s) { + case DLT_PEER_LIFECYCLE_CONNECTING: return "CONNECTING"; + case DLT_PEER_LIFECYCLE_HANDSHAKING: return "HANDSHAKING"; + case DLT_PEER_LIFECYCLE_SYNCING: return "SYNCING"; + case DLT_PEER_LIFECYCLE_ACTIVE: return "ACTIVE"; + case DLT_PEER_LIFECYCLE_DISCONNECTED: return "DISCONNECTED"; + case DLT_PEER_LIFECYCLE_BANNED: return "BANNED"; + } + return "?"; +} + +const char* node_status_name(uint8_t s) { + return s == DLT_NODE_STATUS_FORWARD ? "FORWARD" : "SYNC"; +} + +const char* fork_status_name(uint8_t s) { + switch (s) { + case DLT_FORK_STATUS_NORMAL: return "NORMAL"; + case DLT_FORK_STATUS_LOOKING_RESOLUTION: return "LOOKING_RESOLUTION"; + case DLT_FORK_STATUS_MINORITY: return "MINORITY"; + } + return "?"; +} + +std::string node_id_hex(const node_id_t &id) { + static const char digits[] = "0123456789abcdef"; + bool zero = true; + for (size_t i = 0; i < sizeof(id.data); ++i) { + if (id.data[i] != 0) { zero = false; break; } + } + if (zero) return std::string(); + std::string out; + out.reserve(sizeof(id.data) * 2); + for (size_t i = 0; i < sizeof(id.data); ++i) { + const unsigned char b = static_cast(id.data[i]); + out.push_back(digits[b >> 4]); + out.push_back(digits[b & 0x0f]); + } + return out; +} + +} // namespace + +void dlt_p2p_node::add_peer(const fc::ip::endpoint& ep, bool trusted) { + if (trusted) _trusted_peers.insert(ep); + + dlt_known_peer kp; + kp.endpoint = ep; + kp.last_seen = fc::time_point::now(); + _known_peers.insert(kp); + + // An endpoint we already track may be parked in DISCONNECTED/BANNED with a long + // backoff; clear that so add_peer dials now rather than in an hour. + for (auto& item : _peer_states) { + auto& s = item.second; + if (s.endpoint != ep) continue; + s.ban_reason.clear(); + s.ban_duration_sec = 0; + s.reconnect_backoff_sec = dlt_peer_state::INITIAL_RECONNECT_BACKOFF_SEC; + s.next_reconnect_attempt = fc::time_point(); + break; + } + + connect_to_peer(ep); + ilog(DLT_LOG_GREEN "DLT P2P: operator added peer ${ep}${t}" DLT_LOG_RESET, + ("ep", ep)("t", trusted ? " as trusted" : "")); +} + +uint32_t dlt_p2p_node::clear_banned_peers() { + uint32_t cleared = 0; + for (auto& item : _peer_states) { + auto& s = item.second; + const bool banned = s.lifecycle_state == DLT_PEER_LIFECYCLE_BANNED || !s.ban_reason.empty(); + if (!banned) continue; + + ilog(DLT_LOG_GREEN "DLT P2P: clearing ban for ${ep} (was: ${r})" DLT_LOG_RESET, + ("ep", s.endpoint)("r", s.ban_reason.empty() ? std::string("remote") : s.ban_reason)); + ++cleared; + s.ban_reason.clear(); + s.ban_duration_sec = 0; + s.reconnect_backoff_sec = dlt_peer_state::INITIAL_RECONNECT_BACKOFF_SEC; + s.next_reconnect_attempt = fc::time_point(); + + dlt_known_peer kp; + kp.endpoint = s.endpoint; + kp.last_seen = fc::time_point::now(); + _known_peers.insert(kp); + + // connect_to_peer() reuses this peer_id and resets the per-session fields. + connect_to_peer(s.endpoint); + } + ilog(DLT_LOG_GREEN "DLT P2P: clearing banned peer list — ${n} entries reset" DLT_LOG_RESET, + ("n", cleared)); + return cleared; +} + +dlt_peer_report dlt_p2p_node::get_peer_report() const { + dlt_peer_report r; + const auto now = fc::time_point::now(); + + r.uptime_sec = (_node_start_time.sec_since_epoch() > 0) + ? static_cast((now - _node_start_time).count() / 1000000) + : 0; + r.node_status = node_status_name(_node_status); + r.fork_status = fork_status_name(_fork_status); + r.paused = _block_processing_paused; + r.isolated_peers = _isolated_peers; + r.catching_up = is_catching_up_after_pause(); + r.our_head = _delegate ? _delegate->get_head_block_num() : 0; + r.our_lib = _delegate ? _delegate->get_lib_block_num() : 0; + r.dlt_earliest = _delegate ? _delegate->get_dlt_earliest_block() : 0; + r.dlt_latest = _delegate ? _delegate->get_dlt_latest_block() : 0; + r.max_connections = _max_connections; + r.known_peers = static_cast(_known_peers.size()); + r.trusted_peers = static_cast(_trusted_peers.size()); + + for (const auto& item : _peer_states) { + const auto& s = item.second; + dlt_peer_report_entry e; + e.endpoint = std::string(s.endpoint); + e.node_id = node_id_hex(s.node_id); + e.lifecycle = lifecycle_name(s.lifecycle_state); + e.node_status = node_status_name(s.peer_node_status); + e.fork_status = fork_status_name(s.peer_fork_status); + e.incoming = s.is_incoming; + e.trusted = _trusted_peers.count(s.endpoint) > 0; + e.exchange_enabled = s.exchange_enabled; + e.fork_alignment = s.fork_alignment; + if (s.connected_since.sec_since_epoch() > 0) { + const int64_t sec = (now - s.connected_since).count() / 1000000; + e.connected_sec = sec > 0 ? static_cast(sec) : 0; + } + e.peer_head = s.peer_head_num; + e.peer_lib = s.peer_lib_num; + e.peer_dlt_earliest = s.peer_dlt_earliest; + e.peer_dlt_latest = s.peer_dlt_latest; + e.head_lag = static_cast(r.our_head) - static_cast(s.peer_head_num); + e.spam_strikes = s.spam_strikes; + e.ban_reason = s.ban_reason; + e.ban_duration_sec = s.ban_duration_sec; + e.reconnect_backoff_sec = s.reconnect_backoff_sec; + if (s.next_reconnect_attempt.sec_since_epoch() > 0 && s.next_reconnect_attempt > now) { + e.next_reconnect_in_sec = static_cast( + (s.next_reconnect_attempt - now).count() / 1000000); + } + + if (s.lifecycle_state == DLT_PEER_LIFECYCLE_ACTIVE || + s.lifecycle_state == DLT_PEER_LIFECYCLE_SYNCING) { + ++r.active_count; + } + if (s.lifecycle_state == DLT_PEER_LIFECYCLE_BANNED || !s.ban_reason.empty()) { + ++r.banned_peers; + } + if (s.peer_head_num > r.best_peer_head) r.best_peer_head = s.peer_head_num; + if (s.peer_head_num > r.our_head) ++r.peers_ahead; + + r.peers.push_back(std::move(e)); + } + + r.connection_count = static_cast(_connections.size()); + r.best_peer_head_lag = static_cast(r.our_head) - static_cast(r.best_peer_head); + std::sort(r.peers.begin(), r.peers.end(), + [](const dlt_peer_report_entry& a, const dlt_peer_report_entry& b) { + return a.endpoint < b.endpoint; + }); + return r; +} + // ── Diagnostics ────────────────────────────────────────────────────── void dlt_p2p_node::log_node_status() { @@ -3772,8 +4074,8 @@ void dlt_p2p_node::periodic_known_peers_cleanup() { continue; } - // Skip seeds (always retry-worthy) - bool is_seed = false; + // Skip seeds and operator-trusted peers (always retry-worthy) + bool is_seed = _trusted_peers.count(it->endpoint) > 0; for (const auto& sep : _seed_nodes) { if (sep == it->endpoint) { is_seed = true; break; } } diff --git a/libraries/network/include/graphene/network/dlt_p2p_messages.hpp b/libraries/network/include/graphene/network/dlt_p2p_messages.hpp index 4a5ddc2964..c475feb6c2 100644 --- a/libraries/network/include/graphene/network/dlt_p2p_messages.hpp +++ b/libraries/network/include/graphene/network/dlt_p2p_messages.hpp @@ -41,8 +41,17 @@ enum dlt_message_type_enum { dlt_soft_ban_message_type = 5114, dlt_gap_fill_request_type = 5115, dlt_gap_fill_reply_type = 5116, + dlt_block_vote_message_type = 5117, + dlt_timeout_vote_message_type = 5118, }; +// ── Capability bits advertised in hello.capabilities ──────────────── +// Sent as an optional trailing hello field: peers that predate it ignore the +// extra bytes (unpack_hello_compat), and we never send them a message type they +// would score as an unknown packet. +static const uint32_t DLT_CAP_BLOCK_VOTE = 0x1; // understands dlt_block_vote_message +static const uint32_t DLT_CAP_TIMEOUT_VOTE = 0x2; // understands dlt_timeout_vote_message + // ── DLT node status ───────────────────────────────────────────────── enum dlt_node_status { DLT_NODE_STATUS_SYNC = 0, // catching up @@ -85,6 +94,7 @@ struct dlt_hello_message { // sharing the same NAT IP (different ports). Each node generates a random // keypair at startup. Zero-value means "unknown" (old protocol peer). node_id_t node_id; + uint32_t capabilities = 0; // DLT_CAP_* bits; optional trailing field (absent = 0) }; // ── DLT Hello Reply ───────────────────────────────────────────────── @@ -235,6 +245,34 @@ struct dlt_gap_fill_reply { std::vector blocks; // requested blocks (may be partial) }; +// ── Block vote (QC building block) ────────────────────────────────── +// A scheduled validator's signature over block_vote_digest(chain_id, +// block_num, block_id, round). Gossiped to capable fork peers; a quorum of +// votes for one (block_num, round, block_id) forms a quorum certificate. +// Replaces the pre-HF16 post-validation broadcast, which the DLT network +// redesign reduced to a signature-less fork_status. +struct dlt_block_vote_message { + static const dlt_message_type_enum type; + + uint32_t block_num = 0; + block_id_type block_id; + uint32_t round = 0; + std::string validator; + signature_type signature; +}; + +// HF16 timeout vote: `validator` gave up on `round` without a QC. Signs +// block_timeout_digest(round, high_qc_round); a quorum for one round forms a +// timeout certificate (TC) that lets the next producer skip a QC-less head. +struct dlt_timeout_vote_message { + static const dlt_message_type_enum type; + + uint32_t round = 0; + uint32_t high_qc_round = 0; + std::string validator; + signature_type signature; +}; + } // namespace network } // namespace graphene @@ -257,7 +295,9 @@ FC_REFLECT_ENUM(graphene::network::dlt_message_type_enum, (dlt_transaction_message_type) (dlt_soft_ban_message_type) (dlt_gap_fill_request_type) - (dlt_gap_fill_reply_type)) + (dlt_gap_fill_reply_type) + (dlt_block_vote_message_type) + (dlt_timeout_vote_message_type)) FC_REFLECT_ENUM(graphene::network::dlt_node_status, (DLT_NODE_STATUS_SYNC)(DLT_NODE_STATUS_FORWARD)) @@ -273,7 +313,7 @@ FC_REFLECT_ENUM(graphene::network::dlt_peer_lifecycle_state, FC_REFLECT((graphene::network::dlt_hello_message), (protocol_version)(head_block_id)(head_block_num)(lib_block_id)(lib_block_num) (dlt_earliest_block)(dlt_latest_block)(emergency_active)(has_emergency_key) - (fork_status)(node_status)(node_id)) + (fork_status)(node_status)(node_id)(capabilities)) FC_REFLECT((graphene::network::dlt_hello_reply_message), (exchange_enabled)(fork_alignment)(initiator_head_seen)(initiator_lib_seen) @@ -325,3 +365,9 @@ FC_REFLECT((graphene::network::dlt_gap_fill_request), FC_REFLECT((graphene::network::dlt_gap_fill_reply), (blocks)) + +FC_REFLECT((graphene::network::dlt_block_vote_message), + (block_num)(block_id)(round)(validator)(signature)) + +FC_REFLECT((graphene::network::dlt_timeout_vote_message), + (round)(high_qc_round)(validator)(signature)) diff --git a/libraries/network/include/graphene/network/dlt_p2p_node.hpp b/libraries/network/include/graphene/network/dlt_p2p_node.hpp index 1c2a2d1e14..8930ce01c0 100644 --- a/libraries/network/include/graphene/network/dlt_p2p_node.hpp +++ b/libraries/network/include/graphene/network/dlt_p2p_node.hpp @@ -38,6 +38,57 @@ enum class dlt_block_accept_result { REJECTED ///< Block failed validation entirely (bad signature, etc.) }; +// ── Operator-facing snapshots ───────────────────────────────────────── +// Plain data copies of the P2P state, returned on demand by the admin API. They must be +// read on the P2P thread (the maps they copy belong to it); everything here is already +// formatted (no fc types) so the HTTP layer stays free of network internals. + +struct dlt_peer_report_entry { + std::string endpoint; + std::string node_id; ///< hex of public_key_data, empty before hello + std::string lifecycle; ///< CONNECTING / HANDSHAKING / SYNCING / ACTIVE / DISCONNECTED / BANNED + std::string node_status; ///< SYNC / FORWARD, as last seen from the peer + std::string fork_status; ///< NORMAL / LOOKING_RESOLUTION / MINORITY + bool incoming = false; + bool trusted = false; + bool exchange_enabled = false; + bool fork_alignment = false; + uint32_t connected_sec = 0; + uint32_t peer_head = 0; + uint32_t peer_lib = 0; + uint32_t peer_dlt_earliest = 0; + uint32_t peer_dlt_latest = 0; + int64_t head_lag = 0; ///< our head - peer head; negative = the peer is ahead + uint32_t spam_strikes = 0; + std::string ban_reason; + uint32_t ban_duration_sec = 0; + uint32_t reconnect_backoff_sec = 0; + uint32_t next_reconnect_in_sec = 0; +}; + +struct dlt_peer_report { + uint64_t uptime_sec = 0; + std::string node_status; + std::string fork_status; + bool paused = false; + bool isolated_peers = false; + bool catching_up = false; + uint32_t our_head = 0; + uint32_t our_lib = 0; + uint32_t dlt_earliest = 0; + uint32_t dlt_latest = 0; + uint32_t connection_count = 0; + uint32_t active_count = 0; + uint32_t max_connections = 0; + uint32_t known_peers = 0; + uint32_t trusted_peers = 0; + uint32_t banned_peers = 0; + uint32_t best_peer_head = 0; + int64_t best_peer_head_lag = 0; ///< our head - best peer head; negative = behind + uint32_t peers_ahead = 0; + std::vector peers; +}; + /** * @class dlt_p2p_delegate * @brief Callback interface between dlt_p2p_node and the chain/plugin layer. @@ -68,6 +119,13 @@ class dlt_p2p_delegate { // ── Block/transaction handling ─────────────────────────────── virtual dlt_block_accept_result accept_block(const signed_block& block, bool sync_mode) = 0; virtual bool accept_transaction(const signed_transaction& trx) = 0; + // Verify a block vote (scheduled validator, signature over block_vote_digest) + // and record it. True = valid and not seen before, so it is worth relaying. + // Non-pure: test delegates that never see votes need not implement it. + virtual bool accept_block_vote(const dlt_block_vote_message& vote) { return false; } + // Same contract for a timeout vote (scheduled validator, signature over + // block_timeout_digest). True = valid and new, worth relaying. + virtual bool accept_timeout_vote(const dlt_timeout_vote_message& vote) { return false; } // ── Fork resolution ────────────────────────────────────────── virtual int compare_fork_branches(const block_id_type& a, const block_id_type& b) const = 0; @@ -138,7 +196,10 @@ class dlt_p2p_node { void broadcast_block_post_validation( const block_id_type& block_id, const std::string& witness_account, - const signature_type& validator_signature); + const signature_type& validator_signature, + uint32_t round); + void broadcast_timeout_vote(uint32_t round, uint32_t high_qc_round, + const std::string& validator, const signature_type& signature); void broadcast_transaction(const signed_transaction& trx); void broadcast_chain_status(); @@ -158,6 +219,20 @@ class dlt_p2p_node { dlt_fork_status get_fork_status() const { return _fork_status; } bool is_on_majority_fork() const; + // ── Operator control (admin API) ───────────────────────────── + // All three must be called on the P2P thread: they touch the peer maps. + // + // add_peer() starts dialing immediately and remembers the endpoint so the periodic + // reconnect loop keeps retrying it. trusted=true additionally makes the peer + // exempt from soft-bans: a trusted node that sends bad data is disconnected and + // retried instead of being banned for an hour. + void add_peer(const fc::ip::endpoint& ep, bool trusted); + // Clears local and remote soft-bans, resets backoff and re-dials. Returns the number + // of peer entries that carried a ban. + uint32_t clear_banned_peers(); + // Read-only copy of the state served by admin.p2p.status / the /ui/ page. + dlt_peer_report get_peer_report() const; + // True when block production should be deferred: either during // a block-processing pause (snapshot creation holding DB read lock) // or while catching up after the pause ends (draining queued @@ -233,6 +308,13 @@ class dlt_p2p_node { // ── Gap fill handlers (exchange-only) ──────────────────────── void on_dlt_gap_fill_request(peer_id peer, const dlt_gap_fill_request& req); void on_dlt_gap_fill_reply(peer_id peer, const dlt_gap_fill_reply& reply); + void on_dlt_block_vote(peer_id peer, const dlt_block_vote_message& msg); + // Accept a vote (local or relayed) and gossip it to capable fork peers. + void process_block_vote(const dlt_block_vote_message& msg, peer_id from); + void on_dlt_timeout_vote(peer_id peer, const dlt_timeout_vote_message& msg); + void process_timeout_vote(const dlt_timeout_vote_message& msg, peer_id from); + // Send to active fork peers advertising `cap`, except `from`, once per node id. + void gossip_to_capable(const message& out, uint32_t cap, peer_id from); void request_gap_fill(); // ── Hello construction ─────────────────────────────────────── @@ -391,6 +473,10 @@ class dlt_p2p_node { std::map _peer_states; std::set _known_peers; std::vector _seed_nodes; + // Endpoints the operator marked trusted at runtime (admin.p2p.add_trusted). They are + // kept out of the known-peer GC and never soft-banned. Not persisted: a restart drops + // them unless the operator re-adds them (or lists them in p2p-seed-node). + std::set _trusted_peers; // ── Active connections ─────────────────────────────────────── std::map _connections; @@ -420,6 +506,10 @@ class dlt_p2p_node { // ── Mempool ────────────────────────────────────────────────── std::map _mempool_by_id; + // Relay dedup for block votes: digest of the vote message -> seen time. + // Bounded by pruning entries older than DLT_BLOCK_VOTE_SEEN_SEC. + std::map _block_votes_seen; + std::map _timeout_votes_seen; std::multimap _mempool_by_expiry; uint32_t _mempool_total_bytes = 0; diff --git a/libraries/network/include/graphene/network/dlt_p2p_peer_state.hpp b/libraries/network/include/graphene/network/dlt_p2p_peer_state.hpp index 92255cb4e9..7aacce1333 100644 --- a/libraries/network/include/graphene/network/dlt_p2p_peer_state.hpp +++ b/libraries/network/include/graphene/network/dlt_p2p_peer_state.hpp @@ -42,6 +42,7 @@ struct dlt_peer_state { bool peer_has_emergency_key = false; uint8_t peer_fork_status = DLT_FORK_STATUS_NORMAL; uint8_t peer_node_status = DLT_NODE_STATUS_SYNC; + uint32_t peer_capabilities = 0; // DLT_CAP_* from hello (0 = pre-capability peer) // Our view of this peer bool exchange_enabled = false; // "our fork" diff --git a/libraries/protocol/include/graphene/protocol/base.hpp b/libraries/protocol/include/graphene/protocol/base.hpp index 027a15eaab..bbede92bc4 100644 --- a/libraries/protocol/include/graphene/protocol/base.hpp +++ b/libraries/protocol/include/graphene/protocol/base.hpp @@ -40,10 +40,57 @@ namespace graphene { } }; + /// Quorum certificate of the parent block (HF16). Required when the + /// block hands the turn over (parent produced by another validator): + /// >2/3 of scheduled validators signed block_vote_digest for the parent. + /// Two conflicting certified blocks at one height need a validator to + /// vote twice at that height, which the validator plugin refuses. + struct block_qc_certificate { + uint32_t block_num = 0; + block_id_type block_id; + uint32_t round = 0; + flat_map signatures; + }; + + /// One validator's timeout vote inside a TC: the round of the highest QC + /// it had seen and its signature over block_timeout_digest(round, high_qc_round). + struct block_timeout_vote { + uint32_t high_qc_round = 0; + signature_type signature; + }; + + /// HF16 timeout certificate (Jolteon): a quorum of validators gave up on + /// `round`. A block that skips a QC-less head carries TC(round of that head); + /// its parent QC must be at least the max high_qc_round inside the TC. + struct block_timeout_certificate { + uint32_t round = 0; + flat_map votes; + }; + + /// One signed block vote, as gossiped (see block_vote_digest). + struct block_vote_evidence { + uint32_t block_num = 0; + block_id_type block_id; + signature_type signature; + }; + + /// HF16 double signing: `validator` voted for two different blocks in one + /// round. Carried in a block header; punished like a missed block, once per + /// (validator, round), with the validator_equivocation virtual operation. + struct block_equivocation_proof { + account_name_type validator; + uint32_t round = 0; + block_vote_evidence first; + block_vote_evidence second; + }; + typedef static_variant< void_t, version, // Normal witness version reporting, for diagnostics and voting - hardfork_version_vote // Voting for the next hardfork to trigger + hardfork_version_vote, // Voting for the next hardfork to trigger + block_qc_certificate, // HF16: QC of the parent block (append only: index is wire format) + block_timeout_certificate, // HF16: TC of the skipped round + block_equivocation_proof // HF16: double vote by a validator > block_header_extensions; typedef static_variant< @@ -57,5 +104,10 @@ namespace graphene { } } // graphene::protocol +FC_REFLECT((graphene::protocol::block_qc_certificate), (block_num)(block_id)(round)(signatures)) +FC_REFLECT((graphene::protocol::block_timeout_vote), (high_qc_round)(signature)) +FC_REFLECT((graphene::protocol::block_timeout_certificate), (round)(votes)) +FC_REFLECT((graphene::protocol::block_vote_evidence), (block_num)(block_id)(signature)) +FC_REFLECT((graphene::protocol::block_equivocation_proof), (validator)(round)(first)(second)) FC_REFLECT_TYPENAME((graphene::protocol::block_header_extensions)) FC_REFLECT_TYPENAME((graphene::protocol::future_extensions)) diff --git a/libraries/protocol/include/graphene/protocol/block_vote.hpp b/libraries/protocol/include/graphene/protocol/block_vote.hpp new file mode 100644 index 0000000000..b3717c0b32 --- /dev/null +++ b/libraries/protocol/include/graphene/protocol/block_vote.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +namespace graphene { namespace protocol { + + /// Domain tag of a block vote. Keeps a vote signature from ever being valid + /// as a signature over anything else (transaction, block header, the + /// pre-HF16 post-validation digest of chain_id+block_id). + static const char *const CHAIN_BLOCK_VOTE_DOMAIN = "viz-block-vote-v1"; + + /// Digest a validator signs to vote for block_id at (block_num, round). + /// A quorum of these votes for one (block_num, round, block_id) is a quorum + /// certificate (QC). One signature per (validator, block_num, round) is the + /// safety rule the validator plugin enforces before signing. + inline digest_type block_vote_digest(const chain_id_type &chain_id, uint32_t block_num, + const block_id_type &block_id, uint32_t round) { + digest_type::encoder enc; + fc::raw::pack(enc, std::string(CHAIN_BLOCK_VOTE_DOMAIN)); + fc::raw::pack(enc, chain_id); + fc::raw::pack(enc, block_num); + fc::raw::pack(enc, round); + fc::raw::pack(enc, block_id); + return enc.result(); + } + + /// Domain tag of a timeout vote; distinct from block votes so neither + /// signature can be replayed as the other. + static const char *const CHAIN_BLOCK_TIMEOUT_DOMAIN = "viz-block-timeout-v1"; + + /// Digest a validator signs when it gives up on `round` without a QC. + /// high_qc_round is the round of the highest QC it has seen; signing it + /// binds the claim a TC uses to lower-bound the next block's parent QC. + inline digest_type block_timeout_digest(const chain_id_type &chain_id, uint32_t round, + uint32_t high_qc_round) { + digest_type::encoder enc; + fc::raw::pack(enc, std::string(CHAIN_BLOCK_TIMEOUT_DOMAIN)); + fc::raw::pack(enc, chain_id); + fc::raw::pack(enc, round); + fc::raw::pack(enc, high_qc_round); + return enc.result(); + } + +} } // graphene::protocol diff --git a/libraries/protocol/include/graphene/protocol/chain_operations.hpp b/libraries/protocol/include/graphene/protocol/chain_operations.hpp index 49b99aadca..5623c2d974 100644 --- a/libraries/protocol/include/graphene/protocol/chain_operations.hpp +++ b/libraries/protocol/include/graphene/protocol/chain_operations.hpp @@ -465,7 +465,8 @@ namespace graphene { namespace protocol { FC_ASSERT(inflation_ratio_committee_vs_reward_fund >= 0); FC_ASSERT(inflation_ratio_committee_vs_reward_fund <= CHAIN_100_PERCENT); FC_ASSERT(inflation_recalc_period >= 0); - FC_ASSERT(inflation_recalc_period <= CHAIN_BLOCKS_PER_YEAR); + // HF16 (#6737): one year is 3x more blocks at 1 s; the pre-HF16 bound is kept by the evaluator. + FC_ASSERT(inflation_recalc_period <= CHAIN_BLOCKS_PER_YEAR * 3); } chain_properties_hf4& operator=(const chain_properties_init& src) { @@ -599,7 +600,7 @@ namespace graphene { namespace protocol { FC_ASSERT(distribution_epoch_length >= CHAIN_MIN_DISTRIBUTION_EPOCH_LENGTH, "distribution_epoch_length must be at least ${min} blocks", ("min", CHAIN_MIN_DISTRIBUTION_EPOCH_LENGTH)); - FC_ASSERT(distribution_epoch_length <= uint32_t(CHAIN_BLOCKS_PER_YEAR), + FC_ASSERT(distribution_epoch_length <= uint32_t(CHAIN_BLOCKS_PER_YEAR * 3), // HF16: see evaluator "distribution_epoch_length must not exceed one year in blocks"); } diff --git a/libraries/protocol/include/graphene/protocol/chain_virtual_operations.hpp b/libraries/protocol/include/graphene/protocol/chain_virtual_operations.hpp index 8d69ce63ee..a0605706fb 100644 --- a/libraries/protocol/include/graphene/protocol/chain_virtual_operations.hpp +++ b/libraries/protocol/include/graphene/protocol/chain_virtual_operations.hpp @@ -318,8 +318,24 @@ namespace graphene { namespace protocol { account_name_type stakeholder; asset shares; ///< SHARES credited to stakeholder }; + + // HF16: a block carried proof that `validator` voted for two blocks in + // `round`; the validator is penalised like for a missed block. + struct validator_equivocation_operation : public virtual_operation { + validator_equivocation_operation() {} + + validator_equivocation_operation(const account_name_type& v, uint32_t r, + const block_id_type& a, const block_id_type& b) + : validator(v), round(r), first_block_id(a), second_block_id(b) {} + + account_name_type validator; + uint32_t round = 0; + block_id_type first_block_id; + block_id_type second_block_id; + }; } } //graphene::protocol +FC_REFLECT((graphene::protocol::validator_equivocation_operation), (validator)(round)(first_block_id)(second_block_id)) FC_REFLECT((graphene::protocol::author_reward_operation), (author)(permlink)(token_payout)(vesting_payout)) FC_REFLECT((graphene::protocol::curation_reward_operation), (curator)(reward)(content_author)(content_permlink)) FC_REFLECT((graphene::protocol::content_reward_operation), (author)(permlink)(payout)) diff --git a/libraries/protocol/include/graphene/protocol/config.hpp b/libraries/protocol/include/graphene/protocol/config.hpp index 6d54a0eeb3..ba6b866696 100644 --- a/libraries/protocol/include/graphene/protocol/config.hpp +++ b/libraries/protocol/include/graphene/protocol/config.hpp @@ -2,12 +2,13 @@ #define CHAIN_STARTUP_VERSION (version(1, 0, 0)) #define CHAIN_HARDFORK_STARTUP_VERSION (hardfork_version(CHAIN_STARTUP_VERSION)) -// HF15 (PM audit fixes) — the middle component IS the hardfork version: CHAIN_HARDFORK_VERSION is -// version's hardfork field, and the auto-vote in database.cpp _generate_block fires only while +// HF16 (no-contest fee freeze) — the middle component IS the hardfork version: CHAIN_HARDFORK_VERSION +// is version's hardfork field, and the auto-vote in database.cpp _generate_block fires only while // current_hardfork_version < CHAIN_HARDFORK_VERSION. A new fork therefore has to move THAT field, -// not the revision; 4.1.0 here must stay byte-equal to CHAIN_HARDFORK_15_VERSION in hardfork.d/15.hf, -// which database_hardfork.cpp asserts. See docs/prediction-markets/pm-audit-fix-upgrade.md. -#define CHAIN_VERSION (version(4, 1, 2)) +// not the revision; 4.2.0 here must stay byte-equal to CHAIN_HARDFORK_16_VERSION in +// hardfork.d/16.hf, which database_hardfork.cpp asserts. See +// docs/prediction-markets/pm-no-contest-fee-freeze.md. +#define CHAIN_VERSION (version(4, 2, 0)) #define CHAIN_HARDFORK_VERSION (hardfork_version(CHAIN_VERSION)) #define CHAIN_NAME "VIZ" @@ -30,6 +31,7 @@ #define CHAIN_CLEAR_CLOSED_COMMITTEE_REQUEST_DELAY fc::days(7) #define CHAIN_BLOCK_INTERVAL 3 +#define CHAIN_BLOCK_INTERVAL_HF16 1 // HF16 (#6737, Q#3103=A): slot after the fork block; database::block_interval() #define CHAIN_BLOCK_VALIDATOR_REPEAT 1 #define CHAIN_BLOCKS_PER_YEAR (365*24*60*60/CHAIN_BLOCK_INTERVAL) #define CHAIN_BLOCKS_PER_DAY (24*60*60/CHAIN_BLOCK_INTERVAL) diff --git a/libraries/protocol/include/graphene/protocol/config_testnet.hpp b/libraries/protocol/include/graphene/protocol/config_testnet.hpp index cd5ea5ca75..1c46b0b26a 100644 --- a/libraries/protocol/include/graphene/protocol/config_testnet.hpp +++ b/libraries/protocol/include/graphene/protocol/config_testnet.hpp @@ -2,14 +2,14 @@ #define CHAIN_STARTUP_VERSION (version(1, 0, 0)) #define CHAIN_HARDFORK_STARTUP_VERSION (hardfork_version(CHAIN_STARTUP_VERSION)) -// HF15 (PM audit fixes) — the middle component IS the hardfork version: CHAIN_HARDFORK_VERSION is -// version's hardfork field, and the auto-vote in database.cpp _generate_block fires only while +// HF16 (no-contest fee freeze) — the middle component IS the hardfork version: CHAIN_HARDFORK_VERSION +// is version's hardfork field, and the auto-vote in database.cpp _generate_block fires only while // current_hardfork_version < CHAIN_HARDFORK_VERSION. So a new fork has to move THAT field, not the -// revision; 4.1.0 here must stay byte-equal to CHAIN_HARDFORK_15_VERSION in hardfork.d/15.hf, +// revision; 4.2.0 here must stay byte-equal to CHAIN_HARDFORK_16_VERSION in hardfork.d/16.hf, // which database_hardfork.cpp asserts. config.hpp carries the same value for the production -// config — the two builds differ in chain identity and fork TIMES (15.hf), not in which forks +// config — the two builds differ in chain identity and fork TIMES (16.hf), not in which forks // are registered. -#define CHAIN_VERSION (version(4, 1, 0)) +#define CHAIN_VERSION (version(4, 2, 0)) #define CHAIN_HARDFORK_VERSION (hardfork_version(CHAIN_VERSION)) #define CHAIN_NAME "VIZTEST" @@ -32,6 +32,7 @@ #define CHAIN_CLEAR_CLOSED_COMMITTEE_REQUEST_DELAY fc::minutes(10) #define CHAIN_BLOCK_INTERVAL 3 +#define CHAIN_BLOCK_INTERVAL_HF16 1 // HF16 (#6737, Q#3103=A): slot after the fork block; database::block_interval() #define CHAIN_BLOCK_VALIDATOR_REPEAT 1 #define CHAIN_BLOCKS_PER_YEAR (365*24*60*60/CHAIN_BLOCK_INTERVAL) #define CHAIN_BLOCKS_PER_DAY (24*60*60/CHAIN_BLOCK_INTERVAL) @@ -43,8 +44,17 @@ // Private key: 5JabcrvaLnBTCkCVFX5r4rmeGGfuJuVp4NAKRNLTey6pxhRQmf4 #define CHAIN_INITIATOR_PUBLIC_KEY_STR "VIZ6MyX5QiXAXRZk7SYCiqpi6Mtm8UbHWDFSV8HPpt7FJyahCnc2T" #define CHAIN_INITIATOR_PUBLIC_KEY (graphene::protocol::public_key_type(CHAIN_INITIATOR_PUBLIC_KEY_STR)) +// Multi-validator test network (QC/TC stands): build with +// -DCHAIN_TESTNET_VALIDATORS=N and genesis creates viz, viz1 .. viz(N-1), all +// in the top schedule (one shared initiator key; rotate per validator with +// validator_update after start). Unset = the classic one-validator testnet. +#ifdef CHAIN_TESTNET_VALIDATORS +#define CHAIN_NUM_INITIATORS CHAIN_TESTNET_VALIDATORS +#define CHAIN_MAX_TOP_VALIDATORS CHAIN_TESTNET_VALIDATORS +#else #define CHAIN_NUM_INITIATORS 0 #define CHAIN_MAX_TOP_VALIDATORS 1 +#endif #define CHAIN_MAX_SUPPORT_VALIDATORS 0 #define CHAIN_MAX_VALIDATORS (CHAIN_MAX_TOP_VALIDATORS+CHAIN_MAX_SUPPORT_VALIDATORS) #define CHAIN_HARDFORK_REQUIRED_VALIDATORS 1 diff --git a/libraries/protocol/include/graphene/protocol/operations.hpp b/libraries/protocol/include/graphene/protocol/operations.hpp index 33719140c6..2cf231abee 100644 --- a/libraries/protocol/include/graphene/protocol/operations.hpp +++ b/libraries/protocol/include/graphene/protocol/operations.hpp @@ -157,7 +157,9 @@ namespace graphene { namespace protocol { // #442/#681=D: LP income paid at settlement -> LP's own history (virtual) pm_lp_payout_operation, // HF15 agent access (Onix): account-level delegation of an explicit operation list - set_agent_permission_operation + set_agent_permission_operation, + // HF16 QC: double vote proven in a block header (virtual) + validator_equivocation_operation > operation; /*void operation_get_required_authorities( const operation& op, diff --git a/libraries/protocol/include/graphene/protocol/types.hpp b/libraries/protocol/include/graphene/protocol/types.hpp index 6603c46ea4..512a6c5521 100644 --- a/libraries/protocol/include/graphene/protocol/types.hpp +++ b/libraries/protocol/include/graphene/protocol/types.hpp @@ -102,9 +102,21 @@ namespace graphene { } }; - typedef fc::ripemd160 block_id_type; + // 20-byte identifier. Inherited from Graphene (BitShares -> Steem): the ids are stored in + // fc::ripemd160 because it is a 20-byte container, NOT because they are ripemd160 digests. + // transaction_id_type = first 20 bytes of sha256(packed transaction, without signatures) + // (transaction::id(), transaction.cpp) + // block_id_type = first 20 bytes of sha224(packed header), whose first 4 bytes are + // then replaced by the big-endian block number + // (signed_block_header::id(), block.cpp) + // The alias is pure naming: same type, same bytes, same wire format and reflection, so it + // changes nothing in consensus. See docs/api/ids-and-hashes.md. + typedef fc::ripemd160 id160_type; + typedef id160_type block_id_type; + typedef id160_type transaction_id_type; + // This one IS a real ripemd160: the block merkle root is ripemd160 over the sha256 root of + // the transaction tree (signed_block::calculate_merkle_root(), block.cpp). typedef fc::ripemd160 checksum_type; - typedef fc::ripemd160 transaction_id_type; typedef fc::sha256 digest_type; typedef fc::ecc::compact_signature signature_type; typedef safe share_type; diff --git a/libraries/utilities/CMakeLists.txt b/libraries/utilities/CMakeLists.txt index 89d0e21531..66057c6a0c 100644 --- a/libraries/utilities/CMakeLists.txt +++ b/libraries/utilities/CMakeLists.txt @@ -17,6 +17,7 @@ endif(NOT GRAPHENE_GIT_REVISION_DESCRIPTION) file(GLOB HEADERS "include/graphene/utilities/*.hpp") set(sources + capped_file_appender.cpp key_conversion.cpp string_escape.cpp tempdir.cpp diff --git a/libraries/utilities/capped_file_appender.cpp b/libraries/utilities/capped_file_appender.cpp new file mode 100644 index 0000000000..10cc1e4e44 --- /dev/null +++ b/libraries/utilities/capped_file_appender.cpp @@ -0,0 +1,326 @@ +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#define get_pid _getpid +#else +#include +#define get_pid getpid +#endif + +namespace graphene { +namespace utilities { + +namespace { + +const uint32_t kDefaultMaxLines = 10000; + +// The file is compacted only after it grows past the cap by this many lines. Without +// slack every single line past the cap would trigger a full rewrite (O(n) per line); +// with it the rewrite cost is amortised over ~10% of the file. A 10k-line file is a +// couple of megabytes, so the file oscillates between max_lines and 1.1 * max_lines. +uint64_t compaction_trigger(uint32_t max_lines) { + return static_cast(max_lines) + std::max(16, max_lines / 10); +} + +// Reading a file backwards costs one pass over the tail only; refuse to walk forever +// through a degenerate file that has no line breaks at all (a single 10 GB "line"). +const uint64_t kMaxScanBytes = 64ull * 1024 * 1024; + +/** + * Offset right after the newline that starts the last `keep` lines, or 0 when the + * whole file is that short. `have_boundary` distinguishes "cut at 0" from "cut at 0 + * because everything fits", which matters for skipping the rewrite. + */ +bool find_tail_offset(const std::string &path, uint32_t keep, uint64_t &cut, bool &found, std::string &error) { + cut = 0; + found = false; + + std::ifstream in(path.c_str(), std::ios::binary); + if (!in) { + error = "cannot open " + path + ": " + std::strerror(errno); + return false; + } + in.seekg(0, std::ios::end); + const std::streamoff size = in.tellg(); + if (size <= 0) return true; + + // A trailing newline terminates the last line; it does not open a new one. So a + // file that ends with '\n' needs keep+1 newline boundaries to cut before line N. + char last = 0; + in.seekg(size - 1); + in.read(&last, 1); + const uint64_t newlines_wanted = (last == '\n') ? static_cast(keep) + 1 : keep; + + const std::size_t chunk = 64 * 1024; + std::streamoff pos = size; + uint64_t seen = 0; + uint64_t scanned = 0; + + while (pos > 0) { + const std::size_t take = static_cast(std::min(chunk, pos)); + pos -= take; + std::string piece(take, '\0'); + in.seekg(pos); + in.read(&piece[0], static_cast(take)); + if (in.gcount() != static_cast(take)) { + error = "short read from " + path; + return false; + } + for (std::streamoff i = static_cast(take) - 1; i >= 0; --i) { + if (piece[static_cast(i)] == '\n' && ++seen == newlines_wanted) { + cut = static_cast(pos + i + 1); + found = true; + return true; + } + } + scanned += take; + if (scanned >= kMaxScanBytes) { + error = "no line boundary within " + std::to_string(kMaxScanBytes) + " bytes of " + path; + return false; + } + } + return true; +} + +bool replace_with_tail(const std::string &path, uint64_t cut, std::string &error) { + std::ifstream in(path.c_str(), std::ios::binary); + if (!in) { + error = "cannot open " + path + ": " + std::strerror(errno); + return false; + } + in.seekg(static_cast(cut)); + + const std::string tmp = path + ".trim." + std::to_string(static_cast(get_pid())); + { + std::ofstream out(tmp.c_str(), std::ios::binary | std::ios::trunc); + if (!out) { + error = "cannot create " + tmp + ": " + std::strerror(errno); + return false; + } + out << in.rdbuf(); + out.flush(); + if (!out) { + error = "cannot write " + tmp + ": " + std::strerror(errno); + out.close(); + std::remove(tmp.c_str()); + return false; + } + } + in.close(); + +#ifdef _WIN32 + // std::rename does not replace an existing file on Windows. The appender closes its + // stream before compaction and reopens afterwards, so deleting first is safe here. + std::remove(path.c_str()); +#endif + if (std::rename(tmp.c_str(), path.c_str()) != 0) { + error = "cannot replace " + path + ": " + std::strerror(errno); + std::remove(tmp.c_str()); + return false; + } + return true; +} + +std::string format_line(const fc::log_message &m) { + std::stringstream line; + { + int64_t us = m.get_context().get_timestamp().time_since_epoch().count(); + int ms = static_cast((us / 1000) % 1000); + int64_t s = us / 1000000; + line << std::setfill('0') + << std::setw(2) << static_cast((s / 3600) % 24) << ':' + << std::setw(2) << static_cast((s / 60) % 60) << ':' + << std::setw(2) << static_cast(s % 60) << '.' + << std::setw(3) << ms + << std::setfill(' ') << ' '; + } + line << std::setw(21) + << (m.get_context().get_thread_name().substr(0, 9) + std::string(":") + m.get_context().get_task_name()).c_str() + << " "; + + std::string method_name = m.get_context().get_method(); + if (!method_name.empty()) { + uint32_t p = 0; + for (uint32_t i = 0; i < method_name.size(); ++i) { + if (method_name[i] == ':') p = i; + } + if (method_name[p] == ':') ++p; + line << std::setw(20) << method_name.substr(p, 20).c_str() << " "; + } + + line << "] " << fc::format_string(m.get_format(), m.get_data()).c_str(); + return line.str(); +} + +} // namespace + +class capped_file_appender::impl : public fc::retainable { +public: + config cfg; + std::ofstream out; + boost::mutex slock; + uint64_t lines = 0; + bool warned = false; + + explicit impl(const config &c) : cfg(c) { + try { + if (!cfg.filename.parent_path().string().empty()) fc::create_directories(cfg.filename.parent_path()); + } catch (...) { + // Reported below by the failed open; a bare filename needs no directory. + } + + if (cfg.max_lines > 0) { + std::string error; + if (!capped_file_appender::trim_to_last_lines(cfg.filename, cfg.max_lines, error)) { + warn(error); + } + lines = count_lines_of_file(); + } + open_append(); + } + + void open_append() { + out.open(cfg.filename.string().c_str(), std::ios::out | std::ios::app | std::ios::binary); + if (!out.is_open()) { + error("cannot open log file " + cfg.filename.string() + ": " + std::strerror(errno)); + } + } + + void error(const std::string &text) const { + std::cerr << "capped_file_appender: " << text << "\n"; + } + + void warn(const std::string &text) { + if (warned) return; + warned = true; + std::cerr << "capped_file_appender: " << text << " (log cap is not enforced)\n"; + } + + uint64_t count_lines_of_file() const { + uint64_t n = 0; + std::string error_text; + if (!capped_file_appender::count_lines_in_file(cfg.filename, n, error_text)) { + // Counting is best-effort here: a wrong count only delays or advances the next + // compaction, so report it and keep logging rather than dropping the appender. + error(error_text); + return 0; + } + return n; + } + + // Caller holds slock. Rewrites the file to its last max_lines lines and reopens. + void compact() { + out.flush(); + out.close(); + + std::string error_text; + if (capped_file_appender::trim_to_last_lines(cfg.filename, cfg.max_lines, error_text)) { + lines = count_lines_of_file(); + } else { + // Do not retry on every following line: wait for the next trigger window. + warn(error_text); + lines = 0; + } + open_append(); + } +}; + +capped_file_appender::config::config(const fc::path &p) : + filename(p), + max_lines(kDefaultMaxLines), + flush(true) { +} + +capped_file_appender::capped_file_appender(const fc::variant &args) : + my(new impl(args.as())) { +} + +capped_file_appender::~capped_file_appender() = default; + +bool capped_file_appender::count_lines_in_file(const fc::path &path, uint64_t &lines, std::string &error) { + lines = 0; + error.clear(); + + const std::string name = path.string(); + std::ifstream in(name.c_str(), std::ios::binary); + if (!in) { + if (errno == ENOENT) return true; // nothing written yet + error = "cannot open " + name + ": " + std::strerror(errno); + return false; + } + + std::vector buf(64 * 1024); + char last = 0; + bool any = false; + while (in) { + in.read(&buf[0], static_cast(buf.size())); + const std::streamsize got = in.gcount(); + if (got <= 0) break; + lines += static_cast( + std::count(buf.begin(), buf.begin() + static_cast(got), '\n')); + last = buf[static_cast(got) - 1]; + any = true; + } + if (in.bad()) { + error = "read error on " + name; + lines = 0; + return false; + } + // A file that does not end in '\n' still has that last partial line. + if (any && last != '\n') ++lines; + return true; +} + +bool capped_file_appender::trim_to_last_lines(const fc::path &path, uint32_t keep, std::string &error) { + error.clear(); + if (keep == 0) return true; + + const std::string name = path.string(); + { + std::ifstream probe(name.c_str(), std::ios::binary); + if (!probe) return true; // nothing to trim yet + } + + uint64_t cut = 0; + bool found = false; + if (!find_tail_offset(name, keep, cut, found, error)) return false; + if (!found || cut == 0) return true; // the whole file fits into the cap + + return replace_with_tail(name, cut, error); +} + +void capped_file_appender::log(const fc::log_message &m) { + const std::string line = format_line(m); + + boost::mutex::scoped_lock lock(my->slock); + if (!my->out.is_open()) return; + + my->out << line << "\t\t\t" << m.get_context().get_file() << ":" << m.get_context().get_line_number() << "\n"; + if (my->cfg.flush) my->out.flush(); + ++my->lines; + + if (my->cfg.max_lines > 0 && my->lines >= compaction_trigger(my->cfg.max_lines)) { + my->compact(); + } +} + +} // namespace utilities +} // namespace graphene diff --git a/libraries/utilities/include/graphene/utilities/capped_file_appender.hpp b/libraries/utilities/include/graphene/utilities/capped_file_appender.hpp new file mode 100644 index 0000000000..c81de6028d --- /dev/null +++ b/libraries/utilities/include/graphene/utilities/capped_file_appender.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include + +#include +#include + +namespace graphene { +namespace utilities { + +/** + * File appender that keeps only the last `max_lines` lines of one file. + * + * Why it exists: the stock fc::file_appender rotates by TIME (new file every hour, + * kept for a day). In debug mode an hour of logs can be gigabytes, and 24 hourly + * files of that size overflow a small disk. This appender keeps a single file and + * compacts it in place once it grows past the cap, so the file always holds the + * most recent lines and nothing older. + * + * The appender is OPTIONAL by construction: + * - it is registered as its own type ("capped_file"), so configs that do not use + * it behave exactly as before; + * - an older binary ignores the whole [log.capped_file_appender.*] section (its + * loader does not know the prefix) and an unknown appender type resolves to a + * null appender instead of failing; + * - `max_lines = 0` disables compaction explicitly. + * + * File format is byte-identical to fc::file_appender's text format, so the Logs tab + * of the node_admin plugin (admin.logs.list/tail) needs no changes: it reads a path. + */ +class capped_file_appender : public fc::appender { +public: + struct config { + config(const fc::path &p = "log.txt"); + + fc::path filename; + /// Keep at most this many lines; 0 = unlimited (no compaction). + uint32_t max_lines = 10000; + bool flush = true; + }; + + explicit capped_file_appender(const fc::variant &args); + + ~capped_file_appender() override; + + void log(const fc::log_message &m) override; + + /** + * Rewrite `path` so that it holds only its last `keep` lines. + * + * Reads the file backwards in chunks, so a file that is already far over the cap + * costs one bounded tail read rather than a full scan of 10 GB. The rewrite goes + * through a temporary file in the same directory and an atomic rename, so a reader + * (admin.logs.tail) never sees a half-written file. + * + * @return true when the file is already short enough or was trimmed; false with a + * reason in `error` when it could not be read or replaced. + */ + static bool trim_to_last_lines(const fc::path &path, uint32_t keep, std::string &error); + + /** + * Count the lines of `path`, streaming it in fixed chunks. + * + * The appender needs the current line count to know when to compact. Counting by + * reading the whole file into memory is exactly wrong for this appender: it exists + * for files that may be gigabytes, and the count is also taken when a trim FAILED + * (e.g. disk full, so the temporary file cannot be written) — the worst moment to + * allocate a second copy of a huge log. + * + * A missing file counts as zero lines and is not an error (nothing was written yet). + * + * @return true when the file was read; false with a reason in `error` otherwise. + */ + static bool count_lines_in_file(const fc::path &path, uint64_t &lines, std::string &error); + +private: + class impl; + + fc::shared_ptr my; +}; + +} // namespace utilities +} // namespace graphene + +#include + +FC_REFLECT((graphene::utilities::capped_file_appender::config), (filename)(max_lines)(flush)) diff --git a/plugins/account_history/plugin.cpp b/plugins/account_history/plugin.cpp index 229046bea3..09614dc9a4 100644 --- a/plugins/account_history/plugin.cpp +++ b/plugins/account_history/plugin.cpp @@ -532,6 +532,10 @@ if( options.count(name) ) { \ impacted.insert(op.stakeholder); } + void operator()(const validator_equivocation_operation& op) { + impacted.insert(op.validator); + } + // --- Prediction markets (HF14, Onix) --- // Signed ops: the generic handler already records the signer through the // required authorities, so these overloads add the *other* material diff --git a/plugins/chain/include/graphene/plugins/chain/plugin.hpp b/plugins/chain/include/graphene/plugins/chain/plugin.hpp index 56950ba122..4be2c10510 100644 --- a/plugins/chain/include/graphene/plugins/chain/plugin.hpp +++ b/plugins/chain/include/graphene/plugins/chain/plugin.hpp @@ -137,6 +137,16 @@ namespace graphene { /// Can be called from any plugin that detects corruption at runtime. void attempt_auto_recovery(); + /// Restore chain state from a specific snapshot file, in-process: + /// pause P2P, close and wipe shared memory, import the snapshot, + /// replay dlt_block_log and resume P2P. This is the same sequence as + /// attempt_auto_recovery(), but with an operator-chosen file. + /// Blocks for the whole import, so call it on an fc thread (not on a + /// plain std::thread and not on the p2p thread itself). + /// Returns false and fills `error` if the restore cannot start or fails; + /// a failed import has already wiped the state and requested shutdown. + bool restore_from_snapshot(const std::string& snapshot_path, std::string& error); + /// Apply the chain's runtime corruption policy: recover when enabled, /// otherwise request shutdown. Callers must stop processing the failed item. void handle_shared_memory_corruption(); diff --git a/plugins/chain/plugin.cpp b/plugins/chain/plugin.cpp index 919acc9bb1..feb26d1c85 100644 --- a/plugins/chain/plugin.cpp +++ b/plugins/chain/plugin.cpp @@ -568,9 +568,11 @@ namespace chain { // state so head becomes 0, then come up on empty state. This is the exact // recovery a manual `docker compose up -d --force-recreate` performs, minus // the operator. + bool resync_marker_seen = false; { auto force_resync_marker = my->shared_memory_dir / "force_resync"; if (boost::filesystem::exists(force_resync_marker)) { + resync_marker_seen = true; wlog("Detected force_resync marker (P2P wedge watchdog). Wiping state for a clean " "re-bootstrap (network resync or fresh operator-supplied snapshot)."); std::cerr << " force_resync marker found — wiping state for clean re-bootstrap.\n"; @@ -616,6 +618,37 @@ namespace chain { } } + // ========== force_replay marker (admin repair) ========== + // node_admin's Objects tab writes this marker and exits when an operator asks to + // rebuild state while keeping the block log: replay reindexes every block, which is + // far cheaper than a network resync and is enough whenever the block log itself is + // intact. The marker is removed before the work starts; a crash mid-replay leaves an + // empty state plus an intact block log, and the normal "revision != head" replay picks + // it up on the next boot. + { + auto force_replay_marker = my->shared_memory_dir / "force_replay"; + if (boost::filesystem::exists(force_replay_marker)) { + boost::system::error_code _fr_ec; + if (resync_marker_seen) { + // Both markers asked for a rebuild. The resync above has already wiped state + // AND the block log; replaying an empty log would fail, so the resync wins + // and the stale request is dropped rather than left to crash the next boot. + boost::filesystem::remove(force_replay_marker, _fr_ec); + wlog("force_replay ignored and removed: force_resync already wiped state and " + "the block log in this same startup."); + } else { + wlog("Detected force_replay marker (admin repair). Wiping state and replaying the block log."); + std::cerr << " force_replay marker found — rebuilding state from the block log.\n"; + my->force_replay = true; + my->replay = true; + boost::filesystem::remove(force_replay_marker, _fr_ec); + if (_fr_ec) + wlog("force_replay: failed to remove marker (${e}); may re-trigger next boot.", + ("e", _fr_ec.message())); + } + } + } + my->db.set_flush_interval(my->flush_interval); my->db.add_checkpoints(my->loaded_checkpoints); my->db.set_require_locking(my->check_locks); @@ -1134,6 +1167,110 @@ namespace chain { } } + bool plugin::restore_from_snapshot(const std::string& snapshot_path, std::string& error) { + bool expected = false; + if (!my->recovery_in_progress.compare_exchange_strong(expected, true)) { + error = "another recovery or restore is already in progress"; + return false; + } + struct flag_reset { + std::atomic& flag; + ~flag_reset() { flag.store(false, std::memory_order_release); } + } reset_guard{my->recovery_in_progress}; + + if (snapshot_path.empty()) { + error = "empty snapshot path"; + return false; + } + if (!snapshot_load_callback) { + error = "snapshot plugin is not loaded (add 'plugin = snapshot' to config.ini)"; + return false; + } + if (!bfs::exists(snapshot_path) || !bfs::is_regular_file(snapshot_path)) { + error = "snapshot file not found: " + snapshot_path; + return false; + } + + wlog("=== RESTORE FROM SNAPSHOT: ${p} ===", ("p", snapshot_path)); + + // 1. Stop P2P from pushing blocks into a database that is about to be rebuilt. + // pause_block_processing() itself must run on the p2p thread; this method is + // called from an fc thread, so use the sync wrapper. + try { + auto* p2p_plug = appbase::app().find_plugin(); + if (p2p_plug && p2p_plug->get_state() == appbase::abstract_plugin::started) { + p2p_plug->pause_block_processing_sync(); + wlog("Restore: P2P block processing paused"); + } + } catch (const fc::exception& e) { + wlog("Restore: failed to pause P2P: ${e}", ("e", e.to_detail_string())); + } catch (...) { + wlog("Restore: failed to pause P2P"); + } + + // 2. Gate block production (validator reads is_syncing()). + my->currently_syncing.store(true, std::memory_order_release); + + // 3. Close the current database. open_from_snapshot() in do_snapshot_load() + // reopens it and wipes shared memory for a recovery load. + try { + my->db.close(false); + } catch (...) { + wlog("Restore: ignoring error while closing the database"); + } + + my->snapshot_path = snapshot_path; + auto data_dir = appbase::app().data_dir() / "state"; + + try { + do_snapshot_load(data_dir, true); + } catch (const fc::exception& e) { + error = e.to_detail_string(); + elog("Restore FAILED during snapshot load: ${e}", ("e", error)); + } catch (const std::exception& e) { + error = e.what(); + elog("Restore FAILED during snapshot load: ${e}", ("e", error)); + } catch (...) { + error = "unknown exception during snapshot load"; + elog("Restore FAILED during snapshot load: unknown exception"); + } + + uint32_t head = 0; + try { + head = my->db.head_block_num(); + } catch (...) { + } + + // do_snapshot_load() handles a failed import by wiping the state and requesting + // shutdown, then returns normally. A zero head is the observable trace of that. + if (!error.empty() || head == 0) { + if (error.empty()) error = "snapshot import did not produce state"; + elog("Restore FAILED: ${e}", ("e", error)); + // P2P stays paused and the syncing flag stays set: the state on disk is not + // safe to consume. do_snapshot_load() has already asked the process to quit; + // if it did not, the node must not resume as if nothing happened. + return false; + } + + my->currently_syncing.store(false, std::memory_order_release); + + // 4. Resume P2P. reset_peers_after_recovery() clears soft bans and stale sync + // state, so peers can serve blocks immediately after the import. + try { + auto* p2p_plug = appbase::app().find_plugin(); + if (p2p_plug && p2p_plug->get_state() == appbase::abstract_plugin::started) { + p2p_plug->reset_peers_after_recovery(); + p2p_plug->resume_block_processing(); + wlog("Restore: P2P block processing resumed"); + } + } catch (...) { + wlog("Restore: failed to resume P2P"); + } + + wlog("=== RESTORE COMPLETE: node resumed at block ${n} ===", ("n", head)); + return true; + } + void plugin::wipe_state() { auto data_dir = appbase::app().data_dir() / "state"; my->wipe_db(data_dir, false); diff --git a/plugins/database_api/api.cpp b/plugins/database_api/api.cpp index 04146ad5aa..cda8faeab7 100755 --- a/plugins/database_api/api.cpp +++ b/plugins/database_api/api.cpp @@ -347,7 +347,12 @@ DEFINE_API(plugin, get_config) { } fc::variant_object plugin::api_impl::get_config() const { - return graphene::protocol::get_config(); + // HF16 (#6737): the slot length is chain state, not a compile constant; clients that + // derive times from block counts must see the live value. + fc::mutable_variant_object result(graphene::protocol::get_config()); + result["CHAIN_BLOCK_INTERVAL"] = database().block_interval(); + result["CHAIN_BLOCK_INTERVAL_HF16"] = CHAIN_BLOCK_INTERVAL_HF16; + return result; } DEFINE_API(plugin, get_dynamic_global_properties) { diff --git a/plugins/node_admin/CMakeLists.txt b/plugins/node_admin/CMakeLists.txt new file mode 100644 index 0000000000..5ab986b6d4 --- /dev/null +++ b/plugins/node_admin/CMakeLists.txt @@ -0,0 +1,41 @@ +set(CURRENT_TARGET node_admin) + +list(APPEND CURRENT_TARGET_HEADERS + include/graphene/plugins/node_admin/node_admin_plugin.hpp + ) + +list(APPEND CURRENT_TARGET_SOURCES + node_admin_plugin.cpp + ) + +add_library(graphene_${CURRENT_TARGET} ${VIZ_LIBRARY_TYPE} + ${CURRENT_TARGET_HEADERS} + ${CURRENT_TARGET_SOURCES} + ) + +add_library(graphene::${CURRENT_TARGET} ALIAS graphene_${CURRENT_TARGET}) +set_property(TARGET graphene_${CURRENT_TARGET} PROPERTY EXPORT_NAME ${CURRENT_TARGET}) + +target_link_libraries( + graphene_${CURRENT_TARGET} + graphene_chain + graphene::chain_plugin + graphene::p2p + graphene::snapshot + graphene::validator_guard + graphene::webserver_plugin + graphene::protocol + graphene_utilities + appbase + fc +) + +target_include_directories(graphene_${CURRENT_TARGET} + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include") + +install(TARGETS + graphene_${CURRENT_TARGET} + RUNTIME DESTINATION bin + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib + ) diff --git a/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_plugin.hpp b/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_plugin.hpp new file mode 100644 index 0000000000..6064b212d4 --- /dev/null +++ b/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_plugin.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +#include + +#include + +namespace graphene { +namespace plugins { +namespace node_admin { + +/** + * Administrative API for the node itself. + * + * Listens on a SEPARATE HTTP endpoint (loopback by default) and speaks JSON-RPC 2.0 with + * bearer-token authentication. It is deliberately not part of the public webserver API: + * that endpoint is proxied by nginx, so every remote request arrives inside the node + * looking like 127.0.0.1 — a source-IP check there protects nothing. + * + * The plugin is inert unless `admin-endpoint` is configured. With an endpoint but without + * tokens it refuses to start: an unauthenticated admin port is never a valid configuration. + * + * v1 is READ-ONLY: node_info, diagnostics, plugins.list, config.get, logs.tail, audit.tail. + * Mutating methods (config/plugin edits, restart, validator updates) are later stages and + * will be built on the same listener with plan/apply and an audit trail. + */ +class node_admin_plugin final : public appbase::plugin { +public: + APPBASE_PLUGIN_REQUIRES((graphene::plugins::chain::plugin)) + + constexpr static const char *plugin_name = "node_admin"; + + static const std::string &name() { + static std::string name = plugin_name; + return name; + } + + node_admin_plugin(); + ~node_admin_plugin(); + + void set_program_options( + boost::program_options::options_description &command_line_options, + boost::program_options::options_description &config_file_options + ) override; + + void plugin_initialize(const boost::program_options::variables_map &options) override; + void plugin_startup() override; + void plugin_shutdown() override; + +private: + struct impl; + std::unique_ptr pimpl; +}; + +} // namespace node_admin +} // namespace plugins +} // namespace graphene diff --git a/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_util.hpp b/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_util.hpp new file mode 100644 index 0000000000..2fae1a3840 --- /dev/null +++ b/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_util.hpp @@ -0,0 +1,692 @@ +#pragma once + +// Pure helpers of the node_admin plugin: secret masking, config.ini parsing and admin-token +// parsing. They live in a header (not the plugin TU) so CTest can exercise them without +// starting a node. Behavior here is the contract the plugin relies on; keep it side-effect free. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace graphene { +namespace plugins { +namespace node_admin { +namespace util { + +// ── small string helpers ──────────────────────────────────────────────────────────────────── +inline std::string trim(const std::string &s) { + std::size_t b = 0; + std::size_t e = s.size(); + while (b < e && std::isspace(static_cast(s[b]))) ++b; + while (e > b && std::isspace(static_cast(s[e - 1]))) --e; + return s.substr(b, e - b); +} + +inline std::string to_lower(std::string s) { + for (char &c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +inline std::vector split(const std::string &s, char sep) { + std::vector out; + std::size_t pos = 0; + while (true) { + const std::size_t next = s.find(sep, pos); + if (next == std::string::npos) { + out.push_back(s.substr(pos)); + return out; + } + out.push_back(s.substr(pos, next - pos)); + pos = next + 1; + } +} + +/// Names carried by one `plugin = ...` line. appbase splits that value on whitespace and +/// loads every name, so `plugin = chain p2p` is two plugins; everything that reads the file +/// (the list, the editor, the group filter) has to see the same set or it disagrees with the +/// loader — a live config.ini is written that way. +inline std::vector plugin_names_in_value(const std::string &value) { + std::vector out; + std::size_t i = 0; + while (i < value.size()) { + while (i < value.size() && std::isspace(static_cast(value[i]))) ++i; + const std::size_t start = i; + while (i < value.size() && !std::isspace(static_cast(value[i]))) ++i; + if (i > start) out.push_back(value.substr(start, i - start)); + } + return out; +} + +/// A snapshot file name as accepted from HTTP paths and JSON params: one path component, +/// no traversal, and a snapshot extension. Names come from list_snapshots(), so anything +/// else is a client mistake (or an attempt to reach outside the snapshot directory). +inline bool is_plain_snapshot_name(const std::string &name) { + if (name.empty() || name.size() > 255) return false; + if (name.find('/') != std::string::npos || name.find('\\') != std::string::npos) return false; + if (name == "." || name == "..") return false; + const std::string lower = to_lower(name); + auto ends_with = [&](const char *suffix) { + const std::size_t n = std::strlen(suffix); + return lower.size() > n && lower.compare(lower.size() - n, n, suffix) == 0; + }; + return ends_with(".vizjson") || ends_with(".json"); +} + +// ── secret masking ────────────────────────────────────────────────────────────────────────── +inline bool sensitive_key(const std::string &key) { + const std::string k = to_lower(key); + static const char *needles[] = { + "private-key", "private_key", "password", "secret", "token", "wif", "emergency" + }; + for (const char *n : needles) { + if (k.find(n) != std::string::npos) return true; + } + return false; +} + +/// Redact key material from free text (log tail). Deliberately coarse: a false positive +/// (masking an innocent word) is harmless; a false negative is a leaked WIF. +inline std::string mask_text(const std::string &text) { + static const std::regex wif( + "(^|[^A-Za-z0-9])(5[HJK][1-9A-HJ-NP-Za-km-z]{49}|[KL][1-9A-HJ-NP-Za-km-z]{51})([^A-Za-z0-9]|$)", + std::regex::ECMAScript); + static const std::regex inline_secret( + "((private-key|emergency-private-key|admin-token|password|secret)[[:space:]]*[=:][[:space:]]*)([^[:space:]\\\"]+)", + std::regex::ECMAScript | std::regex::icase); + std::string out = std::regex_replace(text, wif, "$1***$3"); + out = std::regex_replace(out, inline_secret, "$1***"); + return out; +} + +// ── config file ──────────────────────────────────────────────────────────────────────────── +struct cfg_entry { + std::string section; + std::string key; + std::string value; +}; + +inline std::vector parse_config_file(const std::string &path, bool *exists = nullptr) { + std::vector entries; + std::ifstream in(path.c_str()); + if (!in) { + if (exists) *exists = false; + return entries; + } + if (exists) *exists = true; + + std::string section; + std::string line; + while (std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + // Boost's config reader — the one appbase loads config.ini through — treats '#' as a + // comment to the end of the line, so the value must be cut at the same place. Otherwise + // `plugin = chain # why` reaches the plugin list as names "#" and "why", which the + // loader never sees. + const std::size_t hash = line.find('#'); + if (hash != std::string::npos) line.erase(hash); + const std::string t = trim(line); + if (t.empty() || t[0] == ';') continue; + if (t[0] == '[' && t.back() == ']') { + section = trim(t.substr(1, t.size() - 2)); + continue; + } + const std::size_t eq = t.find('='); + if (eq == std::string::npos) { + entries.push_back(cfg_entry{section, trim(t), ""}); + } else { + entries.push_back(cfg_entry{section, trim(t.substr(0, eq)), trim(t.substr(eq + 1))}); + } + } + return entries; +} + +// ── config editor: allow-list ───────────────────────────────────────────────────────────── +// config.ini is the file the next start depends on, so the admin API may rewrite only keys +// from this table. Identity, keys, data locations, listener endpoints and startup modes are +// deliberately absent: those are operator decisions made by hand (design §4.6). Everything +// here is read in plugin_initialize(), so each change needs a restart; the flag stays explicit +// because live-applied keys may be added later. +enum class config_type { boolean, unsigned_int, log_level }; + +struct config_spec { + const char *name; + config_type type; + uint64_t min_value; + uint64_t max_value; + bool needs_restart; + /// Empty for a top-level `key = value`; otherwise the key lives inside `[section]` + /// (`logger.default.level` -> section "logger.default", key "level"). + const char *section = ""; +}; + +/// Split an allow-list name into its ini section and leaf key. A name without a section is a +/// top-level option: section empty, key unchanged. `section` must match config_spec::section, +/// which is what the editor uses to address the line. +inline void config_name_parts(const config_spec &spec, std::string §ion, std::string &key) { + section = spec.section; + key = spec.name; + if (!section.empty() && key.size() > section.size() + 1 && + key.compare(0, section.size(), section) == 0 && key[section.size()] == '.') { + key = key.substr(section.size() + 1); + } +} + +/// Accepted logger levels, in the order the UI shows them (quiet last is deliberate: an operator +/// raising verbosity should not be one stray click from `off`, which silences the log). +inline const std::vector &log_level_values() { + static const std::vector levels = {"all", "debug", "info", "warn", "error", "off"}; + return levels; +} + +/// Which plugin a parameter belongs to, for grouping in the UI and in admin.plugins.list. +/// `logging` is not a plugin: it is the runtime logging section of the same panel. +inline std::string config_key_group(const std::string &name) { + if (name.compare(0, 4, "p2p-") == 0 || name.compare(0, 4, "dlt-") == 0 || + name == "auto-resync-on-wedge") return "p2p"; + if (name.compare(0, 10, "webserver-") == 0) return "webserver"; + if (name.compare(0, 16, "validator-guard-") == 0) return "validator_guard"; + if (name.compare(0, 7, "logger.") == 0 || name.compare(0, 4, "log.") == 0) return "logging"; + return "other"; +} + +inline const std::vector &config_allowlist() { + static const std::vector specs = { + {"p2p-max-connections", config_type::unsigned_int, 1, 10000, true}, + {"dlt-peer-max-disconnect-hours", config_type::unsigned_int, 1, 8760, true}, + {"dlt-mempool-max-tx", config_type::unsigned_int, 1, 1000000, true}, + {"dlt-mempool-max-bytes", config_type::unsigned_int, 1024, 1073741824ULL, true}, + {"dlt-mempool-max-tx-size", config_type::unsigned_int, 256, 67108864, true}, + {"dlt-mempool-max-expiration-hours", config_type::unsigned_int, 1, 720, true}, + {"dlt-peer-exchange-max-per-reply", config_type::unsigned_int, 0, 1000, true}, + {"dlt-peer-exchange-max-per-subnet", config_type::unsigned_int, 0, 1000, true}, + {"dlt-peer-exchange-min-uptime-sec", config_type::unsigned_int, 0, 86400, true}, + {"dlt-stats-interval-sec", config_type::unsigned_int, 10, 86400, true}, + {"p2p-isolated-peers", config_type::boolean, 0, 0, true}, + {"auto-resync-on-wedge", config_type::boolean, 0, 0, true}, + {"webserver-thread-pool-size", config_type::unsigned_int, 1, 4096, true}, + {"webserver-cache-enabled", config_type::boolean, 0, 0, true}, + {"webserver-cache-size", config_type::unsigned_int, 0, 10000000, true}, + {"webserver-stats-enabled", config_type::boolean, 0, 0, true}, + {"webserver-limits-enabled", config_type::boolean, 0, 0, true}, + {"webserver-limit-m", config_type::unsigned_int, 1, 100000000, true}, + {"webserver-limit-h", config_type::unsigned_int, 1, 100000000, true}, + {"webserver-limit-d", config_type::unsigned_int, 1, 100000000, true}, + {"webserver-stats-log-max-size", config_type::unsigned_int, 1024, 1073741824ULL, true}, + {"webserver-stats-log-keep", config_type::unsigned_int, 1, 1000, true}, + {"webserver-stats-interval", config_type::unsigned_int, 1, 86400, true}, + {"validator-guard-enabled", config_type::boolean, 0, 0, true}, + {"validator-guard-interval", config_type::unsigned_int, 1, 3600, true}, + {"validator-guard-disable", config_type::boolean, 0, 0, true}, + {"validator-guard-disable-max", config_type::unsigned_int, 0, 1000, true}, + {"validator-guard-disable-on-shutdown", config_type::boolean, 0, 0, true}, + {"validator-guard-shutdown-grace", config_type::unsigned_int, 1, 3600, true}, + // Logging (owner decision 20.10.2026, Q#3081=A: the parameters panel plus log settings). + // Levels only: appender filenames and rotation windows stay operator decisions, while the + // capped appender's bounds are exactly the knobs against a log that fills the disk. + {"logger.default.level", config_type::log_level, 0, 0, true, "logger.default"}, + {"logger.p2p.level", config_type::log_level, 0, 0, true, "logger.p2p"}, + {"log.capped_file_appender.node.max_lines", config_type::unsigned_int, 0, 10000000, true, + "log.capped_file_appender.node"}, + {"log.capped_file_appender.node.flush", config_type::boolean, 0, 0, true, + "log.capped_file_appender.node"} + }; + return specs; +} + +inline const config_spec *config_spec_for(const std::string &key) { + for (const config_spec &s : config_allowlist()) { + if (key == s.name) return &s; + } + return nullptr; +} + +/// Validate and canonicalize a value. Unknown keys are refused here, so a caller cannot bypass +/// the allow-list by skipping config_spec_for(). +inline bool normalize_config_value(const std::string &key, const std::string &raw, + std::string &normalized, std::string &why) { + const config_spec *spec = config_spec_for(key); + if (!spec) { + why = "key '" + key + "' is not in the admin allow-list"; + return false; + } + const std::string value = trim(raw); + if (spec->type == config_type::log_level) { + const std::string v = to_lower(value); + for (const std::string &level : log_level_values()) { + if (v == level) { + normalized = v; + return true; + } + } + why = "'" + key + "' expects one of: all, debug, info, warn, error, off"; + return false; + } + if (spec->type == config_type::boolean) { + const std::string v = to_lower(value); + if (v == "true" || v == "yes" || v == "on" || v == "1") { + normalized = "true"; + return true; + } + if (v == "false" || v == "no" || v == "off" || v == "0") { + normalized = "false"; + return true; + } + why = "'" + key + "' expects a boolean (true/false)"; + return false; + } + if (value.empty() || value.size() > 19 || + value.find_first_not_of("0123456789") != std::string::npos) { + why = "'" + key + "' expects a non-negative integer"; + return false; + } + const uint64_t parsed = std::strtoull(value.c_str(), nullptr, 10); + if (parsed < spec->min_value || parsed > spec->max_value) { + why = "'" + key + "' must be between " + std::to_string(spec->min_value) + " and " + + std::to_string(spec->max_value); + return false; + } + normalized = std::to_string(parsed); + return true; +} + +// ── config editor: plugin list ───────────────────────────────────────────────────────────── +/// Plugin names registered by programs/vizd/main.cpp (including dependencies that +/// register_dependencies() pulls in, such as json_rpc). A name absent here is refused even when +/// it looks plausible: appbase would abort startup on an unknown plugin, and that is exactly the +/// failure the plan/apply pair exists to prevent. +inline const std::set &known_plugins() { + static const std::set names = { + "chain", "json_rpc", "database_api", "p2p", "webserver", "validator", + "validator_api", "validator_guard", "network_broadcast_api", "account_history", + "account_by_key", "raw_block", "block_archive", "block_info", "committee_api", + "invite_api", "paid_subscription_api", "prediction_market_api", + "custom_protocol_api", "snapshot", "testnet_plugin", "node_admin" + }; + return names; +} + +/// Plugins whose removal would either break the node's reason to exist or cut the admin API +/// itself off; the API refuses to remove them. +inline bool plugin_removal_forbidden(const std::string &name) { + return name == "chain" || name == "node_admin"; +} + +// ── config editor: raw-line transforms ──────────────────────────────────────────────────── +// Lines are edited in place so comments, ordering and unknown keys survive. The functions +// below are pure: they take the current lines and return the new ones. + +struct cfg_line { + enum kind_t { other, header, entry } kind = other; + std::string section; // section the entry belongs to (or the new section for a header) + std::string key; + std::string value; + /// Everything from the first '#' to the end of the line, kept so a rewritten line does not + /// swallow the operator's note. The value itself ends where the loader would end it. + std::string comment; +}; + +/// Classify one raw line (without its '\n', possibly with a trailing '\r'). `current_section` +/// is the section the line starts in; for a header, `section` is the section it opens. +inline cfg_line classify_config_line(const std::string &raw, const std::string ¤t_section) { + cfg_line out; + out.section = current_section; + std::string line = raw; + if (!line.empty() && line.back() == '\r') line.pop_back(); + const std::size_t hash = line.find('#'); + if (hash != std::string::npos) { + out.comment = line.substr(hash); + line.erase(hash); + } + const std::string t = trim(line); + if (t.empty() || t[0] == ';') return out; + if (t[0] == '[' && t.back() == ']') { + out.kind = cfg_line::header; + out.section = trim(t.substr(1, t.size() - 2)); + return out; + } + out.kind = cfg_line::entry; + const std::size_t eq = line.find('='); + if (eq == std::string::npos) { + out.key = t; + } else { + out.key = trim(line.substr(0, eq)); + out.value = trim(line.substr(eq + 1)); + } + return out; +} + +inline std::vector split_lines(const std::string &text) { + std::vector out; + std::size_t pos = 0; + while (pos < text.size()) { + const std::size_t nl = text.find('\n', pos); + if (nl == std::string::npos) { + out.push_back(text.substr(pos)); + break; + } + out.push_back(text.substr(pos, nl - pos)); + pos = nl + 1; + } + return out; +} + +inline std::string join_lines(const std::vector &lines, bool trailing_newline = true) { + std::string out; + for (std::size_t i = 0; i < lines.size(); ++i) { + if (i != 0) out += '\n'; + out += lines[i]; + } + if (trailing_newline && !lines.empty()) out += '\n'; + return out; +} + +inline bool text_uses_crlf(const std::vector &lines) { + for (const std::string &line : lines) { + if (!line.empty() && line.back() == '\r') return true; + } + return false; +} + +struct line_edit { + std::vector lines; + std::size_t changed = 0; + std::string error; +}; + +/// Replace the value of a top-level `key = value` line, or insert the key before the first +/// [section] (a key after a section header would belong to that section, not to the top level). +inline line_edit set_config_value(std::vector lines, const std::string &key, + const std::string &value) { + line_edit out{std::move(lines), 0, ""}; + const bool crlf = text_uses_crlf(out.lines); + std::string section; + std::size_t first_header = std::string::npos; + for (std::size_t i = 0; i < out.lines.size(); ++i) { + const cfg_line l = classify_config_line(out.lines[i], section); + if (l.kind == cfg_line::header) { + section = l.section; + if (first_header == std::string::npos) first_header = i; + continue; + } + if (l.kind != cfg_line::entry || !l.section.empty() || l.key != key) continue; + + std::string raw = out.lines[i]; + const bool cr = !raw.empty() && raw.back() == '\r'; + if (cr) raw.pop_back(); + const std::size_t eq = raw.find('='); + if (eq == std::string::npos) { + std::size_t b = 0; + while (b < raw.size() && std::isspace(static_cast(raw[b]))) ++b; + out.lines[i] = raw.substr(0, b) + key + " = " + value + + (l.comment.empty() ? "" : " " + l.comment) + (cr ? "\r" : ""); + } else { + // Keep the original spacing around '=' so the diff is exactly the value. + std::size_t ws = eq + 1; + while (ws < raw.size() && (raw[ws] == ' ' || raw[ws] == '\t')) ++ws; + out.lines[i] = raw.substr(0, eq + 1) + raw.substr(eq + 1, ws - (eq + 1)) + value + + (l.comment.empty() ? "" : " " + l.comment) + (cr ? "\r" : ""); + } + ++out.changed; + } + if (out.changed == 0) { + const std::string row = key + " = " + value + (crlf ? "\r" : ""); + if (first_header == std::string::npos) { + out.lines.push_back(row); + } else { + out.lines.insert(out.lines.begin() + static_cast(first_header), row); + } + out.changed = 1; + } + return out; +} + +/// Replace the value of `key = value` INSIDE `[section]`. A missing section is an error, not an +/// insert: inventing `[logger.foo]` would configure a logger the node never declares, and the +/// next start would silently ignore it. A missing key inside an existing section is inserted +/// directly under its header, which is where the shipped configs keep single-line options. +inline line_edit set_config_value_in_section(std::vector lines, + const std::string §ion, const std::string &key, + const std::string &value) { + line_edit out{std::move(lines), 0, ""}; + if (section.empty()) { + out.error = "internal: empty section"; + return out; + } + const bool crlf = text_uses_crlf(out.lines); + std::string current; + std::size_t header_at = std::string::npos; + for (std::size_t i = 0; i < out.lines.size(); ++i) { + const cfg_line l = classify_config_line(out.lines[i], current); + if (l.kind == cfg_line::header) { + current = l.section; + if (current == section) header_at = i; + continue; + } + if (current != section || l.kind != cfg_line::entry || l.key != key) continue; + + std::string raw = out.lines[i]; + const bool cr = !raw.empty() && raw.back() == '\r'; + if (cr) raw.pop_back(); + const std::size_t eq = raw.find('='); + if (eq == std::string::npos) { + std::size_t b = 0; + while (b < raw.size() && std::isspace(static_cast(raw[b]))) ++b; + out.lines[i] = raw.substr(0, b) + key + " = " + value + + (l.comment.empty() ? "" : " " + l.comment) + (cr ? "\r" : ""); + } else { + std::size_t ws = eq + 1; + while (ws < raw.size() && (raw[ws] == ' ' || raw[ws] == '\t')) ++ws; + out.lines[i] = raw.substr(0, eq + 1) + raw.substr(eq + 1, ws - (eq + 1)) + value + + (l.comment.empty() ? "" : " " + l.comment) + (cr ? "\r" : ""); + } + ++out.changed; + return out; + } + if (header_at == std::string::npos) { + out.error = "section [" + section + "] is not present in config.ini"; + return out; + } + out.lines.insert(out.lines.begin() + static_cast(header_at) + 1, + key + " = " + value + (crlf ? "\r" : "")); + out.changed = 1; + return out; +} + +/// Current value of `key` inside `[section]`, for the plan diff. +inline bool config_value_in_section(const std::vector &lines, + const std::string §ion, const std::string &key, + std::string &value) { + std::string current; + bool found = false; + for (const std::string &raw : lines) { + const cfg_line l = classify_config_line(raw, current); + if (l.kind == cfg_line::header) { + current = l.section; + continue; + } + if (current == section && l.kind == cfg_line::entry && l.key == key) { + value = l.value; + found = true; + } + } + return found; +} + +/// Add and remove `plugin = ` lines. Validation is part of the transform: an unknown, +/// duplicated, protected, absent or already-configured name yields `error` and the lines are +/// not touched. +inline line_edit edit_plugin_lines(std::vector lines, + const std::vector &add, + const std::vector &remove) { + line_edit out{std::move(lines), 0, ""}; + std::set configured; + std::string section; + for (const std::string &raw : out.lines) { + const cfg_line l = classify_config_line(raw, section); + if (l.kind == cfg_line::header) { + section = l.section; + } else if (l.kind == cfg_line::entry && l.section.empty() && l.key == "plugin" && + !l.value.empty()) { + for (const std::string &name : plugin_names_in_value(l.value)) configured.insert(name); + } + } + + std::set add_set; + std::set remove_set; + for (const std::string &raw : add) { + const std::string name = trim(raw); + if (!known_plugins().count(name)) { + out.error = "unknown plugin '" + name + "'"; + return out; + } + if (!add_set.insert(name).second) { + out.error = "plugin '" + name + "' requested twice"; + return out; + } + if (configured.count(name)) { + out.error = "plugin '" + name + "' is already configured"; + return out; + } + } + for (const std::string &raw : remove) { + const std::string name = trim(raw); + if (!configured.count(name)) { + out.error = "plugin '" + name + "' is not configured"; + return out; + } + if (!remove_set.insert(name).second) { + out.error = "plugin '" + name + "' requested for removal twice"; + return out; + } + if (plugin_removal_forbidden(name)) { + out.error = "plugin '" + name + "' cannot be disabled through the admin API"; + return out; + } + if (add_set.count(name)) { + out.error = "plugin '" + name + "' is both added and removed"; + return out; + } + } + if (add_set.empty() && remove_set.empty()) { + out.error = "nothing to do"; + return out; + } + + std::vector kept; + kept.reserve(out.lines.size() + add_set.size()); + section.clear(); + for (const std::string &raw : out.lines) { + const cfg_line l = classify_config_line(raw, section); + if (l.kind == cfg_line::header) section = l.section; + if (l.kind == cfg_line::entry && l.section.empty() && l.key == "plugin" && + !l.value.empty()) { + // One line can carry several names (`plugin = chain p2p`), so removal is a rewrite + // when some names survive: dropping the whole line would disable a plugin the + // operator never asked to touch. + const std::vector names = plugin_names_in_value(l.value); + std::vector keep_names; + for (const std::string &name : names) { + if (!remove_set.count(name)) keep_names.push_back(name); + } + if (keep_names.size() != names.size()) { + ++out.changed; + if (keep_names.empty()) continue; + std::string line = "plugin = "; + for (std::size_t n = 0; n < keep_names.size(); ++n) { + if (n) line += " "; + line += keep_names[n]; + } + if (!l.comment.empty()) line += " " + l.comment; + if (!raw.empty() && raw.back() == '\r') line += "\r"; + kept.push_back(line); + continue; + } + } + kept.push_back(raw); + } + out.lines = std::move(kept); + + if (!add_set.empty()) { + const bool crlf = text_uses_crlf(out.lines); + std::size_t at = out.lines.size(); + std::size_t first_header = std::string::npos; + bool have_plugin_line = false; + section.clear(); + for (std::size_t i = 0; i < out.lines.size(); ++i) { + const cfg_line l = classify_config_line(out.lines[i], section); + if (l.kind == cfg_line::header) { + section = l.section; + if (first_header == std::string::npos) first_header = i; + } else if (l.kind == cfg_line::entry && l.section.empty() && l.key == "plugin") { + at = i + 1; + have_plugin_line = true; + } + } + if (!have_plugin_line && first_header != std::string::npos) at = first_header; + std::vector rows; + rows.reserve(add_set.size()); + for (const std::string &name : add_set) { + rows.push_back("plugin = " + name + (crlf ? "\r" : "")); + } + out.lines.insert(out.lines.begin() + static_cast(at), rows.begin(), + rows.end()); + out.changed += rows.size(); + } + return out; +} + +// ── tokens ───────────────────────────────────────────────────────────────────────────────── +struct token_info { + std::string label; + std::set scopes; + std::string hash_hex; + uint64_t expires = 0; +}; + +inline token_info parse_token_spec(const std::string &spec) { + // label:scope[,scope]:sha256hex[:expires-unixtime] + const std::vector parts = split(spec, ':'); + FC_ASSERT(parts.size() >= 3 && parts.size() <= 4, + "admin-token: expected 'label:scope[,scope]:sha256hex[:expires]', got '${s}'", + ("s", spec)); + token_info t; + t.label = trim(parts[0]); + FC_ASSERT(!t.label.empty(), "admin-token: empty label"); + + for (const std::string &raw : split(parts[1], ',')) { + const std::string scope = trim(raw); + FC_ASSERT(scope == "read" || scope == "plugins" || scope == "config" || + scope == "process" || scope == "chain" || scope == "p2p" || + scope == "snapshot" || scope == "webserver", + "admin-token: unknown scope '${s}' (allowed: read, plugins, config, process, chain, p2p, snapshot, webserver)", + ("s", scope)); + t.scopes.insert(scope); + } + FC_ASSERT(!t.scopes.empty(), "admin-token: token '${l}' has no scopes", ("l", t.label)); + + t.hash_hex = to_lower(trim(parts[2])); + FC_ASSERT(t.hash_hex.size() == 64 && + t.hash_hex.find_first_not_of("0123456789abcdef") == std::string::npos, + "admin-token: token '${l}' hash is not 64 hex chars", ("l", t.label)); + + if (parts.size() == 4) { + const std::string exp = trim(parts[3]); + FC_ASSERT(!exp.empty() && exp.find_first_not_of("0123456789") == std::string::npos, + "admin-token: token '${l}' has a non-numeric expiry", ("l", t.label)); + t.expires = std::stoull(exp); + } + return t; +} + +} // namespace util +} // namespace node_admin +} // namespace plugins +} // namespace graphene diff --git a/plugins/node_admin/include/graphene/plugins/node_admin/objects_check.hpp b/plugins/node_admin/include/graphene/plugins/node_admin/objects_check.hpp new file mode 100644 index 0000000000..7ee5288190 --- /dev/null +++ b/plugins/node_admin/include/graphene/plugins/node_admin/objects_check.hpp @@ -0,0 +1,213 @@ +#pragma once + +// Judging of the chainbase state view shown by the Objects tab. +// +// The plugin collects facts about the running node (file sizes, index counts, markers) and +// this header turns them into a verdict. It is deliberately free of fc/chainbase/appbase: +// the whole point of separating it is that every branch can be driven by a unit test with a +// hand-built state, including the ones an operator acts on (wipe + replay / resync), where a +// false "corrupted" would be expensive and a false "ok" would hide real damage. + +#include +#include +#include + +namespace graphene { +namespace plugins { +namespace node_admin { +namespace objects_check { + +/// One row per chainbase index (one per object type). +struct index_row { + std::string type; + int64_t type_id = 0; + int64_t objects = 0; + int64_t revision = 0; +}; + +/// Everything the admin plugin can see about the state of a running node. Defaults describe +/// a node whose state directory is missing; the collector fills what it can read. +struct state { + std::string state_dir; + bool state_dir_exists = false; + bool shm_exists = false; + uint64_t shm_bytes = 0; + uint64_t mapped_bytes = 0; + uint64_t free_bytes = 0; + uint64_t reserved_bytes = 0; + int64_t revision = 0; + uint32_t head_block_num = 0; + uint32_t stored_schema = 0; + bool schema_file_present = false; + bool block_log_present = false; + uint64_t block_log_bytes = 0; + bool recovering = false; + std::vector markers; + std::vector indexes; + int64_t total_objects = 0; + int64_t dgp_objects = -1; + int64_t schedule_objects = -1; + int64_t account_objects = -1; + std::string lock_error; +}; + +struct finding { + std::string severity; // "critical" | "warn" + std::string code; + std::string text; +}; + +struct judgement { + std::string verdict; // "ok" | "warn" | "corrupted" | "unreadable" + int critical = 0; + int warnings = 0; + std::vector findings; + std::string recommended_repair; // "" | "replay" | "resync" + bool replay_available = false; +}; + +/// Facts only: the node's schema version differs from the binary that is running it. +inline bool schema_mismatch(const state &s, uint32_t compiled_schema) { + return s.shm_exists && (!s.schema_file_present || s.stored_schema != compiled_schema); +} + +/// What the next startup will really do with a repair marker. The chain plugin takes the +/// snapshot path before it looks at force_replay, so on a --replay-from-snapshot node neither +/// marker does what its name suggests: the state is rebuilt from the snapshot either way. +inline std::string repair_effect(const std::string &mode, bool snapshot_replay_from, + bool snapshot_configured) { + if (mode == "replay" && snapshot_replay_from) { + return "next startup rebuilds state from the configured snapshot and replays the " + "block log on top of it: this node runs with --replay-from-snapshot, which the " + "chain plugin takes before the marker's replay-from-block-1 path"; + } + if (mode == "replay") { + return "next startup wipes shared memory and reindexes the existing block log from block 1"; + } + if (snapshot_configured) { + return "next startup wipes shared memory and the block log, then rebuilds state from the " + "configured snapshot and continues from the network (not from genesis)"; + } + return "next startup wipes shared memory and the block log, then resyncs from peers"; +} + +/// A block log that only carries chainbase's one-byte placeholder has nothing to replay. +/// block_log::open() writes that byte when it creates the files, so on a fresh node (and after +/// a resync wipe) "the file exists" is true while it holds zero blocks. Treating it as a +/// replay source would promise a rebuild that would reindex nothing. +inline bool has_replayable_blocks(const state &s) { + return s.block_log_present && s.block_log_bytes > 1; +} + +inline judgement judge(const state &s, uint32_t compiled_schema) { + judgement j; + auto add = [&](const char *severity, const char *code, const std::string &text) { + finding f; + f.severity = severity; + f.code = code; + f.text = text; + j.findings.push_back(f); + if (std::string(severity) == "critical") ++j.critical; + else if (std::string(severity) == "warn") ++j.warnings; + }; + auto num = [](uint64_t v) { return std::to_string(v); }; + + if (!s.lock_error.empty()) { + add("critical", "read_lock_timeout", + "shared memory did not answer a read lock: " + s.lock_error); + } + if (!s.state_dir_exists) { + add("critical", "state_dir_missing", "shared-memory directory does not exist: " + s.state_dir); + } else if (!s.shm_exists) { + add("critical", "shared_memory_missing", + "shared_memory.bin is absent in " + s.state_dir + "; the node has no state"); + } else { + if (s.mapped_bytes > 0 && s.shm_bytes < s.mapped_bytes) { + add("critical", "shared_memory_truncated", + "shared_memory.bin is " + num(s.shm_bytes) + " bytes but the node maps " + + num(s.mapped_bytes) + "; the file was truncated or replaced under a running process"); + } else if (s.mapped_bytes > 0 && s.shm_bytes > s.mapped_bytes) { + add("warn", "shared_memory_larger_than_mapping", + "shared_memory.bin is " + num(s.shm_bytes) + " bytes, larger than the mapping this " + "process opened (" + num(s.mapped_bytes) + "); something else grew the file under " + "the running node, usually a second process pointed at the same state directory"); + } + } + if (!s.markers.empty()) { + std::string list; + for (const std::string &x : s.markers) { + if (!list.empty()) list += ", "; + list += x; + } + add("critical", "stale_marker", + "marker file(s) present from an interrupted operation: " + list + + "; the next startup will act on them, and until then the state is mid-operation"); + } + if (schema_mismatch(s, compiled_schema)) { + add("critical", "schema_mismatch", + "schema_version " + std::string(s.schema_file_present ? num(s.stored_schema) : "missing") + + " does not match this binary (" + num(compiled_schema) + + "); the next startup wipes shared memory and rebuilds it from a snapshot or block log"); + } + if (s.shm_exists && s.revision != static_cast(s.head_block_num)) { + add("warn", "revision_behind_head", + "index revision " + std::to_string(s.revision) + " differs from head block " + + num(s.head_block_num) + "; the next startup replays the gap from the block log. " + "Normal after a crash, not corruption by itself"); + } + if (s.mapped_bytes > 0 && s.free_bytes < 8ull * 1024 * 1024) { + add("critical", "shared_memory_exhausted", + "only " + num(s.free_bytes) + " bytes are free in the mapped file; the node cannot " + "write another block. Repair does not help: raise shared-file-size or the resize " + "limits and restart"); + } else if (s.mapped_bytes > 0 && s.free_bytes * 20 < s.mapped_bytes) { + add("warn", "shared_memory_low", + "less than 5% of the mapped file is free (" + num(s.free_bytes) + " of " + + num(s.mapped_bytes) + " bytes)"); + } + if (s.head_block_num > 0) { + if (s.dgp_objects != 1) { + add("critical", "global_properties_count", + "dynamic_global_property_object rows = " + std::to_string(s.dgp_objects) + + ", expected exactly 1"); + } + if (s.schedule_objects != 1) { + add("critical", "validator_schedule_count", + "validator_schedule_object rows = " + std::to_string(s.schedule_objects) + + ", expected exactly 1"); + } + if (s.account_objects == 0) { + add("critical", "account_index_empty", + "account_object rows = 0 while head is " + num(s.head_block_num)); + } + } + if (!s.block_log_present && s.head_block_num > 0) { + add("warn", "block_log_missing", + "block_log is absent while head is " + num(s.head_block_num) + + "; a replay cannot rebuild state, only a full resync can"); + } + if (s.recovering) { + add("warn", "recovery_in_progress", + "the chain plugin reports an automatic recovery in progress"); + } + + if (!s.lock_error.empty()) j.verdict = "unreadable"; + else if (j.critical > 0) j.verdict = "corrupted"; + else if (j.warnings > 0) j.verdict = "warn"; + else j.verdict = "ok"; + + // A repair rewrites state, so it is recommended only when rewriting can fix the finding. + // Exhausted shared memory is the exception: the same size would fill up again, so wiping + // alone is not a fix unless something else is also wrong. + const bool repair_helps = + j.verdict == "corrupted" && + !(j.critical == 1 && s.free_bytes < 8ull * 1024 * 1024 && s.mapped_bytes > 0); + if (repair_helps) j.recommended_repair = has_replayable_blocks(s) ? "replay" : "resync"; + j.replay_available = has_replayable_blocks(s); + return j; +} + +} // namespace objects_check +} // namespace node_admin +} // namespace plugins +} // namespace graphene diff --git a/plugins/node_admin/node_admin_plugin.cpp b/plugins/node_admin/node_admin_plugin.cpp new file mode 100644 index 0000000000..c329816ca8 --- /dev/null +++ b/plugins/node_admin/node_admin_plugin.cpp @@ -0,0 +1,5288 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include + +// Must precede any websocketpp header: restores the `io_service` alias that +// Boost >= 1.87 dropped but the vendored websocketpp still spells throughout. +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#include +#endif + +namespace graphene { +namespace plugins { +namespace node_admin { + +namespace bpo = boost::program_options; +namespace bfs = boost::filesystem; +namespace asio = boost::asio; +using boost::asio::ip::tcp; +using websocketpp::connection_hdl; +// CHAIN_VERSION expands to an unqualified `version(4, 2, 0)`; same import as vizd's main.cpp. +using graphene::protocol::version; + +namespace { + +using util::cfg_entry; +using util::classify_config_line; +using util::edit_plugin_lines; +using util::is_plain_snapshot_name; +using util::join_lines; +using util::line_edit; +using util::mask_text; +using util::normalize_config_value; +using util::parse_config_file; +using util::parse_token_spec; +using util::plugin_removal_forbidden; +using util::sensitive_key; +using util::set_config_value; +using util::split; +using util::split_lines; +using util::to_lower; +using util::token_info; +using util::trim; + +// appbase exposes only a typed find_plugin(); the name-based lookup is protected and the +// plugin registry has no public enumeration. So the config list is the visible population, and +// the state below is genuinely observed only for the plugins whose type this TU links. +const appbase::abstract_plugin *observe_plugin(const std::string &name) { + if (name == "chain") return appbase::app().find_plugin(); + if (name == "p2p") return appbase::app().find_plugin(); + if (name == "snapshot") return appbase::app().find_plugin(); + if (name == "webserver") return appbase::app().find_plugin(); + if (name == "validator_guard") return appbase::app().find_plugin(); + if (name == "validator") return appbase::app().find_plugin(); + if (name == node_admin_plugin::name()) return appbase::app().find_plugin(); + return nullptr; +} + +// ── chainbase introspection ───────────────────────────────────────────────────────────────── +// The shared-memory image is the node's whole state. Its file size and the index list are the +// two facts an operator can see from inside a running process; anything deeper (page checks, +// checksum) needs the file closed, so "repair" is a marker honoured at the next startup. +bool ends_with_text(const std::string &s, const char *suffix) { + const std::size_t n = std::strlen(suffix); + return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0; +} + +bool read_uint_file(const bfs::path &p, uint32_t &out) { + std::ifstream f(p.string()); + if (!f) return false; + uint32_t v = 0; + f >> v; + if (f.fail()) return false; + out = v; + return true; +} + +// The facts live in objects_check.hpp together with the judge, so the verdict branches can be +// driven by unit tests without chainbase: see tests/node_admin/node_admin_objects_test.cpp. +using objects_state = objects_check::state; + +// ── limits ────────────────────────────────────────────────────────────────────────────────── +const std::size_t kMaxBodyBytes = 64 * 1024; +const std::size_t kMaxLogBytes = 64 * 1024; +const std::size_t kMaxLines = 2000; +const std::size_t kMaxSnapshotPage = 200; +const std::size_t kAuditRotateBytes = 16 * 1024 * 1024; +const uint32_t kRatePerSecond = 20; +// Snapshot files are served in one HTTP response, so the body has to fit in memory. +// VIZ mainnet snapshots are ~4 MB compressed; the cap is for a pathological file, not +// the normal case. Larger files stay downloadable with scp from the node. +const uint64_t kMaxSnapshotDownloadBytes = 512ULL * 1024 * 1024; + +/// Constant-time comparison for equal-length strings. Token hashes are always 64 hex chars, +/// so an early size check leaks nothing beyond "this is not a sha256 hex digest". +bool ct_equal(const std::string &a, const std::string &b) { + if (a.size() != b.size()) return false; + unsigned char diff = 0; + for (std::size_t i = 0; i < a.size(); ++i) { + diff |= static_cast(a[i] ^ b[i]); + } + return diff == 0; +} + +std::string iso_utc(const fc::time_point_sec &t) { + return t.to_iso_string() + "Z"; +} + +std::string now_iso() { + return iso_utc(fc::time_point_sec(static_cast(fc::time_point::now().sec_since_epoch()))); +} + +std::string unixtime_iso(uint32_t t) { + return iso_utc(fc::time_point_sec(t)); +} + +uint32_t now_unixtime() { + return static_cast(fc::time_point::now().sec_since_epoch()); +} + +// ── config file IO ──────────────────────────────────────────────────────────────────────── +bool read_text_file(const std::string &path, std::string &out, std::string &why) { + std::ifstream in(path.c_str(), std::ios::binary); + if (!in) { + why = "cannot open " + path; + return false; + } + std::ostringstream ss; + ss << in.rdbuf(); + out = ss.str(); + return true; +} + +std::string text_sha256(const std::string &text) { + return fc::sha256::hash(text).str(); +} + +/// Current value of a top-level key, for the plan diff. Keys inside [sections] are a different +/// namespace and are not touched by the editor. +bool top_level_value(const std::vector &lines, const std::string &key, + std::string &value) { + std::string section; + bool found = false; + for (const std::string &raw : lines) { + const util::cfg_line l = util::classify_config_line(raw, section); + if (l.kind == util::cfg_line::header) { + section = l.section; + continue; + } + if (l.kind == util::cfg_line::entry && l.section.empty() && l.key == key) { + value = l.value; + found = true; + } + } + return found; +} + +/// Apply one allow-listed key to the candidate lines, choosing the namespace by the spec: the 29 +/// top-level options keep their old behaviour, and the logging keys (`logger.*`, `log.*`) are +/// edited inside their ini section. Both plan and apply go through here, so the diff shown to the +/// operator and the file that is actually written cannot disagree. +line_edit set_allowed_config_value(std::vector lines, const std::string &key, + const std::string &value) { + const util::config_spec *spec = util::config_spec_for(key); + if (!spec) { + line_edit out{std::move(lines), 0, "key '" + key + "' is not in the admin allow-list"}; + return out; + } + std::string section; + std::string leaf; + util::config_name_parts(*spec, section, leaf); + if (section.empty()) return util::set_config_value(std::move(lines), leaf, value); + return util::set_config_value_in_section(std::move(lines), section, leaf, value); +} + +/// Current value of an allow-listed key in whichever namespace its spec names. +bool allowed_config_value(const std::vector &lines, const std::string &key, + std::string &value) { + const util::config_spec *spec = util::config_spec_for(key); + if (!spec) return false; + std::string section; + std::string leaf; + util::config_name_parts(*spec, section, leaf); + if (section.empty()) return top_level_value(lines, leaf, value); + return util::config_value_in_section(lines, section, leaf, value); +} + +/// Replace config.ini atomically: copy the current file to `.bak-`, write the +/// candidate to a sibling temp file, fsync it, rename over the original and fsync the directory. +/// A failure before the rename leaves the original untouched; a crash after it leaves either +/// the old or the new file, never a half-written one. +bool write_config_atomic(const std::string &path, const std::string &body, + std::string &backup_path, std::string &why) { + const bfs::path target(path); + const bfs::path dir = target.parent_path(); + boost::system::error_code ec; + + if (bfs::exists(target, ec)) { + const std::string stamp = std::to_string(static_cast(now_unixtime())); + backup_path = path + ".bak-" + stamp; + for (int n = 1; bfs::exists(backup_path, ec); ++n) { + backup_path = path + ".bak-" + stamp + "-" + std::to_string(n); + } + bfs::copy_file(target, bfs::path(backup_path), bfs::copy_options::none, ec); + if (ec) { + why = "cannot back up " + path + ": " + ec.message(); + return false; + } + } + +#ifdef _WIN32 + const std::string tmp = path + ".tmp"; +#else + const std::string tmp = path + ".tmp." + std::to_string(::getpid()); +#endif + { + std::ofstream out(tmp.c_str(), std::ios::binary | std::ios::trunc); + if (!out) { + why = "cannot write " + tmp; + return false; + } + out.write(body.data(), static_cast(body.size())); + out.flush(); + if (!out) { + why = "short write to " + tmp; + return false; + } + } +#ifndef _WIN32 + struct stat st; + if (::stat(path.c_str(), &st) == 0) ::chmod(tmp.c_str(), st.st_mode & 07777); + const int fd = ::open(tmp.c_str(), O_RDONLY); + if (fd >= 0) { + if (::fsync(fd) != 0) { + ::close(fd); + why = "cannot fsync " + tmp; + return false; + } + ::close(fd); + } +#endif + bfs::rename(bfs::path(tmp), target, ec); + if (ec) { + why = "cannot replace " + path + ": " + ec.message(); + return false; + } +#ifndef _WIN32 + const int dfd = ::open(dir.string().c_str(), O_RDONLY | O_DIRECTORY); + if (dfd >= 0) { + ::fsync(dfd); + ::close(dfd); + } +#endif + return true; +} + +/// Plan ids are bearer capabilities for one single use, so they come from random_device rather +/// than from a counter: an id that can be guessed is an id somebody else can spend. +std::string random_plan_id() { + std::random_device rd; + std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); + std::uniform_int_distribution dist; + const uint64_t a = dist(gen); + const uint64_t b = dist(gen); + char buf[33]; + std::snprintf(buf, sizeof(buf), "%016llx%016llx", + static_cast(a), static_cast(b)); + return std::string(buf, 32); +} + +// ── tail a file ──────────────────────────────────────────────────────────────────────────── +struct tail_result { + bool ok = false; + std::string error; + uint64_t size = 0; + uint64_t bytes_returned = 0; + std::vector lines; +}; + +int64_t elapsed_ms(const fc::time_point &started) { + return (fc::time_point::now() - started).count() / 1000; +} + +// The admin API is machine-facing: emit real JSON numbers. fc's default formatting quotes every +// int64 above 2^32-1 and every double, which turns byte counts and load averages into strings +// and forces the client to parse them twice. +std::string to_json(const fc::variant &v) { + return fc::json::to_string(v, fc::json::legacy_generator); +} + +int64_t param_int(const fc::variant_object &o, const std::string &key, int64_t def) { + auto it = o.find(key); + if (it == o.end()) return def; + try { + return it->value().as_int64(); + } catch (...) { + return def; + } +} + +std::string param_str(const fc::variant_object &o, const std::string &key, const std::string &def) { + auto it = o.find(key); + if (it == o.end() || !it->value().is_string()) return def; + return it->value().as_string(); +} + +tail_result tail_file(const std::string &path, uint64_t bytes, std::size_t max_lines) { + tail_result r; + std::ifstream in(path.c_str(), std::ios::binary); + if (!in) { + r.error = "cannot open file"; + return r; + } + in.seekg(0, std::ios::end); + const std::streamoff end = in.tellg(); + if (end < 0) { + r.error = "cannot stat file"; + return r; + } + r.size = static_cast(end); + const uint64_t take = std::min(bytes, r.size); + in.seekg(static_cast(r.size - take), std::ios::beg); + std::string buf(static_cast(take), '\0'); + in.read(&buf[0], static_cast(take)); + buf.resize(static_cast(in.gcount())); + r.bytes_returned = buf.size(); + + std::vector all = split(buf, '\n'); + // A tail that starts mid-line produces a truncated first line; drop it unless we read + // the whole file (then the first line is genuine). + if (take < r.size && !all.empty()) all.erase(all.begin()); + // Files end with a newline, so the final split element is an empty string — not a line. + if (!all.empty() && all.back().empty()) all.pop_back(); + if (all.size() > max_lines) { + all.erase(all.begin(), all.end() - static_cast(max_lines)); + } + r.lines = std::move(all); + r.ok = true; + return r; +} + +// ── built-in UI ────────────────────────────────────────────────────────────────────────── +// A single static page served at /ui/. It holds no secrets and no state: the operator +// pastes a bearer token, the page keeps it in sessionStorage and calls the same JSON-RPC +// endpoint. Vanilla JS, no build step, no external assets (CSP forbids them anyway). +const char *ui_page() { + return R"HTML( + + + + +VIZ node admin + + + +
+

VIZ node admin

+ + + no token + — +
+ + +
+
+

Node

+
+
+ + + + + + + + + + + + +
+ + + +)HTML"; +} + +// ── websocketpp server config (same stub-log shape as webserver_plugin) ───────────────────── +struct asio_with_stub_log : public websocketpp::config::asio { + typedef asio_with_stub_log type; + typedef asio base; + + typedef base::concurrency_type concurrency_type; + typedef base::request_type request_type; + typedef base::response_type response_type; + typedef base::message_type message_type; + typedef base::con_msg_manager_type con_msg_manager_type; + typedef base::endpoint_msg_manager_type endpoint_msg_manager_type; + typedef base::alog_type alog_type; + typedef base::elog_type elog_type; + typedef base::rng_type rng_type; + + struct transport_config : public base::transport_config { + typedef type::concurrency_type concurrency_type; + typedef type::alog_type alog_type; + typedef type::elog_type elog_type; + typedef type::request_type request_type; + typedef type::response_type response_type; + typedef websocketpp::transport::asio::basic_socket::endpoint socket_type; + }; + + typedef websocketpp::transport::asio::endpoint transport_type; + + static const long timeout_open_handshake = 0; +}; + +typedef websocketpp::server websocket_server_type; + +// ── JSON-RPC error ───────────────────────────────────────────────────────────────────────── +struct rpc_error { + int code; + std::string message; + std::string reason; + std::string hint; +}; + +} // namespace + +// ── impl ─────────────────────────────────────────────────────────────────────────────────── +struct node_admin_plugin::impl { + explicit impl(chain::plugin &c) : chain(c) {} + + chain::plugin &chain; + + // listener + asio::io_context ios; + websocket_server_type server; + std::thread thread; + std::unique_ptr endpoint; + bool listening = false; + fc::time_point started_at = fc::time_point::now(); + + // auth / policy + std::vector tokens; + std::string audit_path; + std::map log_files; // name -> path + bool allow_remote = false; + + // rate limit: label -> (window start sec, count in window) + std::mutex rate_mutex; + std::map> rate; + + std::atomic request_seq{0}; + std::mutex audit_mutex; + + // plan/apply for config.ini (design §0.3). A plan is a single-use, token-bound snapshot of + // "what will be written": the file's hash at plan time, the exact key/value set or plugin + // edit, and an expiry. apply re-checks the hash, so a hand edit between plan and apply is a + // refusal, not an overwrite. + struct pending_plan { + std::string id; + std::string label; + std::string kind; // "config" | "plugins" + uint32_t created = 0; + uint32_t expires = 0; + std::string base_sha256; + std::map set; // config + std::vector add; // plugins + std::vector remove; // plugins + // validators: [{name, action, signing_key, expected_key, hold_from}] + fc::variants actions; + fc::variant diff; + }; + std::mutex plans_mutex; + std::map plans; + // Serializes the read-hash-write of an apply: without it two concurrent applies each + // pass their own hash check and the later rename silently drops the other's edit. + std::mutex config_write_mutex; + uint32_t plan_ttl_sec = 120; + std::atomic restart_requested{false}; + // Startup mode of the chain plugin, captured while node_admin still sees the option + // map. It decides what a repair marker really does: with --replay-from-snapshot the + // chain plugin takes the snapshot path before it ever looks at force_replay, so the + // marker's "replay from block 1" is superseded, and a force_resync comes back from the + // snapshot rather than from genesis. Reporting the generic effect there would be a lie. + bool snapshot_replay_from = false; + bool snapshot_configured = false; + + void store_plan(const pending_plan &p); + pending_plan take_plan(const std::string &id, const token_info &token, const std::string &kind); + + // ── listener ─────────────────────────────────────────────────────────────────────────── + void start(); + void stop(); + void handle_http(connection_hdl hdl); + + // ── auth ─────────────────────────────────────────────────────────────────────────────── + const token_info *authenticate(const std::string &header, std::string &why) const; + bool rate_ok(const std::string &label); + + // ── dispatch ─────────────────────────────────────────────────────────────────────────── + fc::variant dispatch(const std::string &method, const fc::variant ¶ms, + const token_info &token); + fc::variant rpc_ok(const fc::variant &id, const fc::variant &result) const; + fc::variant rpc_err(const fc::variant &id, const rpc_error &e) const; + + // ── methods ──────────────────────────────────────────────────────────────────────────── + fc::mutable_variant_object make_node_info() const; + fc::variant h_auth_me(const token_info &token); + fc::variant h_ping(const fc::variant ¶ms); + fc::variant h_node_info(const fc::variant ¶ms); + fc::variant h_diagnostics(const fc::variant ¶ms); + fc::variant h_plugins_list(const fc::variant ¶ms); + fc::variant h_plugins_status(const fc::variant ¶ms); + fc::variant h_config_get(const fc::variant ¶ms); + fc::variant h_objects_list(const fc::variant ¶ms); + fc::variant h_objects_check(const fc::variant ¶ms); + fc::variant h_objects_repair(const fc::variant ¶ms); + objects_state collect_objects() const; + fc::variant h_logs_list(const fc::variant ¶ms); + fc::variant h_logs_tail(const fc::variant ¶ms); + fc::variant h_audit_tail(const fc::variant ¶ms); + fc::variant h_validators_active(const fc::variant ¶ms); + + // ── P2P operator methods ────────────────────────────────────────────────────────────── + graphene::plugins::p2p::p2p_plugin &p2p_started() const; + fc::variant h_p2p_status(const fc::variant ¶ms); + fc::variant h_p2p_add_node(const fc::variant ¶ms, bool trusted); + fc::variant h_p2p_clear_banned(const fc::variant ¶ms); + + // ── snapshot operator methods ───────────────────────────────────────────────────────── + graphene::plugins::snapshot::snapshot_plugin &snapshot_started() const; + fc::variant snapshot_list_variant() const; + fc::variant h_snapshot_status(const fc::variant ¶ms); + fc::variant h_snapshot_create(const fc::variant ¶ms); + fc::variant h_snapshot_restore(const fc::variant ¶ms); + + // ── webserver operator methods ──────────────────────────────────────────────────────── + graphene::plugins::webserver::webserver_plugin &webserver_started() const; + fc::variant h_webserver_status(const fc::variant ¶ms); + fc::variant h_webserver_ips(const fc::variant ¶ms); + fc::variant h_webserver_limits(const fc::variant ¶ms); + fc::variant h_webserver_stats(const fc::variant ¶ms); + fc::variant h_webserver_list_add(const fc::variant ¶ms, bool whitelist); + fc::variant h_webserver_list_remove(const fc::variant ¶ms, bool whitelist); + fc::variant h_webserver_reset(const fc::variant ¶ms); + fc::variant h_webserver_reload(const fc::variant ¶ms); + + // ── validator chain operations (delegate signed through validator_guard) ────────────── + // The plugin keeps both keys and never exposes them; node_admin only drives it and + // reports the outcome. The `chain` scope additionally requires a live guard. + graphene::plugins::validator_guard::validator_guard_plugin *guard_plugin() const; + bool guard_ready() const; + fc::variant h_validator_list(const fc::variant ¶ms); + fc::variant h_validator_plan(const fc::variant ¶ms, const token_info &token); + fc::variant h_validator_apply(const fc::variant ¶ms, const token_info &token); + + // ── config/plugin editor and restart (plan/apply) ───────────────────────────────────── + fc::variant h_config_plan(const fc::variant ¶ms, const token_info &token); + fc::variant h_config_apply(const fc::variant ¶ms, const token_info &token); + fc::variant h_config_backups(const fc::variant ¶ms); + fc::variant h_plugins_plan(const fc::variant ¶ms, const token_info &token); + fc::variant h_plugins_apply(const fc::variant ¶ms, const token_info &token); + fc::variant h_plan_cancel(const fc::variant ¶ms, const token_info &token); + fc::variant h_restart(const fc::variant ¶ms); + + // ── MCP (Streamable HTTP) ────────────────────────────────────────────────────────────── + fc::variant mcp_tools() const; + fc::variant h_mcp_initialize(const fc::variant ¶ms); + fc::variant h_mcp_ping(const fc::variant ¶ms); + fc::variant h_mcp_tools_list(const fc::variant ¶ms); + fc::variant h_mcp_tools_call(const fc::variant ¶ms, const token_info &token); + + // ── audit ────────────────────────────────────────────────────────────────────────────── + void audit(const std::string &rid, const std::string &label, const std::string &method, + const fc::variant ¶ms, const std::string &outcome, int code, + int64_t duration_ms, const std::string &remote); + void audit_line(const std::string &line); + + std::string data_dir() const { return appbase::app().data_dir().string(); } + std::string config_path() const { return appbase::app().config_path().string(); } + + void respond(websocket_server_type::connection_ptr con, + websocketpp::http::status_code::value status, const std::string &body, + const std::string &content_type = "application/json", + int64_t content_length_override = -1) { + try { + con->append_header("Content-Type", content_type); + con->append_header("Cache-Control", "no-store"); + con->set_body(body); + // set_body rewrites Content-Length (0 for a HEAD reply), so an explicit + // length must be applied after it, not before. + if (content_length_override >= 0) { + con->replace_header("Content-Length", + std::to_string(content_length_override)); + } + con->set_status(status); + con->send_http_response(); + } catch (const std::exception &e) { + wlog("node_admin: failed to send response: ${e}", ("e", e.what())); + } catch (...) { + } + } +}; + +// ── option registration ──────────────────────────────────────────────────────────────────── +void node_admin_plugin::set_program_options( + bpo::options_description &command_line_options, + bpo::options_description &config_file_options +) { + // Config-file description only, like testnet_plugin: appbase also accepts these on the + // command line, and adding the same long name to both descriptions makes boost refuse + // every start with "option is ambiguous". + bpo::options_description opts("Node administration API"); + opts.add_options() + ("admin-endpoint", bpo::value(), + "HTTP endpoint of the admin API, e.g. 127.0.0.1:8099. Empty (default) keeps the " + "plugin inert.") + ("admin-token", bpo::value>()->composing()->multitoken(), + "Access token: label:scope[,scope]:sha256hex[:expires-unixtime]. Repeatable.") + ("admin-allow-remote", bpo::value()->default_value(false), + "Allow admin-endpoint on a non-loopback address. Off by default; a remote admin " + "port is a security decision, not a convenience.") + ("admin-plan-ttl", bpo::value()->default_value(120), + "Seconds a config/plugin plan stays applicable. Plans are single-use and bound to the " + "token that created them. Default: 120.") + ("admin-audit-log", bpo::value()->default_value(""), + "Append-only audit log path. Default: node-admin-audit.log next to config.ini.") + ("admin-log-file", bpo::value>()->composing()->multitoken(), + "File the admin API may tail, as name=path or path. Repeatable. Nothing outside " + "this allow-list can be read through admin.logs.tail."); + config_file_options.add(opts); + (void) command_line_options; +} + +node_admin_plugin::node_admin_plugin() {} +node_admin_plugin::~node_admin_plugin() = default; + +// ── lifecycle ───────────────────────────────────────────────────────────────────────────── +void node_admin_plugin::plugin_initialize(const bpo::variables_map &options) { + pimpl.reset(new impl(appbase::app().get_plugin())); + + if (!options.count("admin-endpoint")) { + ilog("node_admin: admin-endpoint is not set; the admin API is disabled"); + return; + } + + const std::string ep = options.at("admin-endpoint").as(); + if (trim(ep).empty()) { + ilog("node_admin: admin-endpoint is empty; the admin API is disabled"); + return; + } + + pimpl->allow_remote = options.at("admin-allow-remote").as(); + // Same option map the chain plugin reads. Guarded with count(): node_admin must not make + // a snapshot option mandatory for nodes that start without one. + pimpl->snapshot_replay_from = + options.count("replay-from-snapshot") && options.at("replay-from-snapshot").as(); + pimpl->snapshot_configured = + (options.count("snapshot-auto-latest") && options.at("snapshot-auto-latest").as()) || + (options.count("snapshot") && !trim(options.at("snapshot").as()).empty()); + // A zero TTL would make every plan unusable before it is returned; clamp like the + // webserver limits do. + pimpl->plan_ttl_sec = std::max(1, options.at("admin-plan-ttl").as()); + + // Tokens are mandatory: an admin port without authentication is never a valid state. + FC_ASSERT(options.count("admin-token"), "node_admin: admin-endpoint is set but no admin-token is configured"); + for (const std::string &spec : options.at("admin-token").as>()) { + pimpl->tokens.push_back(parse_token_spec(spec)); + } + FC_ASSERT(!pimpl->tokens.empty(), "node_admin: admin-endpoint is set but no admin-token is configured"); + + const std::string audit = trim(options.at("admin-audit-log").as()); + if (audit.empty()) { + bfs::path p = appbase::app().config_path(); + p = p.parent_path() / "node-admin-audit.log"; + pimpl->audit_path = p.string(); + } else { + pimpl->audit_path = audit; + } + + if (options.count("admin-log-file")) { + for (const std::string &spec : options.at("admin-log-file").as>()) { + const std::size_t eq = spec.find('='); + std::string name; + std::string path; + if (eq == std::string::npos) { + path = trim(spec); + name = bfs::path(path).filename().string(); + } else { + name = trim(spec.substr(0, eq)); + path = trim(spec.substr(eq + 1)); + } + FC_ASSERT(!name.empty() && !path.empty(), + "admin-log-file: expected 'name=path' or 'path', got '${s}'", ("s", spec)); + pimpl->log_files[name] = path; + } + } + + // Resolve and validate the endpoint. The listener is loopback-only unless the operator + // says otherwise: a bearer token on an open port is a different threat model. + std::vector eps = appbase::app().resolve_string_to_ip_endpoints(ep); + FC_ASSERT(!eps.empty(), "admin-endpoint '${e}' did not resolve", ("e", ep)); + const tcp::endpoint chosen = eps.front(); + if (!chosen.address().is_loopback() && !pimpl->allow_remote) { + FC_ASSERT(false, + "admin-endpoint '${e}' is not a loopback address; set admin-allow-remote = true " + "if this is deliberate", ("e", ep)); + } + pimpl->endpoint.reset(new tcp::endpoint(chosen)); + + ilog("node_admin: configured on ${host}:${port}, tokens=${n}, audit=${audit}", + ("host", chosen.address().to_string())("port", chosen.port()) + ("n", pimpl->tokens.size())("audit", pimpl->audit_path)); + if (!chosen.address().is_loopback()) { + wlog("node_admin: listening on a NON-loopback address ${host} — the admin API is exposed to the network", + ("host", chosen.address().to_string())); + } +} + +void node_admin_plugin::plugin_startup() { + if (!pimpl || !pimpl->endpoint) return; + pimpl->start(); +} + +void node_admin_plugin::plugin_shutdown() { + if (pimpl) pimpl->stop(); +} + +void node_admin_plugin::impl::start() { + server.clear_access_channels(websocketpp::log::alevel::all); + server.clear_error_channels(websocketpp::log::elevel::all); + server.init_asio(&ios); + server.set_reuse_addr(true); + server.set_max_http_body_size(kMaxBodyBytes); + + server.set_http_handler([this](connection_hdl hdl) { this->handle_http(hdl); }); + + server.listen(*endpoint); + server.start_accept(); + listening = true; + started_at = fc::time_point::now(); + + thread = std::thread([this]() { + try { + ios.run(); + } catch (const std::exception &e) { + elog("node_admin: listener stopped with an error: ${e}", ("e", e.what())); + } catch (...) { + elog("node_admin: listener stopped with an unknown error"); + } + }); + ilog("node_admin: listening on ${host}:${port}", + ("host", endpoint->address().to_string())("port", endpoint->port())); +} + +void node_admin_plugin::impl::stop() { + if (!listening) return; + listening = false; + try { + server.stop_listening(); + } catch (...) { + } + ios.stop(); + if (thread.joinable()) thread.join(); + ilog("node_admin: listener stopped"); +} + +// ── auth / rate limit ───────────────────────────────────────────────────────────────────── +const token_info *node_admin_plugin::impl::authenticate(const std::string &header, + std::string &why) const { + static const std::string prefix = "bearer "; + const std::string h = trim(header); + if (h.size() <= prefix.size() || to_lower(h.substr(0, prefix.size())) != prefix) { + why = "missing or malformed Authorization header (expected 'Bearer ')"; + return nullptr; + } + const std::string token = trim(h.substr(prefix.size())); + if (token.empty()) { + why = "empty bearer token"; + return nullptr; + } + const std::string hash = fc::sha256::hash(token).str(); + const uint64_t now = static_cast(fc::time_point::now().sec_since_epoch()); + for (const token_info &t : tokens) { + if (!ct_equal(hash, t.hash_hex)) continue; + if (t.expires != 0 && now > t.expires) { + why = "token expired"; + return nullptr; + } + return &t; + } + why = "unknown token"; + return nullptr; +} + +bool node_admin_plugin::impl::rate_ok(const std::string &label) { + const uint32_t now = static_cast(fc::time_point::now().sec_since_epoch()); + std::lock_guard lock(rate_mutex); + auto &slot = rate[label]; + if (slot.first != now) { + slot.first = now; + slot.second = 0; + } + if (++slot.second > kRatePerSecond) return false; + return true; +} + +// ── HTTP entry ──────────────────────────────────────────────────────────────────────────── +void node_admin_plugin::impl::handle_http(connection_hdl hdl) { + websocket_server_type::connection_ptr con; + try { + con = server.get_con_from_hdl(hdl); + } catch (...) { + return; + } + if (!con) return; + con->defer_http_response(); + + const fc::time_point started = fc::time_point::now(); + const std::string rid = std::to_string(static_cast(++request_seq)); + // The real peer, not X-Forwarded-For: this listener is never behind a proxy by design, so + // that header would only let a client choose its own audit attribution. + std::string remote; + try { + remote = con->get_remote_endpoint(); + } catch (...) { + } + + auto finish = [&](websocketpp::http::status_code::value status, const std::string &body) { + respond(con, status, body); + }; + + // ── built-in UI: one static page, no data and no secrets in it ──────────────────────── + // The page cannot carry a bearer token on navigation, and it does not need one: it is + // the same bytes for everybody and all real calls go back to this listener as POSTs + // with the operator-supplied token. Serving it unauthenticated therefore leaks nothing. + const std::string req_method = con->get_request().get_method(); + std::string path = con->get_request().get_uri(); + { + const std::size_t qpos = path.find('?'); + if (qpos != std::string::npos) path = path.substr(0, qpos); + } + if (req_method == "GET" || req_method == "HEAD") { + if (path == "/ui" || path == "/ui/") { + try { + con->append_header("X-Content-Type-Options", "nosniff"); + con->append_header("X-Frame-Options", "DENY"); + con->append_header("Referrer-Policy", "no-referrer"); + con->append_header("Content-Security-Policy", + "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; " + "connect-src 'self'; base-uri 'none'; form-action 'none'; " + "frame-ancestors 'none'"); + } catch (const std::exception &e) { + wlog("node_admin: failed to set UI headers: ${e}", ("e", e.what())); + } + respond(con, websocketpp::http::status_code::ok, + req_method == "HEAD" ? std::string() : std::string(ui_page()), + "text/html; charset=utf-8"); + audit(rid, "-", req_method + " " + path, fc::variant(), "ui", 0, + elapsed_ms(started), remote); + return; + } + + // Snapshot download: the newest file by default, or a named file from list_snapshots(). + // Unlike /ui/ this carries node state, so it is authenticated like every JSON-RPC call. + static const std::string snap_prefix = "/snapshot/"; + if (path == "/snapshot" || path.compare(0, snap_prefix.size(), snap_prefix) == 0) { + std::string why; + const token_info *token = + authenticate(con->get_request().get_header("Authorization"), why); + if (!token) { + respond(con, websocketpp::http::status_code::unauthorized, + to_json(rpc_err(fc::variant(), rpc_error{-32001, "unauthorized", why, ""}))); + audit(rid, "-", req_method + " " + path, fc::variant(), "unauthorized", + -32001, elapsed_ms(started), remote); + return; + } + if (!rate_ok(token->label)) { + respond(con, websocketpp::http::status_code::too_many_requests, + to_json(rpc_err(fc::variant(), + rpc_error{-32005, "rate limit exceeded", "", ""}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), "rate_limited", + -32005, elapsed_ms(started), remote); + return; + } + if (token->scopes.find("read") == token->scopes.end()) { + respond(con, websocketpp::http::status_code::forbidden, + to_json(rpc_err(fc::variant(), + rpc_error{-32002, "forbidden", + "token '" + token->label + "' has no 'read' scope", + ""}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), "forbidden", + -32002, elapsed_ms(started), remote); + return; + } + + std::string file; + std::string filename; + try { + auto &snap = snapshot_started(); + if (path == "/snapshot" || path == "/snapshot/") { + respond(con, websocketpp::http::status_code::bad_request, + to_json(rpc_err(fc::variant(), + rpc_error{-32602, "invalid params", + "use /snapshot/latest or /snapshot/", + "names come from admin.snapshot.status"}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), + "bad_request", -32602, elapsed_ms(started), remote); + return; + } + const std::string name = path.substr(snap_prefix.size()); + if (name == "latest") { + file = snap.latest_snapshot_path(); + } else if (is_plain_snapshot_name(name)) { + file = (bfs::path(snap.snapshot_directory()) / name).string(); + } else { + respond(con, websocketpp::http::status_code::bad_request, + to_json(rpc_err(fc::variant(), + rpc_error{-32602, "invalid snapshot name", name, + "expected a file name ending in .vizjson"}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), + "bad_request", -32602, elapsed_ms(started), remote); + return; + } + if (!file.empty()) filename = bfs::path(file).filename().string(); + } catch (const rpc_error &e) { + respond(con, websocketpp::http::status_code::service_unavailable, + to_json(rpc_err(fc::variant(), e))); + audit(rid, token->label, req_method + " " + path, fc::variant(), "error", + e.code, elapsed_ms(started), remote); + return; + } catch (const std::exception &e) { + respond(con, websocketpp::http::status_code::internal_server_error, + to_json(rpc_err(fc::variant(), + rpc_error{-32099, "internal error", e.what(), ""}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), + "internal_error", -32099, elapsed_ms(started), remote); + return; + } + + boost::system::error_code ec; + if (file.empty() || !bfs::exists(file, ec) || ec || + !bfs::is_regular_file(file, ec) || ec) { + respond(con, websocketpp::http::status_code::not_found, + to_json(rpc_err(fc::variant(), + rpc_error{-32602, "snapshot not found", + file.empty() ? "no snapshots in the snapshot directory" : file, + "see admin.snapshot.status"}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), "not_found", + -32602, elapsed_ms(started), remote); + return; + } + const uintmax_t size = bfs::file_size(file, ec); + if (ec || size > kMaxSnapshotDownloadBytes) { + respond(con, websocketpp::http::status_code::request_entity_too_large, + to_json(rpc_err(fc::variant(), + rpc_error{-32600, "snapshot too large to serve over HTTP", + std::to_string(size) + " bytes (limit " + + std::to_string(kMaxSnapshotDownloadBytes) + ")", + "copy the file with scp instead"}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), + "too_large", -32600, elapsed_ms(started), remote); + return; + } + + std::string content; + if (req_method != "HEAD") { + std::ifstream in(file, std::ios::binary); + if (!in) { + respond(con, websocketpp::http::status_code::internal_server_error, + to_json(rpc_err(fc::variant(), + rpc_error{-32099, "cannot open snapshot file", + file, ""}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), + "open_failed", -32099, elapsed_ms(started), remote); + return; + } + content.resize(static_cast(size)); + if (size) in.read(&content[0], static_cast(size)); + content.resize(static_cast(in.gcount())); + if (!in && !in.eof()) { + respond(con, websocketpp::http::status_code::internal_server_error, + to_json(rpc_err(fc::variant(), + rpc_error{-32099, "cannot read snapshot file", + file, ""}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), + "read_failed", -32099, elapsed_ms(started), remote); + return; + } + } + + try { + con->append_header("X-Content-Type-Options", "nosniff"); + con->append_header("Content-Disposition", + "attachment; filename=\"" + filename + "\""); + } catch (const std::exception &e) { + wlog("node_admin: failed to set snapshot headers: ${e}", ("e", e.what())); + } + respond(con, websocketpp::http::status_code::ok, content, + "application/octet-stream", + // A HEAD reply carries the headers a GET would return, so the file + // size must be explicit: the body is empty by design. + req_method == "HEAD" ? static_cast(size) : -1); + audit(rid, token->label, req_method + " " + path, + fc::mutable_variant_object("file", filename)("bytes", static_cast(size)), + "snapshot_download", 0, elapsed_ms(started), remote); + return; + } + } + + if (con->get_request().get_method() != "POST") { + const fc::variant id; + finish(websocketpp::http::status_code::method_not_allowed, + to_json(rpc_err(id, rpc_error{-32600, "only POST is supported", "", ""}))); + audit(rid, "-", con->get_request().get_method(), fc::variant(), "method_not_allowed", + -32600, elapsed_ms(started), remote); + return; + } + + const std::string body = con->get_request_body(); + if (body.empty()) { + finish(websocketpp::http::status_code::bad_request, + to_json(rpc_err(fc::variant(), rpc_error{-32700, "empty request body", "", ""}))); + audit(rid, "-", "-", fc::variant(), "empty_body", -32700, elapsed_ms(started), remote); + return; + } + if (body.size() > kMaxBodyBytes) { + finish(websocketpp::http::status_code::request_entity_too_large, + to_json(rpc_err(fc::variant(), rpc_error{-32600, "request body too large", "", ""}))); + audit(rid, "-", "-", fc::variant(), "body_too_large", -32600, elapsed_ms(started), remote); + return; + } + + // ── auth first: parsing untrusted JSON before authentication is needless attack surface + std::string why; + const token_info *token = authenticate(con->get_request().get_header("Authorization"), why); + if (!token) { + finish(websocketpp::http::status_code::unauthorized, + to_json(rpc_err(fc::variant(), + rpc_error{-32001, "unauthorized", why, ""}))); + audit(rid, "-", "-", fc::variant(), "unauthorized", -32001, elapsed_ms(started), remote); + return; + } + if (!rate_ok(token->label)) { + finish(websocketpp::http::status_code::too_many_requests, + to_json(rpc_err(fc::variant(), + rpc_error{-32005, "rate limit exceeded", "", ""}))); + audit(rid, token->label, "-", fc::variant(), "rate_limited", -32005, elapsed_ms(started), remote); + return; + } + + // ── parse + fc::variant request; + try { + request = fc::json::from_string(body); + } catch (const fc::exception &e) { + finish(websocketpp::http::status_code::bad_request, + to_json(rpc_err(fc::variant(), + rpc_error{-32700, "parse error", e.to_string(), ""}))); + audit(rid, token->label, "-", fc::variant(), "parse_error", -32700, elapsed_ms(started), remote); + return; + } + if (!request.is_object()) { + finish(websocketpp::http::status_code::bad_request, + to_json(rpc_err(fc::variant(), + rpc_error{-32600, "invalid request", "expected a JSON object", ""}))); + audit(rid, token->label, "-", fc::variant(), "invalid_request", -32600, elapsed_ms(started), remote); + return; + } + + const fc::variant_object &obj = request.get_object(); + fc::variant id; + auto id_it = obj.find("id"); + if (id_it != obj.end()) id = id_it->value(); + + if (!obj.contains("method") || !obj["method"].is_string()) { + finish(websocketpp::http::status_code::bad_request, + to_json(rpc_err(id, rpc_error{-32600, "invalid request", "missing 'method'", ""}))); + audit(rid, token->label, "-", fc::variant(), "invalid_request", -32600, elapsed_ms(started), remote); + return; + } + const std::string method = obj["method"].as_string(); + fc::variant params; + auto p_it = obj.find("params"); + if (p_it != obj.end()) params = p_it->value(); + + // JSON-RPC notification (no id): MCP clients send `notifications/initialized` this way and + // expect 202 with no body. Answering a notification with a response is a protocol error. + if (method.compare(0, 14, "notifications/") == 0) { + finish(websocketpp::http::status_code::accepted, ""); + audit(rid, token->label, method, params, "notification", 0, elapsed_ms(started), remote); + return; + } + + try { + const fc::variant result = dispatch(method, params, *token); + finish(websocketpp::http::status_code::ok, to_json(rpc_ok(id, result))); + audit(rid, token->label, method, params, "ok", 0, elapsed_ms(started), remote); + } catch (const rpc_error &e) { + finish(websocketpp::http::status_code::ok, to_json(rpc_err(id, e))); + audit(rid, token->label, method, params, "error", e.code, elapsed_ms(started), remote); + } catch (const fc::exception &e) { + finish(websocketpp::http::status_code::ok, + to_json(rpc_err(id, rpc_error{-32099, "internal error", e.to_string(), ""}))); + audit(rid, token->label, method, params, "internal_error", -32099, elapsed_ms(started), remote); + } catch (const std::exception &e) { + finish(websocketpp::http::status_code::ok, + to_json(rpc_err(id, rpc_error{-32099, "internal error", e.what(), ""}))); + audit(rid, token->label, method, params, "internal_error", -32099, elapsed_ms(started), remote); + } +} + +// ── dispatch ───────────────────────────────────────────────────────────────────────────── +fc::variant node_admin_plugin::impl::rpc_ok(const fc::variant &id, const fc::variant &result) const { + fc::mutable_variant_object r; + r("jsonrpc", "2.0"); + r("id", id); + r("result", result); + return fc::variant(r); +} + +fc::variant node_admin_plugin::impl::rpc_err(const fc::variant &id, const rpc_error &e) const { + fc::mutable_variant_object err; + err("code", static_cast(e.code)); + err("message", e.message); + if (!e.reason.empty() || !e.hint.empty()) { + fc::mutable_variant_object data; + if (!e.reason.empty()) data("reason", e.reason); + if (!e.hint.empty()) data("hint", e.hint); + err("data", data); + } + fc::mutable_variant_object r; + r("jsonrpc", "2.0"); + r("id", id); + r("error", err); + return fc::variant(r); +} + +fc::variant node_admin_plugin::impl::dispatch(const std::string &method, const fc::variant ¶ms, + const token_info &token) { + // v1 is read-only; every method needs the `read` scope. Later stages add their own + // scopes (plugins/config/process/chain) on the same check. + if (token.scopes.find("read") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", "token '" + token.label + "' has no 'read' scope", ""}; + } + + // The UI asks who it is so it can hide actions the token cannot perform. Answer with the + // token's own label and scopes — never with the token or its hash. + if (method == "admin.auth.me") return h_auth_me(token); + if (method == "admin.ping") return h_ping(params); + if (method == "admin.node_info") return h_node_info(params); + if (method == "admin.diagnostics") return h_diagnostics(params); + if (method == "admin.plugins.list") return h_plugins_list(params); + if (method == "admin.plugins.status") return h_plugins_status(params); + if (method == "admin.config.get") return h_config_get(params); + // Chainbase introspection: which object types exist, how many rows each holds, and how + // much of the mapped file is used. Read-only; the check is the same view plus a verdict. + if (method == "admin.objects.list") return h_objects_list(params); + if (method == "admin.objects.check") return h_objects_check(params); + if (method == "admin.logs.list") return h_logs_list(params); + if (method == "admin.logs.tail") return h_logs_tail(params); + if (method == "admin.audit.tail") return h_audit_tail(params); + if (method == "admin.validators.active") return h_validators_active(params); + if (method == "admin.p2p.status") return h_p2p_status(params); + if (method == "admin.snapshot.status") return h_snapshot_status(params); + if (method == "admin.webserver.status") return h_webserver_status(params); + if (method == "admin.webserver.ips") return h_webserver_ips(params); + + // Mutating peer operations need `p2p` on top of `read`: a read-only token must not be able + // to change whom this node talks to. Both required scopes are named in the error. + if (method == "admin.p2p.add_node" || method == "admin.p2p.add_trusted" || + method == "admin.p2p.clear_banned") { + if (token.scopes.find("p2p") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'p2p' scope", + "peer operations need a token with scopes 'read,p2p'"}; + } + if (method == "admin.p2p.add_node") return h_p2p_add_node(params, false); + if (method == "admin.p2p.add_trusted") return h_p2p_add_node(params, true); + return h_p2p_clear_banned(params); + } + + // Snapshot creation and restore rewrite node state / disk, so they need `snapshot` + // on top of `read`. Listing and downloading stay under `read`. + if (method == "admin.snapshot.create" || method == "admin.snapshot.restore") { + if (token.scopes.find("snapshot") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'snapshot' scope", + "snapshot operations need a token with scopes 'read,snapshot'"}; + } + if (method == "admin.snapshot.create") return h_snapshot_create(params); + return h_snapshot_restore(params); + } + + // The webserver knobs (limits, IP lists, stats switch, reset/reload) change how the + // public API treats its clients, so they need the `webserver` scope. Reads stay under + // `read`. Changes are applied to the live plugin immediately and persisted to its + // admin state file; config.ini is only the startup seed. + if (method == "admin.webserver.limits" || method == "admin.webserver.stats" || + method == "admin.webserver.whitelist_add" || method == "admin.webserver.whitelist_remove" || + method == "admin.webserver.blacklist_add" || method == "admin.webserver.blacklist_remove" || + method == "admin.webserver.reset" || method == "admin.webserver.reload") { + if (token.scopes.find("webserver") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'webserver' scope", + "webserver operations need a token with scopes 'read,webserver'"}; + } + if (method == "admin.webserver.limits") return h_webserver_limits(params); + if (method == "admin.webserver.stats") return h_webserver_stats(params); + if (method == "admin.webserver.whitelist_add") return h_webserver_list_add(params, true); + if (method == "admin.webserver.whitelist_remove") return h_webserver_list_remove(params, true); + if (method == "admin.webserver.blacklist_add") return h_webserver_list_add(params, false); + if (method == "admin.webserver.blacklist_remove") return h_webserver_list_remove(params, false); + if (method == "admin.webserver.reset") return h_webserver_reset(params); + return h_webserver_reload(params); + } + + // Config.ini edits are two-phase: plan returns the exact diff plus a single-use, + // token-bound plan_id; apply re-validates against the current file and writes atomically + // with a backup. Reads (config.backups) stay under `read`. + if (method == "admin.config.plan" || method == "admin.config.apply" || + method == "admin.config.backups" || method == "admin.plugins.plan" || + method == "admin.plugins.apply") { + const bool cfg = method == "admin.config.plan" || method == "admin.config.apply"; + const bool plg = method == "admin.plugins.plan" || method == "admin.plugins.apply"; + if (cfg && token.scopes.find("config") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'config' scope", + "config edits need a token with scopes 'read,config'"}; + } + if (plg && token.scopes.find("plugins") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'plugins' scope", + "plugin edits need a token with scopes 'read,plugins'"}; + } + if (method == "admin.config.plan") return h_config_plan(params, token); + if (method == "admin.config.apply") return h_config_apply(params, token); + if (method == "admin.config.backups") return h_config_backups(params); + if (method == "admin.plugins.plan") return h_plugins_plan(params, token); + return h_plugins_apply(params, token); + } + + if (method == "admin.plan.cancel") return h_plan_cancel(params, token); + + if (method == "admin.restart" || method == "admin.objects.repair") { + if (token.scopes.find("process") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'process' scope", + "restart and state repair need a token with scopes 'read,process'"}; + } + if (method == "admin.objects.repair") return h_objects_repair(params); + return h_restart(params); + } + + // Validator chain operations are signed INSIDE validator_guard with its active keys + // (decision q#3051=D): node_admin never sees a key. Reads stay under `read`; plan/apply + // need `chain`, and that scope is usable only while a live guard holds an active key. + if (method == "admin.validator.list" || method == "admin.validator.status") { + return h_validator_list(params); + } + if (method == "admin.validator.plan" || method == "admin.validator.apply") { + if (token.scopes.find("chain") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'chain' scope", + "validator operations need a token with scopes 'read,chain'"}; + } + if (!guard_ready()) { + throw rpc_error{-32002, "forbidden", "validator-guard is not configured", + "chain operations are signed by validator_guard; configure the " + "plugin with validator-guard-validator = [name,signing_wif,active_wif]"}; + } + if (method == "admin.validator.plan") return h_validator_plan(params, token); + return h_validator_apply(params, token); + } + + // MCP Streamable HTTP on the same authenticated listener (decision q#3052=B: the MCP + // endpoint lives inside the plugin, not in a sidecar). Tools are 1:1 with the admin.* + // methods above, so the audit trail and scopes are shared. + if (method == "initialize") return h_mcp_initialize(params); + if (method == "ping") return h_mcp_ping(params); + if (method == "tools/list") return h_mcp_tools_list(params); + if (method == "tools/call") return h_mcp_tools_call(params, token); + + throw rpc_error{-32601, "method not found", method, ""}; +} + +// ── methods ────────────────────────────────────────────────────────────────────────────── +fc::mutable_variant_object node_admin_plugin::impl::make_node_info() const { + const graphene::chain::database &db = chain.db(); + const auto head_time = db.head_block_time(); + const auto now = fc::time_point::now().sec_since_epoch(); + const int64_t head_age = static_cast(now) - static_cast(head_time.sec_since_epoch()); + + fc::mutable_variant_object m; + m("server_time", now_iso()); + m("version", std::string(CHAIN_VERSION)); + m("hardfork_count", static_cast(CHAIN_NUM_HARDFORKS)); + m("chain_id", db.get_chain_id().str()); + m("head_block_num", static_cast(db.head_block_num())); + m("head_block_id", db.head_block_id().str()); + m("head_block_time", iso_utc(head_time)); + // An empty chain reports head time 1970, so the "age" would be 56 years and mean nothing. + m("head_age_sec", db.head_block_num() == 0 ? fc::variant() : fc::variant(head_age < 0 ? 0 : head_age)); + const auto &dgp = db.get_dynamic_global_properties(); + m("last_irreversible_block_num", static_cast(dgp.last_irreversible_block_num)); + // Emergency consensus is a chain-wide state voted in by validators. It used to be visible + // only in the p2p hello and in the log, never on the operator page. + m("emergency_consensus_active", dgp.emergency_consensus_active); + m("emergency_consensus_start_block", + static_cast(dgp.emergency_consensus_active ? dgp.emergency_consensus_start_block : 0)); + bool emergency_key = false; + if (auto *vp = appbase::app().find_plugin()) { + emergency_key = vp->is_emergency_key_configured(); + } + m("emergency_key_configured", emergency_key); + m("recovering", chain.is_recovering()); + m("uptime_sec", static_cast((fc::time_point::now() - started_at).count() / 1000000)); + return m; +} + +fc::variant node_admin_plugin::impl::h_ping(const fc::variant &) { + fc::mutable_variant_object m; + m("ok", true); + m("server_time", now_iso()); + return fc::variant(m); +} + +// The UI cannot guess what a token may do: the same page is opened with a read-only token and +// with a full one. It asks here and hides the actions the token cannot perform. Only the label, +// the scopes and the expiry leave this method — never the token or its hash. +fc::variant node_admin_plugin::impl::h_auth_me(const token_info &token) { + fc::variants scopes; + for (const std::string &s : token.scopes) scopes.push_back(fc::variant(s)); + + fc::mutable_variant_object m; + m("label", token.label); + m("scopes", scopes); + m("expires", token.expires == 0 ? fc::variant() + : fc::variant(static_cast(token.expires))); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_node_info(const fc::variant &) { + return fc::variant(make_node_info()); +} + +fc::variant node_admin_plugin::impl::h_diagnostics(const fc::variant &) { + fc::mutable_variant_object m = make_node_info(); + + m("data_dir", data_dir()); + m("config_path", config_path()); + + // peers: read through the p2p thread, because the peer map is not thread-safe + bool p2p_loaded = false; + uint32_t peers = 0; + if (auto *p2p = appbase::app().find_plugin()) { + p2p_loaded = true; + if (p2p->get_state() == appbase::abstract_plugin::started) { + peers = p2p->get_connections_count_sync(); + } + } + m("p2p_loaded", p2p_loaded); + m("peers", static_cast(peers)); + + // configured plugins: appbase keeps its registry private, so config.ini is what we can see. + // `plugins_observed` counts the names whose lifecycle state is read through a typed + // find_plugin(); the rest are known to be loaded by construction (appbase aborts startup + // on an unknown plugin name) but their state cannot be read from here. + bool cfg_exists = false; + const std::vector cfg = parse_config_file(config_path(), &cfg_exists); + std::set configured; + for (const auto &e : cfg) { + if (e.key != "plugin" || e.value.empty()) continue; + for (const std::string &name : util::plugin_names_in_value(e.value)) configured.insert(name); + } + std::size_t observed_count = 0; + for (const auto &name : configured) { + if (observe_plugin(name) != nullptr) ++observed_count; + } + m("plugins_configured", static_cast(configured.size())); + m("plugins_observed", static_cast(observed_count)); + + // shared memory file + boost::system::error_code ec; + // shared_memory.bin follows shared-file-dir, which the chain plugin resolves at startup. + const bfs::path shm = bfs::path(chain.get_state_dir()) / "shared_memory.bin"; + const uintmax_t shm_size = bfs::file_size(shm, ec); + if (!ec) m("shared_memory_bytes", static_cast(shm_size)); + +#ifndef _WIN32 + struct statvfs vfs; + if (::statvfs(data_dir().c_str(), &vfs) == 0) { + m("disk_total_bytes", + static_cast(static_cast(vfs.f_blocks) * vfs.f_frsize)); + m("disk_free_bytes", + static_cast(static_cast(vfs.f_bavail) * vfs.f_frsize)); + } + struct rusage usage; + if (::getrusage(RUSAGE_SELF, &usage) == 0) { + m("rss_peak_kb", static_cast(usage.ru_maxrss)); + } + double load[3] = {0, 0, 0}; + if (::getloadavg(load, 3) == 3) { + m("loadavg", fc::variants{fc::variant(load[0]), fc::variant(load[1]), fc::variant(load[2])}); + } +#endif + + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_plugins_list(const fc::variant &) { + bool exists = false; + const std::vector cfg = parse_config_file(config_path(), &exists); + + fc::variants plugins; + std::set seen; + for (const auto &e : cfg) { + if (e.key != "plugin" || e.value.empty()) continue; + // A line may name several plugins (`plugin = chain p2p`); appbase loads each of them, + // so each gets its own row. Keeping the line as one name made state, status_method and + // parameter grouping unreachable for every plugin on such a line. + for (const std::string &name : util::plugin_names_in_value(e.value)) { + if (!seen.insert(name).second) continue; + + const appbase::abstract_plugin *p = observe_plugin(name); + const char *state = "unknown"; + const char *source = "inferred"; + if (p) { + source = "observed"; + switch (p->get_state()) { + case appbase::abstract_plugin::registered: state = "registered"; break; + case appbase::abstract_plugin::initialized: state = "initialized"; break; + case appbase::abstract_plugin::started: state = "started"; break; + case appbase::abstract_plugin::stopped: state = "stopped"; break; + default: state = "unknown"; break; + } + } + fc::mutable_variant_object item; + item("name", name); + // A configured plugin is always loaded: appbase calls get_plugin(name) for every + // `plugin =` line and aborts startup when the name is unknown. + item("loaded", p ? p->get_state() != appbase::abstract_plugin::registered : true); + item("state", p ? fc::variant(std::string(state)) : fc::variant()); + item("state_source", std::string(source)); + // The UI needs both facts before it can draw a toggle: a plugin that cannot be disabled + // must not render as merely "off", and a plugin with no allow-listed keys must say so + // instead of showing an empty panel. + item("removable", !util::plugin_removal_forbidden(name)); + // Plugins that answer a status question of their own. Null means the plugin exposes + // no operator status: the panel should say so instead of drawing an empty drawer. + const char *status_method = nullptr; + if (name == "p2p") status_method = "admin.p2p.status"; + else if (name == "webserver") status_method = "admin.webserver.status"; + else if (name == "snapshot") status_method = "admin.snapshot.status"; + else if (name == "validator_guard") status_method = "admin.validator.list"; + item("status_method", status_method ? fc::variant(std::string(status_method)) : fc::variant()); + fc::variants params; + for (const util::config_spec &spec : util::config_allowlist()) { + if (util::config_key_group(spec.name) != name) continue; + fc::mutable_variant_object pitem; + pitem("key", spec.name); + params.push_back(fc::variant(pitem)); + } + item("params", params); + plugins.push_back(fc::variant(item)); + } + } + + // The catalogue the binary can load, so the panel can offer an enable action. It is the + // same set admin.plugins.plan validates `add` against; a name that is already configured + // still appears here, and the client subtracts the configured ones itself. + fc::variants known; + for (const std::string &name : util::known_plugins()) known.push_back(fc::variant(name)); + + fc::mutable_variant_object m; + m("config_path", config_path()); + m("config_exists", exists); + m("known", known); + m("note", "Entries come from config.ini. Appbase keeps its plugin registry private, so " + "lifecycle state is observed only for the plugin types this binary's node_admin " + "links (chain, p2p, snapshot, webserver, validator, validator_guard, node_admin); " + "for the others loaded=true is implied by startup (an unknown plugin name aborts " + "the node). status_method names the method that returns that plugin's own status."); + m("plugins", plugins); + return fc::variant(m); +} + +// A plugin's lifecycle state says whether it is running; it does not say what it is doing. +// This method answers the second question for every plugin that has a status API of its own, +// and says so plainly when there is none instead of returning an empty object. +fc::variant node_admin_plugin::impl::h_plugins_status(const fc::variant ¶ms) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {name: \"p2p\"}", ""}; + } + const std::string name = trim(param_str(params.get_object(), "name", "")); + if (name.empty()) { + throw rpc_error{-32602, "invalid params", "name is required", ""}; + } + + bool exists = false; + const std::vector cfg = parse_config_file(config_path(), &exists); + bool configured = false; + for (const cfg_entry &e : cfg) { + if (e.key == "plugin" && e.value == name) { configured = true; break; } + } + + const appbase::abstract_plugin *p = observe_plugin(name); + std::string state = "unobserved"; + if (p) { + switch (p->get_state()) { + case appbase::abstract_plugin::registered: state = "registered"; break; + case appbase::abstract_plugin::initialized: state = "initialized"; break; + case appbase::abstract_plugin::started: state = "started"; break; + case appbase::abstract_plugin::stopped: state = "stopped"; break; + default: state = "unknown"; break; + } + } + const bool started = p && p->get_state() == appbase::abstract_plugin::started; + + fc::mutable_variant_object m; + m("name", name); + m("configured", configured); + m("state", p ? fc::variant(state) : fc::variant()); + m("state_source", p ? std::string("observed") : std::string("unobserved")); + + // Only call into a plugin that is actually started: these handlers touch thread-owned + // state (peer map, connection counters) and would throw on a stopped plugin. + fc::variant detail; + std::string detail_method; + if (started) { + if (name == "p2p") { detail = h_p2p_status(params); detail_method = "admin.p2p.status"; } + else if (name == "webserver") { detail = h_webserver_status(params); detail_method = "admin.webserver.status"; } + else if (name == "snapshot") { detail = h_snapshot_status(params); detail_method = "admin.snapshot.status"; } + else if (name == "validator_guard") { detail = h_validator_list(params); detail_method = "admin.validator.list"; } + } + m("detail_available", !detail_method.empty()); + m("detail_method", detail_method.empty() ? fc::variant() : fc::variant(detail_method)); + if (!detail_method.empty()) m("detail", detail); + else if (!p) m("reason", "this binary does not link this plugin, so its lifecycle cannot be " + "observed; a configured plugin is loaded or startup would have aborted"); + else if (!started) m("reason", "the plugin is not running, so it has no status to report"); + else m("reason", "this plugin exposes no status of its own; only its lifecycle state is known"); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_config_get(const fc::variant &) { + bool exists = false; + const std::vector cfg = parse_config_file(config_path(), &exists); + + // Preserve order and duplicate keys (config.ini repeats `plugin = ...`), so this is an + // array of sections rather than a map. + fc::variants sections; + std::string current; + fc::variants current_entries; + bool have_section = false; + auto flush = [&]() { + fc::mutable_variant_object s; + s("name", current); + s("entries", current_entries); + sections.push_back(fc::variant(s)); + current_entries.clear(); + }; + for (const auto &e : cfg) { + if (!have_section || e.section != current) { + if (have_section) flush(); + current = e.section; + have_section = true; + } + fc::mutable_variant_object item; + item("key", e.key); + item("value", sensitive_key(e.key) ? std::string("***") : e.value); + current_entries.push_back(fc::variant(item)); + } + if (have_section) flush(); + + // The editable subset, with its current value: this is what the parameters panel renders. + // Keys are the same full addresses admin.config.plan accepts (`logger.default.level`), so the + // UI never has to know which keys are sectioned. + std::string raw_text; + std::string raw_why; + std::vector all; + if (read_text_file(config_path(), raw_text, raw_why)) all = split_lines(raw_text); + fc::variants allow; + for (const util::config_spec &spec : util::config_allowlist()) { + std::string value; + const bool found = allowed_config_value(all, spec.name, value); + // Report the value in the form the editor accepts, not as written on disk: config.ini + // may say `yes` or `1` where the panel renders a true/false select, and an input that + // does not match any option would silently show as blank. A value that fails + // normalization is returned raw with valid=false so the panel can flag the line. + bool valid = found; + if (found) { + std::string norm; + std::string why; + if (util::normalize_config_value(spec.name, value, norm, why)) value = norm; + else valid = false; + } + fc::mutable_variant_object a; + a("key", spec.name); + a("group", util::config_key_group(spec.name)); + a("section", spec.section); + a("type", spec.type == util::config_type::boolean ? "boolean" + : spec.type == util::config_type::log_level ? "log_level" + : "unsigned_int"); + a("value", found ? fc::variant(value) : fc::variant()); + a("defaulted", !found); + a("valid", valid); + if (spec.type == util::config_type::unsigned_int) { + a("min", spec.min_value); + a("max", spec.max_value); + } + if (spec.type == util::config_type::log_level) { + fc::variants levels; + for (const std::string &l : util::log_level_values()) levels.push_back(fc::variant(l)); + a("values", levels); + } + a("needs_restart", spec.needs_restart); + allow.push_back(fc::variant(a)); + } + + fc::mutable_variant_object m; + m("path", config_path()); + m("exists", exists); + m("sections", sections); + m("allow", allow); + return fc::variant(m); +} + +// Which logs an operator can tail. A file that is configured but not there yet is still listed: +// "the appender has not written anything" is a different fact from "nobody configured it". +fc::variant node_admin_plugin::impl::h_logs_list(const fc::variant &) { + fc::variants arr; + for (const auto &kv : log_files) { + boost::system::error_code ec; + const uintmax_t size = bfs::file_size(kv.second, ec); + fc::mutable_variant_object m; + m("name", kv.first); + m("path", kv.second); + m("exists", !ec); + m("size", ec ? fc::variant() : fc::variant(static_cast(size))); + arr.push_back(fc::variant(m)); + } + + fc::mutable_variant_object m; + m("logs", arr); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_logs_tail(const fc::variant ¶ms) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object {name, bytes?, lines?}", ""}; + } + const fc::variant_object &o = params.get_object(); + const std::string name = param_str(o, "name", ""); + if (name.empty()) { + throw rpc_error{-32602, "invalid params", "missing 'name'", "known names: the admin-log-file entries"}; + } + auto it = log_files.find(name); + if (it == log_files.end()) { + std::string known; + for (const auto &kv : log_files) { + if (!known.empty()) known += ", "; + known += kv.first; + } + throw rpc_error{-32004, "unknown log", "no admin-log-file entry named '" + name + "'", + known.empty() ? "no admin-log-file entries are configured" : "known: " + known}; + } + + const int64_t bytes = std::max(1, std::min( + param_int(o, "bytes", 16 * 1024), static_cast(kMaxLogBytes))); + const int64_t lines = std::max(1, std::min( + param_int(o, "lines", 200), static_cast(kMaxLines))); + + tail_result t = tail_file(it->second, static_cast(bytes), static_cast(lines)); + if (!t.ok) { + throw rpc_error{-32004, "cannot read log", t.error, it->second}; + } + + fc::variants out; + for (const std::string &line : t.lines) out.push_back(fc::variant(mask_text(line))); + + fc::mutable_variant_object m; + m("name", name); + m("path", it->second); + m("size", static_cast(t.size)); + m("bytes_returned", static_cast(t.bytes_returned)); + m("lines", out); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_audit_tail(const fc::variant ¶ms) { + const fc::variant_object empty; + const fc::variant_object &o = params.is_object() ? params.get_object() : empty; + const int64_t lines = std::max(1, std::min( + param_int(o, "lines", 100), static_cast(kMaxLines))); + + tail_result t = tail_file(audit_path, kMaxLogBytes, static_cast(lines)); + if (!t.ok) { + throw rpc_error{-32004, "cannot read audit log", t.error, audit_path}; + } + fc::variants out; + for (const std::string &line : t.lines) out.push_back(fc::variant(line)); + + fc::mutable_variant_object m; + m("path", audit_path); + m("size", static_cast(t.size)); + m("lines", out); + return fc::variant(m); +} + +// ── P2P operator methods ───────────────────────────────────────────────────────────────── +graphene::plugins::p2p::p2p_plugin &node_admin_plugin::impl::p2p_started() const { + auto *p2p = appbase::app().find_plugin(); + if (p2p == nullptr || p2p->get_state() != appbase::abstract_plugin::started) { + throw rpc_error{-32004, "p2p unavailable", + "the p2p plugin is not loaded or not started", + "peer administration needs 'plugin = p2p' in config.ini"}; + } + return *p2p; +} + +// Mirrors what the node writes to its own log every minute (log_node_status) and every five +// minutes (log_peer_stats): the same facts, on demand, for an operator or agent. +fc::variant node_admin_plugin::impl::h_p2p_status(const fc::variant &) { + const graphene::network::dlt_peer_report r = p2p_started().get_peer_report_sync(); + + fc::mutable_variant_object m; + m("as_of", now_iso()); + m("uptime_sec", static_cast(r.uptime_sec)); + m("node_status", r.node_status); + m("fork_status", r.fork_status); + m("paused", r.paused); + m("isolated_peers", r.isolated_peers); + m("catching_up", r.catching_up); + m("head_block_num", static_cast(r.our_head)); + m("lib_block_num", static_cast(r.our_lib)); + m("dlt_earliest", static_cast(r.dlt_earliest)); + m("dlt_latest", static_cast(r.dlt_latest)); + m("connections", static_cast(r.connection_count)); + m("active", static_cast(r.active_count)); + m("max_connections", static_cast(r.max_connections)); + m("known_peers", static_cast(r.known_peers)); + m("trusted_peers", static_cast(r.trusted_peers)); + m("banned_peers", static_cast(r.banned_peers)); + m("best_peer_head", static_cast(r.best_peer_head)); + // our head minus the best peer head: negative means we are behind the network + m("best_peer_head_lag", r.best_peer_head_lag); + m("peers_ahead", static_cast(r.peers_ahead)); + + fc::variants peers; + for (const graphene::network::dlt_peer_report_entry &p : r.peers) { + fc::mutable_variant_object e; + e("endpoint", p.endpoint); + e("node_id", p.node_id); + e("lifecycle", p.lifecycle); + e("node_status", p.node_status); + e("fork_status", p.fork_status); + e("incoming", p.incoming); + e("trusted", p.trusted); + e("exchange_enabled", p.exchange_enabled); + e("fork_alignment", p.fork_alignment); + e("connected_sec", static_cast(p.connected_sec)); + e("peer_head", static_cast(p.peer_head)); + e("peer_lib", static_cast(p.peer_lib)); + e("peer_dlt_earliest", static_cast(p.peer_dlt_earliest)); + e("peer_dlt_latest", static_cast(p.peer_dlt_latest)); + e("head_lag", p.head_lag); + e("spam_strikes", static_cast(p.spam_strikes)); + e("ban_reason", p.ban_reason); + e("ban_duration_sec", static_cast(p.ban_duration_sec)); + e("reconnect_backoff_sec", static_cast(p.reconnect_backoff_sec)); + e("next_reconnect_in_sec", static_cast(p.next_reconnect_in_sec)); + peers.push_back(fc::variant(e)); + } + m("peers", peers); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_p2p_add_node(const fc::variant ¶ms, bool trusted) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object {endpoint}", + "endpoint is IPv4:port, e.g. 1.2.3.4:1776"}; + } + const fc::variant_object &o = params.get_object(); + const std::string text = trim(param_str(o, "endpoint", "")); + if (text.empty()) { + throw rpc_error{-32602, "invalid params", "missing 'endpoint'", + "endpoint is IPv4:port, e.g. 1.2.3.4:1776"}; + } + + fc::ip::endpoint ep; + try { + ep = fc::ip::endpoint::from_string(text); + } catch (const fc::exception &) { + throw rpc_error{-32602, "invalid endpoint", text, + "expected IPv4:port, e.g. 1.2.3.4:1776"}; + } + if (ep.port() == 0) { + throw rpc_error{-32602, "invalid endpoint", text, "port must be 1..65535"}; + } + + // Serialized onto the p2p thread: the peer maps are owned there. + p2p_started().add_peer_sync(ep, trusted); + + fc::mutable_variant_object m; + m("endpoint", std::string(ep)); + m("trusted", trusted); + m("status", "added"); + m("note", trusted + ? "trusted peers are never soft-banned and are kept through known-peer GC; the trusted " + "set lives in memory and is not persisted across restarts" + : "the peer is dialed now; use admin.p2p.add_trusted to exempt it from soft bans"); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_p2p_clear_banned(const fc::variant &) { + const uint32_t cleared = p2p_started().clear_banned_peers_sync(); + fc::mutable_variant_object m; + m("cleared", static_cast(cleared)); + m("status", "ok"); + return fc::variant(m); +} + +// ── snapshots ──────────────────────────────────────────────────────────────────────────── +graphene::plugins::snapshot::snapshot_plugin &node_admin_plugin::impl::snapshot_started() const { + auto *snap = appbase::app().find_plugin(); + if (!snap) { + throw rpc_error{-32004, "snapshot unavailable", + "the snapshot plugin is not loaded", + "add 'plugin = snapshot' to config.ini"}; + } + return *snap; +} + +fc::variant node_admin_plugin::impl::snapshot_list_variant() const { + auto &snap = snapshot_started(); + const std::string latest_path = snap.latest_snapshot_path(); + fc::variants files; + for (const auto &f : snap.list_snapshots()) { + fc::mutable_variant_object m; + m("name", f.name); + m("path", f.path); + m("block_num", static_cast(f.block_num)); + m("size_bytes", static_cast(f.size_bytes)); + m("modified", static_cast(f.modified)); + m("modified_iso", iso_utc(fc::time_point_sec(f.modified))); + m("latest", !latest_path.empty() && f.path == latest_path); + files.push_back(fc::variant(m)); + } + return fc::variant(files); +} + +fc::variant node_admin_plugin::impl::h_snapshot_status(const fc::variant ¶ms) { + const fc::variant_object empty; + const fc::variant_object &o = params.is_object() ? params.get_object() : empty; + // A node that has been running for a year accumulates hundreds of snapshots; the page shows + // the newest page and asks for the next one on demand. + const int64_t offset = std::max(0, param_int(o, "offset", 0)); + const int64_t limit = std::max(1, std::min( + param_int(o, "limit", 20), static_cast(kMaxSnapshotPage))); + // Plain offset paging on a list that changes under the reader repeats or skips rows: a + // snapshot written between two pages shifts every index below it by one, and the retention + // sweep removes from the tail. A client that sends the name of the last row it already has + // gets the page that follows THAT row ("after"); offset stays the fallback for an anchor that + // has already been pruned, and the reply says so instead of silently serving a shifted page. + const std::string after = param_str(o, "after", ""); + + auto &snap = snapshot_started(); + const bool recovering = chain.is_recovering(); + const fc::variant all_files = snapshot_list_variant(); + const fc::variants &files = all_files.get_array(); + const std::string latest_path = snap.latest_snapshot_path(); + + fc::variants page; + std::size_t begin = std::min(static_cast(offset), files.size()); + bool after_found = false; + if (!after.empty()) { + for (std::size_t i = 0; i < files.size(); ++i) { + if (files[i].get_object()["name"].as_string() == after) { + begin = i + 1; + after_found = true; + break; + } + } + } + const std::size_t end = std::min( + begin + static_cast(limit), files.size()); + for (std::size_t i = begin; i < end; ++i) page.push_back(files[i]); + + fc::mutable_variant_object m; + m("as_of", now_iso()); + m("dir", snap.snapshot_directory()); + // While a restore runs the database is closed or being rebuilt; reading head there + // would either block or report a half-built value, so it is null during that window. + m("head_block_num", recovering ? fc::variant() + : fc::variant(static_cast(chain.db().head_block_num()))); + m("snapshot_count", static_cast(files.size())); + m("snapshot_total", static_cast(files.size())); + m("offset", offset); + m("limit", limit); + m("after", after.empty() ? fc::variant() : fc::variant(after)); + m("after_found", after_found); + m("has_more", static_cast(end) < static_cast(files.size())); + m("in_progress", snap.is_snapshot_in_progress()); + m("restoring", snap.is_restoring()); + m("recovering", recovering); + m("latest_path", latest_path); + m("latest_name", latest_path.empty() + ? fc::variant() + : fc::variant(bfs::path(latest_path).filename().string())); + m("snapshots", page); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_snapshot_create(const fc::variant &) { + auto &snap = snapshot_started(); + std::string path; + const std::string status = snap.request_snapshot_creation(path); + + if (status == "empty") { + throw rpc_error{-32000, "no state", "node head is 0: nothing to snapshot", + "load a snapshot or wait for the first block"}; + } + if (status == "unavailable") { + throw rpc_error{-32004, "snapshot unavailable", "snapshot plugin is not initialized", ""}; + } + + fc::mutable_variant_object m; + m("status", status); + m("path", path); + if (status == "exists") { + m("note", "a snapshot for the current head block already exists; nothing was written"); + } else if (status == "busy") { + m("note", "another snapshot creation or restore is running; retry when it finishes"); + } else { + m("note", "snapshot creation runs on the node's snapshot thread; poll " + "admin.snapshot.status until in_progress is false"); + } + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_snapshot_restore(const fc::variant ¶ms) { + if (!params.is_object() && !params.is_null()) { + throw rpc_error{-32602, "invalid params", "expected an object {name|path|latest}", ""}; + } + const fc::variant_object empty; + const fc::variant_object &o = params.is_object() ? params.get_object() : empty; + + const std::string name = trim(param_str(o, "name", "")); + const std::string given_path = trim(param_str(o, "path", "")); + bool latest = true; + auto l_it = o.find("latest"); + if (l_it != o.end() && !l_it->value().is_null()) { + if (!l_it->value().is_bool()) { + throw rpc_error{-32602, "invalid params", "'latest' must be a boolean", ""}; + } + latest = l_it->value().as_bool(); + } + + auto &snap = snapshot_started(); + const bfs::path dir(snap.snapshot_directory()); + std::string target; + + if (!given_path.empty()) { + const bfs::path p(given_path); + boost::system::error_code ec; + if (!bfs::exists(p, ec) || ec || !bfs::is_regular_file(p, ec) || ec) { + throw rpc_error{-32602, "snapshot not found", given_path, "see admin.snapshot.status"}; + } + // An absolute path is accepted only if it sits directly in the snapshot directory: + // the admin API must not import arbitrary files from the node's filesystem. + try { + const bfs::path canon_p = bfs::canonical(p); + const bfs::path canon_dir = bfs::canonical(dir); + if (canon_p.parent_path() != canon_dir) { + throw rpc_error{-32602, "path outside snapshot directory", given_path, + "snapshot files live in " + dir.string()}; + } + target = canon_p.string(); + } catch (const rpc_error &) { + throw; + } catch (const std::exception &e) { + throw rpc_error{-32602, "cannot resolve snapshot path", given_path, e.what()}; + } + } else if (!name.empty()) { + if (!is_plain_snapshot_name(name)) { + throw rpc_error{-32602, "invalid snapshot name", name, + "expected a file name ending in .vizjson (see admin.snapshot.status)"}; + } + target = (dir / name).string(); + boost::system::error_code ec; + if (!bfs::exists(target, ec) || ec) { + throw rpc_error{-32602, "snapshot not found", name, "see admin.snapshot.status"}; + } + } else if (!latest) { + throw rpc_error{-32602, "invalid params", "provide 'name'/'path' or set 'latest': true", ""}; + } + + std::string error; + if (!snap.restore_snapshot_now(target, error)) { + throw rpc_error{-32000, "restore not started", error, "see admin.snapshot.status"}; + } + + const std::string resolved = target.empty() ? snap.latest_snapshot_path() : target; + int64_t block = 0; + for (const auto &f : snap.list_snapshots()) { + if (f.path == resolved) { block = static_cast(f.block_num); break; } + } + + fc::mutable_variant_object m; + m("status", "started"); + m("snapshot", resolved); + m("snapshot_name", resolved.empty() ? std::string() : bfs::path(resolved).filename().string()); + m("block_num", block); + m("process_restarted", false); + m("note", "in-process restore: P2P is paused, shared memory is wiped, the snapshot is " + "imported, dlt_block_log is replayed and P2P resumes. The OS process and its " + "uptime are unchanged. Progress: admin.snapshot.status (restoring)."); + return fc::variant(m); +} + +// ── webserver operator methods ─────────────────────────────────────────────────────────── +// The webserver plugin keeps its limits and IP lists in live state: every change below is +// visible to the very next request, without a restart and without re-reading config.ini. +// config.ini seeds the state at startup; changes are persisted to the plugin's admin state +// file (/webserver-admin.json by default) so they survive a restart. +namespace ws = graphene::plugins::webserver; + +graphene::plugins::webserver::webserver_plugin &node_admin_plugin::impl::webserver_started() const { + auto *plugin = appbase::app().find_plugin(); + if (plugin == nullptr || plugin->get_state() != appbase::abstract_plugin::started) { + throw rpc_error{-32004, "webserver unavailable", + "the webserver plugin is not loaded or not started", + "webserver administration needs 'plugin = webserver' in config.ini"}; + } + return *plugin; +} + +fc::variant node_admin_plugin::impl::h_webserver_status(const fc::variant &) { + const ws::webserver_admin_state st = webserver_started().admin_state(0); + + fc::mutable_variant_object limits; + limits("enabled", st.limits.enabled); + limits("per_minute", st.limits.per_minute); + limits("per_hour", st.limits.per_hour); + limits("per_day", st.limits.per_day); + + fc::variants wl, bl; + for (const auto &ip : st.whitelist) wl.push_back(fc::variant(ip)); + for (const auto &ip : st.blacklist) bl.push_back(fc::variant(ip)); + + fc::mutable_variant_object m; + m("as_of", now_iso()); + m("stats_enabled", st.stats_enabled); + m("limits", limits); + m("total_requests", static_cast(st.total_requests)); + m("blocked_requests", static_cast(st.blocked_requests)); + m("limited_requests", static_cast(st.limited_requests)); + m("tracked_ips", static_cast(st.tracked_ips)); + m("whitelist", wl); + m("blacklist", bl); + m("note", "limits are off unless stats are enabled; the blacklist is enforced even " + "when stats are off. Changes apply to the next request."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_ips(const fc::variant ¶ms) { + const fc::variant_object empty; + const fc::variant_object &o = params.is_object() ? params.get_object() : empty; + const int64_t limit = std::max(1, std::min(param_int(o, "limit", 200), 5000)); + + const ws::webserver_admin_state st = + webserver_started().admin_state(static_cast(limit)); + + fc::variants ips; + for (const auto &s : st.ips) { + fc::mutable_variant_object r; + r("ip", s.ip); + r("total", static_cast(s.total)); + r("minute", static_cast(s.minute)); + r("hour", static_cast(s.hour)); + r("day", static_cast(s.day)); + r("blocked", static_cast(s.blocked)); + r("limited", static_cast(s.limited)); + r("last_seen", static_cast(s.last_seen)); + r("last_seen_iso", s.last_seen ? fc::variant(unixtime_iso(s.last_seen)) : fc::variant()); + r("whitelisted", s.whitelisted); + r("blacklisted", s.blacklisted); + ips.push_back(fc::variant(r)); + } + + fc::mutable_variant_object m; + m("as_of", now_iso()); + m("tracked_ips", static_cast(st.tracked_ips)); + m("returned", static_cast(ips.size())); + m("ips", ips); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_limits(const fc::variant ¶ms) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object", + "{enabled, per_minute, per_hour, per_day}; omitted fields keep their value"}; + } + auto &plugin = webserver_started(); + const ws::webserver_admin_state st = plugin.admin_state(0); + const fc::variant_object &o = params.get_object(); + + const bool enabled = o.contains("enabled") ? o["enabled"].as_bool() : st.limits.enabled; + const uint32_t per_minute = static_cast( + std::max(1, param_int(o, "per_minute", st.limits.per_minute))); + const uint32_t per_hour = static_cast( + std::max(1, param_int(o, "per_hour", st.limits.per_hour))); + const uint32_t per_day = static_cast( + std::max(1, param_int(o, "per_day", st.limits.per_day))); + + std::string error; + if (!plugin.set_limits(enabled, per_minute, per_hour, per_day, error)) { + throw rpc_error{-32000, "limits not applied", error, ""}; + } + + const ws::webserver_admin_state after = plugin.admin_state(0); + fc::mutable_variant_object limits; + limits("enabled", after.limits.enabled); + limits("per_minute", after.limits.per_minute); + limits("per_hour", after.limits.per_hour); + limits("per_day", after.limits.per_day); + + fc::mutable_variant_object m; + m("status", "applied"); + m("limits", limits); + m("stats_enabled", after.stats_enabled); + m("note", "applied to the live webserver on the next request and persisted to its " + "admin state file; config.ini is unchanged."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_stats(const fc::variant ¶ms) { + if (!params.is_object() || !params.get_object().contains("enabled")) { + throw rpc_error{-32602, "invalid params", "expected an object {enabled: bool}", ""}; + } + auto &plugin = webserver_started(); + const bool enabled = params.get_object()["enabled"].as_bool(); + plugin.set_stats_enabled(enabled); + + const ws::webserver_admin_state after = plugin.admin_state(0); + fc::mutable_variant_object m; + m("status", enabled ? "enabled" : "disabled"); + m("stats_enabled", after.stats_enabled); + m("limits_enabled", after.limits.enabled); + m("note", "applied to the live webserver immediately and persisted; limits enforcement " + "is controlled separately by admin.webserver.limits."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_list_add(const fc::variant ¶ms, bool whitelist) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object {ip}", ""}; + } + const std::string ip = trim(param_str(params.get_object(), "ip", "")); + if (ip.empty()) { + throw rpc_error{-32602, "invalid params", "missing 'ip'", "a single IPv4 or IPv6 address"}; + } + auto &plugin = webserver_started(); + const bool ok = whitelist ? plugin.whitelist_add(ip) : plugin.blacklist_add(ip); + if (!ok) { + throw rpc_error{-32602, "invalid ip", ip, "a single IPv4 or IPv6 address"}; + } + + const ws::webserver_admin_state st = plugin.admin_state(0); + fc::variants wl, bl; + for (const auto &v : st.whitelist) wl.push_back(fc::variant(v)); + for (const auto &v : st.blacklist) bl.push_back(fc::variant(v)); + + fc::mutable_variant_object m; + m("status", "added"); + m("list", whitelist ? "whitelist" : "blacklist"); + m("ip", ip); + m("whitelist", wl); + m("blacklist", bl); + m("note", whitelist + ? "whitelisted addresses bypass the rate limits (they are still counted)" + : "blacklisted addresses are rejected with HTTP 403; the blacklist wins over the whitelist"); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_list_remove(const fc::variant ¶ms, bool whitelist) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object {ip}", ""}; + } + const std::string ip = trim(param_str(params.get_object(), "ip", "")); + if (ip.empty()) { + throw rpc_error{-32602, "invalid params", "missing 'ip'", ""}; + } + auto &plugin = webserver_started(); + const bool erased = whitelist ? plugin.whitelist_remove(ip) : plugin.blacklist_remove(ip); + + const ws::webserver_admin_state st = plugin.admin_state(0); + fc::variants wl, bl; + for (const auto &v : st.whitelist) wl.push_back(fc::variant(v)); + for (const auto &v : st.blacklist) bl.push_back(fc::variant(v)); + + fc::mutable_variant_object m; + m("status", erased ? "removed" : "not_found"); + m("list", whitelist ? "whitelist" : "blacklist"); + m("ip", ip); + m("whitelist", wl); + m("blacklist", bl); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_reset(const fc::variant &) { + auto &plugin = webserver_started(); + plugin.reset_stats(); + fc::mutable_variant_object m; + m("status", "reset"); + m("note", "counters zeroed; IP lists and limits are unchanged"); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_reload(const fc::variant &) { + auto &plugin = webserver_started(); + std::string error; + if (!plugin.reload_admin_state(error)) { + throw rpc_error{-32000, "state not reloaded", error, + "fix the admin state file or change values via admin.webserver.limits"}; + } + return h_webserver_status(fc::variant(fc::mutable_variant_object())); +} + + +// ── config/plugin editor: plan/apply ───────────────────────────────────────────────────── +void node_admin_plugin::impl::store_plan(const pending_plan &p) { + std::lock_guard lock(plans_mutex); + const uint32_t now = now_unixtime(); + for (auto it = plans.begin(); it != plans.end();) { + if (it->second.expires <= now) it = plans.erase(it); + else ++it; + } + // Plans are tiny, but an authenticated caller must not be able to grow this map without + // bound; 64 outstanding plans is far more than any single operator needs. + if (plans.size() >= 64) { + auto oldest = plans.begin(); + for (auto it = plans.begin(); it != plans.end(); ++it) { + if (it->second.created < oldest->second.created) oldest = it; + } + plans.erase(oldest); + } + plans[p.id] = p; +} + +node_admin_plugin::impl::pending_plan node_admin_plugin::impl::take_plan( + const std::string &id, const token_info &token, const std::string &kind) { + if (id.empty()) { + throw rpc_error{-32602, "invalid params", "missing plan_id", + "create a plan first: admin.config.plan, admin.plugins.plan or " + "admin.validator.plan"}; + } + std::lock_guard lock(plans_mutex); + auto it = plans.find(id); + if (it == plans.end()) { + throw rpc_error{-32004, "unknown plan", id, + "plans are single-use and expire; create a new one"}; + } + if (it->second.kind != kind) { + throw rpc_error{-32602, "invalid params", + "plan kind is '" + it->second.kind + "', not '" + kind + "'", ""}; + } + if (it->second.label != token.label) { + throw rpc_error{-32002, "forbidden", "plan belongs to another token", + "a plan is bound to the token that created it"}; + } + if (it->second.expires <= now_unixtime()) { + plans.erase(it); + throw rpc_error{-32004, "plan expired", id, "create a new plan"}; + } + pending_plan p = it->second; + // Single-use: consumed even when the write below fails, so a plan cannot be replayed. + plans.erase(it); + return p; +} + +fc::variant node_admin_plugin::impl::h_config_plan(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {set: {key: value}}", ""}; + } + const fc::variant_object &o = params.get_object(); + auto it = o.find("set"); + if (it == o.end() || !it->value().is_object() || it->value().get_object().size() == 0) { + throw rpc_error{-32602, "invalid params", + "'set' must be a non-empty object of key: value", + "only allow-listed keys are accepted; see admin.config.get"}; + } + + std::map set; + for (const auto &kv : it->value().get_object()) { + std::string raw; + if (kv.value().is_string()) raw = kv.value().as_string(); + else if (kv.value().is_bool()) raw = kv.value().as_bool() ? "true" : "false"; + else if (kv.value().is_int64()) raw = std::to_string(kv.value().as_int64()); + else if (kv.value().is_uint64()) raw = std::to_string(kv.value().as_uint64()); + else { + throw rpc_error{-32602, "invalid params", + "value for '" + kv.key() + "' must be a string, number or boolean", + ""}; + } + std::string norm; + std::string why; + if (!normalize_config_value(kv.key(), raw, norm, why)) { + throw rpc_error{-32602, "invalid params", why, + "the admin API edits only allow-listed keys"}; + } + set[kv.key()] = norm; + } + + std::string text; + std::string why; + if (!read_text_file(config_path(), text, why)) { + throw rpc_error{-32004, "config unavailable", why, "check the data directory"}; + } + std::vector lines = split_lines(text); + fc::variants diff; + for (const auto &kv : set) { + std::string old; + const bool found = allowed_config_value(lines, kv.first, old); + if (found && old == kv.second) continue; + line_edit e = set_allowed_config_value(lines, kv.first, kv.second); + if (!e.error.empty()) throw rpc_error{-32602, "invalid params", e.error, ""}; + lines = std::move(e.lines); + fc::mutable_variant_object d; + d("key", kv.first); + d("from", found ? fc::variant(old) : fc::variant()); + d("to", kv.second); + diff.push_back(fc::variant(d)); + } + if (diff.empty()) { + throw rpc_error{-32602, "invalid params", "no changes: every value already matches", ""}; + } + + pending_plan p; + p.id = random_plan_id(); + p.label = token.label; + p.kind = "config"; + p.created = now_unixtime(); + p.expires = p.created + plan_ttl_sec; + p.base_sha256 = text_sha256(text); + p.set = std::move(set); + p.diff = fc::variant(diff); + store_plan(p); + + fc::mutable_variant_object m; + m("plan_id", p.id); + m("kind", "config"); + m("config_path", config_path()); + m("diff", diff); + m("needs_restart", true); + m("expires_at", unixtime_iso(p.expires)); + m("hint", "nothing was written yet; apply with admin.config.apply {plan_id}. The plan is " + "single-use, bound to this token, and refused if config.ini changes meanwhile."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_config_apply(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {plan_id}", ""}; + } + const pending_plan p = + take_plan(trim(param_str(params.get_object(), "plan_id", "")), token, "config"); + + std::lock_guard write_lock(config_write_mutex); + std::string text; + std::string why; + if (!read_text_file(config_path(), text, why)) { + throw rpc_error{-32004, "config unavailable", why, "check the data directory"}; + } + if (text_sha256(text) != p.base_sha256) { + throw rpc_error{-32000, "config changed", + "config.ini was modified after this plan was made; nothing was written", + "create a new plan with admin.config.plan"}; + } + + std::vector lines = split_lines(text); + for (const auto &kv : p.set) { + std::string norm; + std::string why2; + if (!normalize_config_value(kv.first, kv.second, norm, why2)) { + throw rpc_error{-32602, "invalid params", why2, ""}; + } + line_edit e = set_allowed_config_value(lines, kv.first, norm); + if (!e.error.empty()) throw rpc_error{-32602, "invalid params", e.error, ""}; + lines = std::move(e.lines); + } + + std::string backup; + std::string werr; + if (!write_config_atomic(config_path(), join_lines(lines, true), backup, werr)) { + throw rpc_error{-32000, "write failed", werr, + "the original config.ini was left untouched"}; + } + + fc::mutable_variant_object m; + m("applied", true); + m("kind", "config"); + m("config_path", config_path()); + m("backup", backup); + m("diff", p.diff); + m("needs_restart", true); + m("restart_hint", + "config.ini is read at startup: call admin.restart {confirm:\"restart\"} once the " + "process is supervised (docker --restart unless-stopped or systemd Restart=always)."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_config_backups(const fc::variant &) { + const bfs::path cfg(config_path()); + const std::string prefix = cfg.filename().string() + ".bak-"; + struct backup_row { + std::time_t mtime; + std::string name; + uintmax_t size; + }; + std::vector rows; + boost::system::error_code ec; + bfs::directory_iterator it(cfg.parent_path(), ec); + const bfs::directory_iterator end; + if (ec) { + throw rpc_error{-32004, "config unavailable", + "cannot list " + cfg.parent_path().string() + ": " + ec.message(), ""}; + } + for (; it != end; it.increment(ec)) { + if (ec) break; + const std::string name = it->path().filename().string(); + if (name.size() <= prefix.size() || name.compare(0, prefix.size(), prefix) != 0) continue; + const uintmax_t size = bfs::file_size(it->path(), ec); + if (ec) continue; + rows.push_back(backup_row{bfs::last_write_time(it->path(), ec), name, size}); + } + std::sort(rows.begin(), rows.end(), [](const backup_row &a, const backup_row &b) { + if (a.mtime != b.mtime) return a.mtime > b.mtime; + return a.name > b.name; + }); + + fc::variants files; + for (const backup_row &r : rows) { + fc::mutable_variant_object f; + f("name", r.name); + f("path", (cfg.parent_path() / r.name).string()); + f("bytes", static_cast(r.size)); + f("modified", iso_utc(fc::time_point_sec(static_cast(r.mtime)))); + files.push_back(fc::variant(f)); + } + + fc::mutable_variant_object m; + m("config_path", config_path()); + m("count", static_cast(files.size())); + m("backups", files); + m("hint", "each apply writes one backup before replacing config.ini; copy one back and " + "restart to roll back by hand"); + return fc::variant(m); +} + +/// Read an array of strings from params, ignoring an absent or null key. +static void read_string_list(const fc::variant_object &o, const char *key, + std::vector &out) { + auto it = o.find(key); + if (it == o.end() || it->value().is_null()) return; + if (!it->value().is_array()) { + throw rpc_error{-32602, "invalid params", std::string("'") + key + "' must be an array", + ""}; + } + for (const fc::variant &v : it->value().get_array()) { + if (!v.is_string()) { + throw rpc_error{-32602, "invalid params", + std::string("'") + key + "' must contain plugin name strings", ""}; + } + const std::string name = trim(v.as_string()); + if (!name.empty()) out.push_back(name); + } +} + +fc::variant node_admin_plugin::impl::h_plugins_plan(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {add: [], remove: []}", ""}; + } + const fc::variant_object &o = params.get_object(); + std::vector add; + std::vector remove; + read_string_list(o, "add", add); + read_string_list(o, "remove", remove); + if (add.empty() && remove.empty()) { + throw rpc_error{-32602, "invalid params", "nothing to do: add and remove are empty", + "example: {\"add\":[\"block_info\"],\"remove\":[]}"}; + } + + std::string text; + std::string why; + if (!read_text_file(config_path(), text, why)) { + throw rpc_error{-32004, "config unavailable", why, "check the data directory"}; + } + const line_edit e = edit_plugin_lines(split_lines(text), add, remove); + if (!e.error.empty()) throw rpc_error{-32602, "invalid params", e.error, ""}; + + fc::variants diff; + for (const std::string &name : add) { + fc::mutable_variant_object d; + d("op", "add"); + d("name", name); + diff.push_back(fc::variant(d)); + } + for (const std::string &name : remove) { + fc::mutable_variant_object d; + d("op", "remove"); + d("name", name); + diff.push_back(fc::variant(d)); + } + + pending_plan p; + p.id = random_plan_id(); + p.label = token.label; + p.kind = "plugins"; + p.created = now_unixtime(); + p.expires = p.created + plan_ttl_sec; + p.base_sha256 = text_sha256(text); + p.add = std::move(add); + p.remove = std::move(remove); + p.diff = fc::variant(diff); + store_plan(p); + + fc::mutable_variant_object m; + m("plan_id", p.id); + m("kind", "plugins"); + m("config_path", config_path()); + m("diff", diff); + m("needs_restart", true); + m("expires_at", unixtime_iso(p.expires)); + m("hint", "nothing was written yet; apply with admin.plugins.apply {plan_id}. The plan is " + "single-use, bound to this token, and refused if config.ini changes meanwhile."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_plugins_apply(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {plan_id}", ""}; + } + const pending_plan p = + take_plan(trim(param_str(params.get_object(), "plan_id", "")), token, "plugins"); + + std::lock_guard write_lock(config_write_mutex); + std::string text; + std::string why; + if (!read_text_file(config_path(), text, why)) { + throw rpc_error{-32004, "config unavailable", why, "check the data directory"}; + } + if (text_sha256(text) != p.base_sha256) { + throw rpc_error{-32000, "config changed", + "config.ini was modified after this plan was made; nothing was written", + "create a new plan with admin.plugins.plan"}; + } + + const line_edit e = edit_plugin_lines(split_lines(text), p.add, p.remove); + if (!e.error.empty()) throw rpc_error{-32602, "invalid params", e.error, ""}; + + std::string backup; + std::string werr; + if (!write_config_atomic(config_path(), join_lines(e.lines, true), backup, werr)) { + throw rpc_error{-32000, "write failed", werr, + "the original config.ini was left untouched"}; + } + + fc::mutable_variant_object m; + m("applied", true); + m("kind", "plugins"); + m("config_path", config_path()); + m("backup", backup); + m("diff", p.diff); + m("needs_restart", true); + m("restart_hint", + "plugin changes are applied by a restart: call admin.restart {confirm:\"restart\"} once " + "the process is supervised (docker --restart unless-stopped or systemd Restart=always)."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_plan_cancel(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {plan_id}", ""}; + } + const std::string id = trim(param_str(params.get_object(), "plan_id", "")); + if (id.empty()) throw rpc_error{-32602, "invalid params", "missing plan_id", ""}; + + std::lock_guard lock(plans_mutex); + auto it = plans.find(id); + if (it == plans.end()) { + throw rpc_error{-32004, "unknown plan", id, "plans are single-use and expire"}; + } + if (it->second.label != token.label) { + throw rpc_error{-32002, "forbidden", "plan belongs to another token", ""}; + } + plans.erase(it); + + fc::mutable_variant_object m; + m("cancelled", true); + m("plan_id", id); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_restart(const fc::variant ¶ms) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {confirm: \"restart\"}", ""}; + } + const std::string confirm = trim(param_str(params.get_object(), "confirm", "")); + if (confirm != "restart") { + throw rpc_error{-32602, "invalid params", + "confirm must be exactly the word 'restart'", + "this stops the node process; a supervisor has to bring it back"}; + } + if (restart_requested.exchange(true)) { + throw rpc_error{-32000, "restart already requested", + "the process is already shutting down", ""}; + } + + // Reply first, quit second: the handler runs on the listener thread and appbase::quit() + // stops the io_service, so the short delay lets this HTTP response reach the client. + std::thread([] { + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + ilog("node_admin: restart requested through the admin API; asking appbase to quit"); + appbase::app().quit(); + }).detach(); + + fc::mutable_variant_object m; + m("ok", true); + m("restarting", true); + m("exit_code", 0); + m("hint", "the process exits cleanly right after this reply. Bring-up belongs to the " + "supervisor: docker --restart unless-stopped or systemd Restart=always. Without " + "one, the node stays down."); + return fc::variant(m); +} + +// ── shared-memory objects (chainbase) ─────────────────────────────────────────────────── +objects_state node_admin_plugin::impl::collect_objects() const { + objects_state s; + s.state_dir = chain.get_state_dir(); + boost::system::error_code ec; + const bfs::path dir(s.state_dir); + s.state_dir_exists = !s.state_dir.empty() && bfs::exists(dir, ec) && bfs::is_directory(dir, ec); + + // The chain plugin opens the database with /state as its data dir, so block_log + // and schema_version live NEXT TO shared_memory.bin, not in the application data dir. + // Reading the app data dir reported both as absent on every healthy node, and the judge + // then called that state "corrupted" and offered a resync. + const bfs::path chain_state = bfs::path(data_dir()) / "state"; + + // The block log is a file in graphene, but the chain plugin wipes it with remove_all, so + // both shapes are read: a directory here would silently report "no block log" otherwise. + const bfs::path block_log = chain_state / "block_log"; + s.block_log_present = bfs::exists(block_log, ec); + if (s.block_log_present) { + if (bfs::is_regular_file(block_log, ec)) { + s.block_log_bytes = bfs::file_size(block_log, ec); + } else if (bfs::is_directory(block_log, ec)) { + for (bfs::directory_iterator it(block_log), end; it != end; ++it) { + if (bfs::is_regular_file(it->path(), ec)) s.block_log_bytes += bfs::file_size(it->path(), ec); + } + } + } + + if (s.state_dir_exists) { + const bfs::path shm = dir / "shared_memory.bin"; + s.shm_exists = bfs::exists(shm, ec); + if (s.shm_exists) s.shm_bytes = bfs::file_size(shm, ec); + + // Files that a successful startup or a successful resize removes. Finding one while + // the node is running means an operation was interrupted, which is exactly the state + // a repair has to clear. + for (const char *marker : {"undo_all_in_progress", "resize_in_progress", + "force_resync", "force_replay"}) { + if (bfs::exists(dir / marker, ec)) s.markers.push_back(marker); + } + } + + s.schema_file_present = read_uint_file(chain_state / "schema_version", s.stored_schema); + + graphene::chain::database &db = chain.db(); + s.recovering = chain.is_recovering(); + try { + // Sizes are read under the same weak read lock the chain API uses: without it the + // multi-index containers would be walked while the block thread mutates them. + db.with_weak_read_lock([&]() { + s.revision = static_cast(db.revision()); + s.head_block_num = db.head_block_num(); + s.mapped_bytes = static_cast(db.max_memory()); + s.reserved_bytes = static_cast(db.reserved_memory()); + s.free_bytes = static_cast(db.segment_manager()->get_free_memory()); + for (auto it = db.index_list_begin(); it != db.index_list_end(); ++it) { + chainbase::abstract_index *idx = *it; + const int64_t n = static_cast(idx->size()); + const std::string type = idx->name(); + objects_check::index_row row; + row.type = type; + row.type_id = static_cast(idx->type_id()); + row.objects = n; + row.revision = static_cast(idx->revision()); + s.indexes.push_back(row); + s.total_objects += n; + if (ends_with_text(type, "::dynamic_global_property_object")) s.dgp_objects = n; + else if (ends_with_text(type, "::validator_schedule_object")) s.schedule_objects = n; + else if (ends_with_text(type, "::account_object")) s.account_objects = n; + } + }); + } catch (const std::exception &e) { + s.lock_error = e.what(); + } + + std::sort(s.indexes.begin(), s.indexes.end(), + [](const objects_check::index_row &a, const objects_check::index_row &b) { + if (a.objects != b.objects) return a.objects > b.objects; + return a.type < b.type; + }); + return s; +} + +namespace { +std::string state_dir_marker_path(const objects_state &s, const char *name) { + return (bfs::path(s.state_dir) / name).string(); +} +} // namespace + +fc::variant node_admin_plugin::impl::h_objects_list(const fc::variant &) { + const objects_state s = collect_objects(); + + fc::variants indexes; + for (const auto &row : s.indexes) { + fc::mutable_variant_object e; + e("type", row.type); + e("type_id", row.type_id); + e("objects", row.objects); + e("revision", row.revision); + indexes.push_back(fc::variant(e)); + } + + fc::mutable_variant_object schema; + schema("file_present", s.schema_file_present); + schema("stored", static_cast(s.stored_schema)); + schema("compiled", static_cast(CHAIN_SCHEMA_VERSION)); + schema("match", s.schema_file_present && s.stored_schema == CHAIN_SCHEMA_VERSION); + + fc::mutable_variant_object block_log; + block_log("path", (bfs::path(data_dir()) / "state" / "block_log").string()); + block_log("present", s.block_log_present); + block_log("bytes", static_cast(s.block_log_bytes)); + + fc::mutable_variant_object m; + m("state_dir", s.state_dir); + m("state_dir_exists", s.state_dir_exists); + m("shared_memory_path", (bfs::path(s.state_dir) / "shared_memory.bin").string()); + m("shared_memory_exists", s.shm_exists); + m("shared_memory_bytes", static_cast(s.shm_bytes)); + m("mapped_bytes", static_cast(s.mapped_bytes)); + m("free_bytes", static_cast(s.free_bytes)); + m("used_bytes", static_cast(s.mapped_bytes > s.free_bytes ? s.mapped_bytes - s.free_bytes : 0)); + m("reserved_bytes", static_cast(s.reserved_bytes)); + m("revision", s.revision); + m("head_block_num", static_cast(s.head_block_num)); + m("recovering", s.recovering); + m("schema", schema); + m("block_log", block_log); + fc::variants markers; + for (const std::string &x : s.markers) markers.push_back(fc::variant(x)); + m("markers", markers); + m("index_count", static_cast(s.indexes.size())); + m("total_objects", s.total_objects); + m("indexes", indexes); + m("generated_at", now_iso()); + // Always present: a client asks "did the lock answer?" and must be able to tell null from + // a missing field without knowing this server's omissions. + m("lock_error", s.lock_error.empty() ? fc::variant() : fc::variant(s.lock_error)); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_objects_check(const fc::variant &) { + const objects_state s = collect_objects(); + const objects_check::judgement j = + objects_check::judge(s, static_cast(CHAIN_SCHEMA_VERSION)); + + fc::variants findings; + for (const objects_check::finding &f : j.findings) { + fc::mutable_variant_object e; + e("severity", f.severity); + e("code", f.code); + e("text", f.text); + findings.push_back(fc::variant(e)); + } + + fc::mutable_variant_object m; + m("verdict", j.verdict); + m("critical", static_cast(j.critical)); + m("warnings", static_cast(j.warnings)); + m("findings", findings); + m("recommended_repair", j.recommended_repair.empty() ? fc::variant() : fc::variant(j.recommended_repair)); + m("replay_available", j.replay_available); + m("state_dir", s.state_dir); + m("checked_at", now_iso()); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_objects_repair(const fc::variant ¶ms) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {mode: \"replay\"|\"resync\", confirm: \"repair\"}", ""}; + } + const std::string mode = trim(param_str(params.get_object(), "mode", "replay")); + if (mode != "replay" && mode != "resync") { + throw rpc_error{-32602, "invalid params", "mode must be 'replay' or 'resync'", ""}; + } + const std::string confirm = trim(param_str(params.get_object(), "confirm", "")); + if (confirm != "repair") { + throw rpc_error{-32602, "invalid params", + "confirm must be exactly the word 'repair'", + "repair rewrites node state; the whole point is that it cannot happen by accident"}; + } + + const objects_state s = collect_objects(); + if (!s.state_dir_exists) { + throw rpc_error{-32000, "state dir missing", + "shared-memory directory does not exist: " + s.state_dir, + "start the node once so it creates its state directory"}; + } + if (mode == "replay" && !objects_check::has_replayable_blocks(s)) { + throw rpc_error{-32000, "no block log", + "replay rebuilds state from the block log, and the block log is absent or empty", + "use mode=resync: it wipes state and blocks and rebuilds from the network"}; + } + + // Startup precedence is resync > replay: the chain plugin removes a force_replay marker + // when force_resync is also present. Honouring the mode the operator confirmed therefore + // means clearing the marker that would otherwise outrank it, and naming it in the reply — + // otherwise "replay (keeps blocks)" would silently drop the block log. The chain plugin is + // the other producer of force_resync (the p2p wedge watchdog), so this case is reachable. + const char *opposite = mode == "replay" ? "force_resync" : "force_replay"; + const std::string opposite_path = state_dir_marker_path(s, opposite); + boost::system::error_code rm_ec; + const bool removed_opposite = bfs::remove(opposite_path, rm_ec); + if (rm_ec) { + throw rpc_error{-32000, "cannot clear conflicting marker", + opposite_path + ": " + rm_ec.message(), + "the opposite marker outranks the requested mode at startup; " + "repair refused rather than promising an effect it cannot deliver"}; + } + + if (restart_requested.exchange(true)) { + throw rpc_error{-32000, "restart already requested", "the process is already shutting down", ""}; + } + + // The chain plugin reads these markers at startup, before it maps shared memory: + // force_replay -> wipe state, keep the block log, reindex it from block 1 + // force_resync -> wipe state and block log, come up empty and resync from peers + // Writing them now and quitting is safer than acting on a live mapping: the file is + // closed by an ordinary shutdown, so the repair starts from a consistent file handle. + const char *marker = mode == "replay" ? "force_replay" : "force_resync"; + const std::string marker_path = state_dir_marker_path(s, marker); + { + std::ofstream f(marker_path.c_str(), std::ios::trunc); + f << "node_admin repair request at " << now_iso() << " mode=" << mode << "\n"; + f.flush(); + if (!f) { + restart_requested.store(false); + throw rpc_error{-32000, "cannot write repair marker", marker_path, + "check the state directory permissions"}; + } + } + + std::thread([] { + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + ilog("node_admin: repair requested through the admin API; asking appbase to quit"); + appbase::app().quit(); + }).detach(); + + fc::mutable_variant_object m; + m("ok", true); + m("restarting", true); + m("mode", mode); + m("marker", marker_path); + m("removed_marker", removed_opposite ? fc::variant(opposite_path) : fc::variant()); + // The effect text must match the startup the chain plugin will actually take; see + // objects_check::repair_effect (unit-tested) for the snapshot-mode case. + m("effect", objects_check::repair_effect(mode, snapshot_replay_from, snapshot_configured)); + m("snapshot_replay", snapshot_replay_from); + m("hint", "the process exits cleanly right after this reply. Bring-up belongs to the " + "supervisor: docker --restart unless-stopped or systemd Restart=always."); + return fc::variant(m); +} + +// ── validator chain operations (validator_guard) ───────────────────────────────────────── +// Decision q#3051=D: node_admin does not keep its own key. The guard already holds +// signing+active keys per validator, signs validator_update inside its own plugin, and +// exposes a plain status/action API here. `chain` is granted only while the guard is +// live with an active key, which is what the dispatch check above enforces. + +graphene::plugins::validator_guard::validator_guard_plugin * +node_admin_plugin::impl::guard_plugin() const { + return appbase::app().find_plugin(); +} + +bool node_admin_plugin::impl::guard_ready() const { + auto *g = guard_plugin(); + return g != nullptr && + g->get_state() == appbase::abstract_plugin::started && + g->chain_actions_available(); +} + +fc::variant node_admin_plugin::impl::h_validator_list(const fc::variant &) { + auto *g = guard_plugin(); + const bool loaded = g != nullptr; + const bool started = loaded && g->get_state() == appbase::abstract_plugin::started; + const bool available = loaded && started && g->chain_actions_available(); + + fc::mutable_variant_object ginfo; + ginfo("loaded", loaded); + ginfo("started", started); + ginfo("available", available); + if (!loaded) { + ginfo("reason", "validator_guard is not loaded; add `plugin = validator_guard` to config.ini"); + } else if (!started) { + ginfo("reason", "validator_guard has not finished starting"); + } else if (!available) { + ginfo("reason", "no validator is configured with an active key " + "(validator-guard-validator = [name,signing_wif,active_wif])"); + } + + fc::variants arr; + if (available) { + for (const auto &s : g->validators_status()) { + fc::mutable_variant_object m; + m("name", s.name); + m("on_chain", s.on_chain); + m("enabled", s.enabled); + m("hold", s.hold); + m("active_authority", s.active_authority); + m("scheduled_in", s.scheduled_in > 0 ? fc::variant(static_cast(s.scheduled_in)) + : fc::variant()); + m("signing_key", s.signing_key.empty() ? fc::variant() : fc::variant(s.signing_key)); + m("configured_key", s.configured_key.empty() ? fc::variant() + : fc::variant(s.configured_key)); + arr.push_back(fc::variant(m)); + } + } + + fc::mutable_variant_object m; + m("guard", ginfo); + m("validators", arr); + return fc::variant(m); +} + +// Who is scheduled in the current validator schedule, and whether the chain is in emergency +// consensus. Names only: signing keys live in the validator plugin and are shown by +// admin.validator.list (and only for the validators this node actually runs). +fc::variant node_admin_plugin::impl::h_validators_active(const fc::variant &) { + const graphene::chain::database &db = chain.db(); + const auto &wso = db.get_validator_schedule_object(); + const auto &dgp = db.get_dynamic_global_properties(); + + fc::variants names; + std::set seen; + for (const auto &n : wso.current_shuffled_validators) { + const std::string name = n; + if (name.empty() || !seen.insert(name).second) continue; + names.push_back(fc::variant(name)); + } + + bool validator_loaded = false; + if (auto *vp = appbase::app().find_plugin()) { + validator_loaded = vp->get_state() == appbase::abstract_plugin::started; + } + + fc::mutable_variant_object m; + m("as_of", now_iso()); + m("head_block_num", static_cast(db.head_block_num())); + m("count", static_cast(names.size())); + m("num_scheduled", static_cast(wso.num_scheduled_validators)); + m("validators", names); + m("emergency_consensus_active", dgp.emergency_consensus_active); + m("emergency_consensus_start_block", + static_cast(dgp.emergency_consensus_active ? dgp.emergency_consensus_start_block : 0)); + m("validator_plugin_loaded", validator_loaded); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_validator_plan(const fc::variant ¶ms, + const token_info &token) { + namespace vg = graphene::plugins::validator_guard; + + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {actions: [{name, action}]}", ""}; + } + const fc::variant_object &o = params.get_object(); + auto it = o.find("actions"); + if (it == o.end() || !it->value().is_array() || it->value().get_array().empty()) { + throw rpc_error{-32602, "invalid params", "'actions' must be a non-empty array", + "example: {\"actions\":[{\"name\":\"val1\",\"action\":\"disable\"}]}"}; + } + + auto *g = guard_plugin(); + std::map by_name; + for (const auto &s : g->validators_status()) by_name[s.name] = s; + + fc::variants actions; + fc::variants diff; + std::set seen; + std::size_t tx_count = 0; + + for (const fc::variant &a : it->value().get_array()) { + if (!a.is_object()) { + throw rpc_error{-32602, "invalid params", "each action must be an object", ""}; + } + const fc::variant_object &ao = a.get_object(); + const std::string name = trim(param_str(ao, "name", "")); + if (name.empty()) { + throw rpc_error{-32602, "invalid params", "each action needs a 'name'", ""}; + } + if (!seen.insert(name).second) { + throw rpc_error{-32602, "invalid params", + "validator '" + name + "' appears twice in one plan", ""}; + } + auto sit = by_name.find(name); + if (sit == by_name.end()) { + throw rpc_error{-32602, "invalid params", + "validator '" + name + "' is not configured in validator-guard", + "admin.validator.list shows the configured validators"}; + } + if (!sit->second.on_chain) { + throw rpc_error{-32602, "invalid params", + "validator '" + name + "' does not exist on chain", ""}; + } + + const std::string act = trim(param_str(ao, "action", "")); + vg::validator_action kind; + if (act == "enable") kind = vg::validator_action::enable; + else if (act == "disable") kind = vg::validator_action::disable; + else if (act == "set_key") kind = vg::validator_action::set_key; + else { + throw rpc_error{-32602, "invalid params", + "action must be 'enable', 'disable' or 'set_key', got '" + act + "'", ""}; + } + + std::string key; + if (kind == vg::validator_action::set_key) { + key = trim(param_str(ao, "signing_key", "")); + if (key.empty()) { + throw rpc_error{-32602, "invalid params", "set_key requires 'signing_key'", ""}; + } + try { + graphene::protocol::public_key_type parsed(key); + } catch (const fc::exception &e) { + throw rpc_error{-32602, "invalid params", + "invalid signing_key: " + e.to_string(), + "expected a base58 public key, e.g. VIZ8..."}; + } + } + + const std::string from = sit->second.signing_key; + std::string to; + if (kind == vg::validator_action::enable) to = sit->second.configured_key; + else if (kind == vg::validator_action::set_key) to = key; + + const bool hold_to = (kind == vg::validator_action::disable); + const bool tx_needed = (from != to); + const bool hold_change = (sit->second.hold != hold_to); + if (!tx_needed && !hold_change) continue; // already in the requested state + + fc::mutable_variant_object d; + d("name", name); + d("action", act); + d("from", from.empty() ? fc::variant() : fc::variant(from)); + d("to", to.empty() ? fc::variant() : fc::variant(to)); + d("hold_from", sit->second.hold); + d("hold_to", hold_to); + d("tx_needed", tx_needed); + diff.push_back(fc::variant(d)); + + fc::mutable_variant_object sa; + sa("name", name); + sa("action", act); + sa("signing_key", key); + sa("expected_key", from); + sa("hold_from", sit->second.hold); + actions.push_back(fc::variant(sa)); + if (tx_needed) ++tx_count; + } + + if (diff.empty()) { + throw rpc_error{-32602, "invalid params", + "no changes: every validator is already in the requested state", ""}; + } + + pending_plan p; + p.id = random_plan_id(); + p.label = token.label; + p.kind = "validators"; + p.created = now_unixtime(); + p.expires = p.created + plan_ttl_sec; + p.actions = std::move(actions); + p.diff = fc::variant(diff); + store_plan(p); + + fc::mutable_variant_object m; + m("plan_id", p.id); + m("kind", "validators"); + m("diff", diff); + m("transactions", static_cast(tx_count)); + m("expires_at", unixtime_iso(p.expires)); + m("hint", "nothing was signed or broadcast yet; apply with admin.validator.apply {plan_id}. " + "The plan is single-use, bound to this token, and each action is refused if the " + "on-chain signing key changed after this plan was made."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_validator_apply(const fc::variant ¶ms, + const token_info &token) { + namespace vg = graphene::plugins::validator_guard; + + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {plan_id}", ""}; + } + const pending_plan p = + take_plan(trim(param_str(params.get_object(), "plan_id", "")), token, "validators"); + + auto *g = guard_plugin(); + if (g == nullptr || !g->chain_actions_available()) { + throw rpc_error{-32002, "forbidden", "validator-guard is no longer active", + "the plan was consumed; configure the guard and create a new plan"}; + } + + std::vector reqs; + reqs.reserve(p.actions.size()); + for (const fc::variant &sv : p.actions) { + const fc::variant_object &so = sv.get_object(); + vg::validator_action_request r; + r.name = param_str(so, "name", ""); + const std::string act = param_str(so, "action", ""); + if (act == "enable") r.action = vg::validator_action::enable; + else if (act == "disable") r.action = vg::validator_action::disable; + else r.action = vg::validator_action::set_key; + r.signing_key = param_str(so, "signing_key", ""); + r.check_expected = true; + r.expected_key = param_str(so, "expected_key", ""); + reqs.push_back(r); + } + + const std::vector res = g->apply_validator_actions(reqs); + + fc::variants out; + int64_t applied = 0; + int64_t unchanged = 0; + int64_t failed = 0; + for (const auto &r : res) { + fc::mutable_variant_object m; + m("name", r.name); + m("action", r.action); + if (!r.ok) { + m("status", "failed"); + m("error", r.error); + ++failed; + } else if (r.changed) { + m("status", "applied"); + m("tx_id", r.tx_id); + ++applied; + } else { + m("status", "already"); + ++unchanged; + } + if (r.ok) m("hold", r.action == "disable"); + out.push_back(fc::variant(m)); + } + + fc::mutable_variant_object m; + m("plan_id", p.id); + m("kind", "validators"); + m("applied", applied); + m("unchanged", unchanged); + m("failed", failed); + m("results", out); + m("hint", "signed transactions are in this node's pending pool and were broadcast to peers; " + "no restart is needed. A disable sets a persisted operator hold, so auto-restore " + "will not turn the validator back on after a restart either."); + return fc::variant(m); +} + +// ── MCP (Streamable HTTP) ──────────────────────────────────────────────────────────────── +// The MCP endpoint is the same loopback listener and the same bearer tokens as admin.*. +// Tools call the admin methods 1:1, so every tool invocation lands in the audit log with +// its own method name (`tools/call` is the JSON-RPC method; the tool name is in params). +fc::variant node_admin_plugin::impl::mcp_tools() const { + auto schema = [](fc::variants props, fc::variants required) { + fc::mutable_variant_object s; + s("type", "object"); + s("properties", props); + s("additionalProperties", false); + if (!required.empty()) s("required", required); + return fc::variant(s); + }; + auto prop = [](const char *type, const char *desc) { + fc::mutable_variant_object p; + p("type", type); + p("description", desc); + return fc::variant(p); + }; + auto tool = [&](const char *name, const char *desc, const fc::variant &input) { + fc::mutable_variant_object t; + t("name", name); + t("description", desc); + t("inputSchema", input); + return fc::variant(t); + }; + const fc::variant no_args = schema(fc::variants{}, fc::variants{}); + + fc::variants logs_props; + logs_props.push_back(prop("string", "Name from admin-log-file entries")); + logs_props.push_back(prop("integer", "Maximum bytes to read from the tail (default 16384)")); + logs_props.push_back(prop("integer", "Maximum lines to return (default 200)")); + fc::variants logs_required; + logs_required.push_back(fc::variant(std::string("name"))); + + fc::variants audit_props; + audit_props.push_back(prop("integer", "Maximum audit lines to return (default 100)")); + + fc::variants tools; + tools.push_back(tool("admin.ping", "Liveness check: server time and ok=true.", no_args)); + tools.push_back(tool("admin.auth.me", + "Who the presented token is: its label and scopes. The token and its " + "hash are never returned. Clients use it to hide actions they cannot run.", + no_args)); + tools.push_back(tool("admin.node_info", + "Chain id, head block, hardfork, uptime, head age and emergency " + "consensus state.", no_args)); + tools.push_back(tool("admin.diagnostics", + "Node info plus data dir, peer count, disk/RSS/loadavg and plugin counters.", + no_args)); + tools.push_back(tool("admin.plugins.list", + "Plugins configured in config.ini with observed lifecycle state and the " + "name of the method that reports each plugin's own status.", no_args)); + + fc::variants plugin_name_props; + plugin_name_props.push_back(prop("string", "Plugin name exactly as written in config.ini, e.g. p2p")); + fc::variants plugin_name_required; + plugin_name_required.push_back(fc::variant(std::string("name"))); + tools.push_back(tool("admin.plugins.status", + "Lifecycle state plus the plugin's own status for p2p, webserver, " + "snapshot and validator_guard. For other plugins it says why no " + "status exists instead of returning an empty object.", + schema(plugin_name_props, plugin_name_required))); + + tools.push_back(tool("admin.objects.list", + "Chainbase object types with row counts per index, the mapped file " + "size, free space, revision vs head and state-repair markers.", no_args)); + tools.push_back(tool("admin.objects.check", + "Same view as admin.objects.list, judged: verdict ok/warn/corrupted/" + "unreadable, named findings, and the repair mode that would fix them.", + no_args)); + + fc::variants repair_props; + repair_props.push_back(prop("string", "'replay' rebuilds state from the block log; " + "'resync' wipes state and blocks and syncs from peers")); + repair_props.push_back(prop("string", "Must be exactly 'repair'")); + fc::variants repair_required; + repair_required.push_back(fc::variant(std::string("mode"))); + repair_required.push_back(fc::variant(std::string("confirm"))); + tools.push_back(tool("admin.objects.repair", + "Write the repair marker and restart: replay keeps the block log, " + "resync drops it. Needs the 'process' scope; the supervisor must " + "bring the node back.", + schema(repair_props, repair_required))); + tools.push_back(tool("admin.config.get", + "Effective config.ini by sections with secret values masked.", no_args)); + tools.push_back(tool("admin.logs.list", + "Log files the admin API may tail, as configured by admin-log-file, " + "with size and whether the file exists yet.", no_args)); + tools.push_back(tool("admin.logs.tail", + "Tail an allow-listed log file; secrets and WIFs are masked.", + schema(logs_props, logs_required))); + tools.push_back(tool("admin.audit.tail", + "Tail the node_admin audit log (one JSON line per request outcome).", + schema(audit_props, fc::variants{}))); + + fc::variants endpoint_props; + endpoint_props.push_back(prop("string", "Peer endpoint as IPv4:port, e.g. 1.2.3.4:1776")); + fc::variants endpoint_required; + endpoint_required.push_back(fc::variant(std::string("endpoint"))); + + tools.push_back(tool("admin.p2p.status", + "P2P diagnostics, the same facts the node logs every minute and five " + "minutes: head and LIB, the DLT block range held locally, lag behind " + "the best peer, uptime, connection counts, and every peer with its " + "lifecycle, head, fork status, trust and ban state.", + no_args)); + tools.push_back(tool("admin.p2p.add_node", + "Add a P2P peer endpoint and dial it now. Needs the 'p2p' scope.", + schema(endpoint_props, endpoint_required))); + tools.push_back(tool("admin.p2p.add_trusted", + "Add a P2P peer as trusted and dial it now: never soft-banned, kept " + "through known-peer GC. Needs the 'p2p' scope.", + schema(endpoint_props, endpoint_required))); + tools.push_back(tool("admin.p2p.clear_banned", + "Clear every soft ban and retry those peers immediately. " + "Needs the 'p2p' scope.", + no_args)); + + fc::variants restore_props; + restore_props.push_back(prop("string", + "Snapshot file name from admin.snapshot.status, e.g. snapshot-block-84360000.vizjson")); + restore_props.push_back(prop("string", + "Absolute path of a snapshot file located directly in the snapshot directory")); + restore_props.push_back(prop("boolean", + "Restore the highest-block snapshot in the snapshot directory (default when " + "neither name nor path is given)")); + + fc::variants snap_props; + snap_props.push_back(prop("integer", "Skip this many snapshots from the newest (default 0)")); + snap_props.push_back(prop("integer", "Return at most this many snapshots (default 20, max 200)")); + snap_props.push_back(prop("string", + "Name of the last row you already have; the page continues after it. Use this instead " + "of offset when paging through a directory that changes: offset repeats or skips rows " + "when a snapshot is written or pruned between pages. A pruned anchor is reported as " + "after_found=false and offset applies.")); + + tools.push_back(tool("admin.snapshot.status", + "A page of snapshots on disk (newest block first) with size and age, the " + "latest file, and whether a creation or restore is currently running. " + "offset/limit page through the list; snapshot_total and has_more say " + "how much is left. after= is the stable cursor.", + schema(snap_props, fc::variants{}))); + tools.push_back(tool("admin.snapshot.create", + "Create a snapshot of the current head block in the snapshot directory, " + "asynchronously. Needs the 'snapshot' scope.", + no_args)); + tools.push_back(tool("admin.snapshot.restore", + "Restore node state from a snapshot file, or from the latest one, " + "in-process: pauses P2P, wipes shared memory, imports the snapshot, " + "replays dlt_block_log and resumes P2P. Needs the 'snapshot' scope.", + schema(restore_props, fc::variants{}))); + + fc::variants ws_ips_props; + ws_ips_props.push_back(prop("integer", "Maximum rows to return (default 200, max 5000)")); + fc::variants ws_limits_props; + ws_limits_props.push_back(prop("boolean", "Enforce the limits (default: keep the current value)")); + ws_limits_props.push_back(prop("integer", "Max requests per IP per minute")); + ws_limits_props.push_back(prop("integer", "Max requests per IP per hour")); + ws_limits_props.push_back(prop("integer", "Max requests per IP per day")); + fc::variants ws_stats_props; + ws_stats_props.push_back(prop("boolean", "Enable or disable per-IP request accounting")); + fc::variants ws_stats_required; + ws_stats_required.push_back(fc::variant(std::string("enabled"))); + fc::variants ws_ip_props; + ws_ip_props.push_back(prop("string", "A single IPv4 or IPv6 address")); + fc::variants ws_ip_required; + ws_ip_required.push_back(fc::variant(std::string("ip"))); + + tools.push_back(tool("admin.webserver.status", + "Public webserver policy: stats switch, limits, request totals and " + "the whitelist/blacklist. Changes made by the other webserver tools " + "apply to the next request.", + no_args)); + tools.push_back(tool("admin.webserver.ips", + "Per-IP request counts (total, current minute/hour/day, blocked and " + "rate-limited), highest first.", + schema(ws_ips_props, fc::variants{}))); + tools.push_back(tool("admin.webserver.limits", + "Set the per-IP request limits and whether they are enforced. Omitted " + "fields keep their value. Needs the 'webserver' scope.", + schema(ws_limits_props, fc::variants{}))); + tools.push_back(tool("admin.webserver.stats", + "Turn per-IP request accounting on or off at runtime. Needs the " + "'webserver' scope.", + schema(ws_stats_props, ws_stats_required))); + tools.push_back(tool("admin.webserver.whitelist_add", + "Whitelist an IP: it bypasses the rate limits (still counted). " + "Needs the 'webserver' scope.", + schema(ws_ip_props, ws_ip_required))); + tools.push_back(tool("admin.webserver.whitelist_remove", + "Remove an IP from the whitelist. Needs the 'webserver' scope.", + schema(ws_ip_props, ws_ip_required))); + tools.push_back(tool("admin.webserver.blacklist_add", + "Blacklist an IP: every request from it is rejected with HTTP 403. " + "Needs the 'webserver' scope.", + schema(ws_ip_props, ws_ip_required))); + tools.push_back(tool("admin.webserver.blacklist_remove", + "Remove an IP from the blacklist. Needs the 'webserver' scope.", + schema(ws_ip_props, ws_ip_required))); + tools.push_back(tool("admin.webserver.reset", + "Zero the request counters; lists and limits are kept. Needs the " + "'webserver' scope.", + no_args)); + tools.push_back(tool("admin.webserver.reload", + "Re-read the webserver admin state file (limits and IP lists) from " + "disk without restarting. Needs the 'webserver' scope.", + no_args)); + + fc::mutable_variant_object set_prop; + set_prop("type", "object"); + set_prop("description", + "Allow-listed config keys mapped to new values (string, number or boolean). " + "Identity, keys, data paths and listener endpoints are never editable."); + set_prop("additionalProperties", true); + fc::variants cfg_plan_props; + cfg_plan_props.push_back(fc::variant(set_prop)); + fc::variants cfg_set_required; + cfg_set_required.push_back(fc::variant(std::string("set"))); + fc::variants plan_id_props; + plan_id_props.push_back(prop("string", "plan_id returned by the matching *.plan call")); + fc::variants plan_id_required; + plan_id_required.push_back(fc::variant(std::string("plan_id"))); + fc::variants list_props; + list_props.push_back(prop("array", "Plugin names to add (must be registered in vizd)")); + list_props.push_back(prop("array", "Plugin names to remove (chain and node_admin are protected)")); + fc::variants confirm_props; + confirm_props.push_back(prop("string", "Must be exactly 'restart'")); + fc::variants confirm_required; + confirm_required.push_back(fc::variant(std::string("confirm"))); + + tools.push_back(tool("admin.config.plan", + "Validate an allow-listed config.ini change and return the exact diff " + "plus a single-use plan_id. Nothing is written. Needs the 'config' scope.", + schema(cfg_plan_props, cfg_set_required))); + tools.push_back(tool("admin.config.apply", + "Write the change shown by admin.config.plan: re-checks the file hash, " + "backs the old file up and replaces config.ini atomically. Needs the " + "'config' scope.", + schema(plan_id_props, plan_id_required))); + tools.push_back(tool("admin.config.backups", + "Backups written next to config.ini by previous applies, newest first.", + no_args)); + tools.push_back(tool("admin.plugins.plan", + "Validate adding/removing `plugin =` lines and return a single-use " + "plan_id with the exact edit. Nothing is written until apply. Needs the " + "'plugins' scope.", + schema(list_props, fc::variants{}))); + tools.push_back(tool("admin.plugins.apply", + "Write the plugin edit shown by admin.plugins.plan, with the same " + "hash check, backup and atomic replace as config.apply. Needs the " + "'plugins' scope.", + schema(plan_id_props, plan_id_required))); + tools.push_back(tool("admin.plan.cancel", + "Drop a plan without applying it. Only the token that created it can " + "cancel it.", + schema(plan_id_props, plan_id_required))); + tools.push_back(tool("admin.restart", + "Ask appbase to quit so the supervisor (docker --restart / systemd " + "Restart=always) starts the node again. Needs the 'process' scope.", + schema(confirm_props, confirm_required))); + + fc::variants validator_props; + validator_props.push_back(prop("array", + "Actions: [{name, action: 'enable'|'disable'|'set_key', signing_key?}]. One entry " + "per validator; duplicates are refused.")); + fc::variants validator_required; + validator_required.push_back(fc::variant(std::string("actions"))); + + tools.push_back(tool("admin.validators.active", + "Names in the current validator schedule, the number of scheduled " + "slots and whether the chain is in emergency consensus. No keys.", + no_args)); + tools.push_back(tool("admin.validator.list", + "Validators monitored by validator_guard: on-chain signing key, " + "whether the operator hold is set, the guard's active authority and " + "the next scheduled slot.", + no_args)); + tools.push_back(tool("admin.validator.plan", + "Validate enable/disable/set_key for several validators and return " + "the exact diff plus a single-use plan_id. Nothing is signed yet. " + "Needs the 'chain' scope and a live validator-guard with an active key.", + schema(validator_props, validator_required))); + tools.push_back(tool("admin.validator.apply", + "Sign and broadcast the planned validator_update transactions " + "through validator_guard, refusing any validator whose on-chain key " + "changed since the plan. Needs the 'chain' scope.", + schema(plan_id_props, plan_id_required))); + return fc::variant(tools); +} + +fc::variant node_admin_plugin::impl::h_mcp_initialize(const fc::variant ¶ms) { + std::string requested; + if (params.is_object()) { + requested = param_str(params.get_object(), "protocolVersion", ""); + } + // Echo only versions we actually implement; otherwise answer with our own, as the spec + // requires ("the server MUST respond with a protocol version it supports"). + const std::set supported{"2024-11-05", "2025-03-26", "2025-06-18"}; + const std::string proto = + supported.count(requested) ? requested : std::string("2025-06-18"); + + fc::mutable_variant_object tools_cap; + tools_cap("listChanged", false); + fc::mutable_variant_object caps; + caps("tools", tools_cap); + + fc::mutable_variant_object server; + server("name", "viz-node-admin"); + server("version", "0.1.0"); + + fc::mutable_variant_object m; + m("protocolVersion", proto); + m("capabilities", caps); + m("serverInfo", server); + m("instructions", + "Administration of this VIZ node. Read-only tools need the 'read' scope; the mutating " + "peer tools admin.p2p.add_node, admin.p2p.add_trusted and admin.p2p.clear_banned " + "additionally need the 'p2p' scope; admin.snapshot.create and admin.snapshot.restore " + "additionally need the 'snapshot' scope; the webserver policy tools " + "(admin.webserver.limits / stats / whitelist_* / blacklist_* / reset / reload) " + "additionally need the 'webserver' scope; admin.config.plan/apply need 'config', " + "admin.plugins.plan/apply need 'plugins', and admin.restart needs 'process'. Validator " + "operations (admin.validator.plan/apply) need 'chain' and are accepted only while " + "validator_guard is live with an active key; disable sets a persisted operator hold, so " + "the guard's auto-restore does not undo it (enable clears the hold). Config and " + "plugin edits are two-phase: plan shows the exact diff and returns a single-use, " + "token-bound plan_id; apply re-checks that config.ini did not change, writes it " + "atomically and keeps a .bak copy. Both need a restart to take effect. Webserver changes " + "apply to the next request and persist in the plugin's admin state file. A human-facing " + "UI is served at /ui/ on this same listener and uses the same bearer token."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_mcp_ping(const fc::variant &) { + return fc::variant(fc::mutable_variant_object()); +} + +fc::variant node_admin_plugin::impl::h_mcp_tools_list(const fc::variant &) { + fc::mutable_variant_object m; + m("tools", mcp_tools()); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_mcp_tools_call(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object {name, arguments}", ""}; + } + const fc::variant_object &o = params.get_object(); + const std::string name = param_str(o, "name", ""); + if (name.empty()) { + throw rpc_error{-32602, "invalid params", "missing tool name", "see tools/list"}; + } + const fc::variant tools = mcp_tools(); + bool known = false; + for (const fc::variant &t : tools.get_array()) { + if (t.get_object()["name"].as_string() == name) { + known = true; + break; + } + } + if (!known) { + throw rpc_error{-32602, "unknown tool", name, "see tools/list"}; + } + + fc::variant args; + auto a_it = o.find("arguments"); + if (a_it != o.end() && !a_it->value().is_null()) { + if (!a_it->value().is_object()) { + throw rpc_error{-32602, "invalid params", "'arguments' must be an object", ""}; + } + args = a_it->value(); + } + + auto content = [](const std::string &text, bool is_error) { + fc::mutable_variant_object item; + item("type", "text"); + item("text", text); + fc::mutable_variant_object m; + m("content", fc::variants{fc::variant(item)}); + m("isError", is_error); + return fc::variant(m); + }; + + try { + return content(to_json(dispatch(name, args, token)), false); + } catch (const rpc_error &e) { + // Tool execution errors are reported inside the result (isError), not as a + // protocol-level JSON-RPC error: the model must see what the tool said. + std::string text = "error " + std::to_string(e.code) + ": " + e.message; + if (!e.reason.empty()) text += ": " + e.reason; + if (!e.hint.empty()) text += " (" + e.hint + ")"; + return content(text, true); + } catch (const fc::exception &e) { + return content("internal error: " + e.to_string(), true); + } catch (const std::exception &e) { + return content(std::string("internal error: ") + e.what(), true); + } +} + + +// ── audit ──────────────────────────────────────────────────────────────────────────────── +void node_admin_plugin::impl::audit(const std::string &rid, const std::string &label, + const std::string &method, const fc::variant ¶ms, + const std::string &outcome, int code, int64_t duration_ms, + const std::string &remote) { + try { + fc::mutable_variant_object m; + m("ts", now_iso()); + m("rid", rid); + m("token", label.empty() ? "-" : label); + m("method", method); + if (!params.is_null()) m("params", params); + m("outcome", outcome); + m("code", static_cast(code)); + m("ms", duration_ms); + m("remote", remote.empty() ? "-" : remote); + audit_line(mask_text(to_json(fc::variant(m)))); + } catch (...) { + // Auditing must never break the request path. + } +} + +void node_admin_plugin::impl::audit_line(const std::string &line) { + std::lock_guard lock(audit_mutex); +#ifndef _WIN32 + // rotate at a fixed size instead of growing without bound + struct stat st; + if (::stat(audit_path.c_str(), &st) == 0 && st.st_size > static_cast(kAuditRotateBytes)) { + ::rename(audit_path.c_str(), (audit_path + ".1").c_str()); + } + const int fd = ::open(audit_path.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0600); + if (fd < 0) return; + const std::string data = line + "\n"; + ssize_t ignored = ::write(fd, data.data(), data.size()); + (void) ignored; + ::close(fd); +#else + std::ofstream out(audit_path.c_str(), std::ios::app); + if (out) out << line << "\n"; +#endif +} + +} // namespace node_admin +} // namespace plugins +} // namespace graphene diff --git a/plugins/node_admin/tests/acceptance.sh b/plugins/node_admin/tests/acceptance.sh new file mode 100755 index 0000000000..901d981055 --- /dev/null +++ b/plugins/node_admin/tests/acceptance.sh @@ -0,0 +1,1084 @@ +#!/usr/bin/env bash +# Live acceptance for the node_admin plugin: starts a throwaway node (fresh data dir, no peers), +# drives the admin HTTP listener with curl and checks the contract from docs/plugins/node-admin.md. +# +# Not wired into CTest: it needs a built vizd and a free TCP port. Run it after changing the +# plugin: +# ./plugins/node_admin/tests/acceptance.sh [path/to/vizd] +# +# The suite writes fixture files into the node's data dir (snapshots, config.ini edits, repair +# markers), so the node must run as the SAME user as the script. With Docker, pass --user: +# docker run --rm --network host --user "$(id -u):$(id -g)" -v "$WORK:$WORK" \ +# --entrypoint /usr/local/bin/vizd vizblockchain/vizd:pm --data-dir "$WORK" +# Otherwise node-created files are root-owned and those fixtures fail with permission denied; +# the guard below stops the run instead of reporting the resulting noise as product failures. +# +# Exit code: 0 = every check passed, 1 = at least one failed, 2 = the node never came up or the +# data dir is not writable by this user (that is "not measured", not a regression). +set -u + +VIZD=${1:-${VIZD:-build/programs/vizd/vizd}} +PORT=${PORT:-18099} +P2P_PORT=${P2P_PORT:-$((PORT + 1))} +WEB_PORT=${WEB_PORT:-$((PORT + 2))} +BASE="http://127.0.0.1:$PORT" +WEB="http://127.0.0.1:$WEB_PORT" +WORK=$(mktemp -d /tmp/vizd-node-admin.XXXXXX) + +GOOD=accept-good-token +OPS=accept-ops-token +PEER=accept-peer-token +SNAP=accept-snap-token +WS=accept-webserver-token +ADM=accept-admin-token +ADM2=accept-admin2-token +CHN=accept-chain-token +EXPIRED=accept-expired-token +sha() { printf %s "$1" | sha256sum | cut -d' ' -f1; } + +cat > "$WORK/config.ini" < "$WORK/node.log" +printf 'do not read me\n' > "$WORK/secret.log" + +"$VIZD" --data-dir "$WORK" > "$WORK/console.log" 2>&1 & +NODE_PID=$! +cleanup() { kill "$NODE_PID" 2>/dev/null; wait "$NODE_PID" 2>/dev/null; } +trap cleanup EXIT + +for _ in $(seq 1 120); do + grep -q "node_admin: listening" "$WORK/console.log" && break + kill -0 "$NODE_PID" 2>/dev/null || { echo "vizd exited during startup"; tail -20 "$WORK/console.log"; exit 2; } + sleep 0.25 +done +if ! grep -q "node_admin: listening" "$WORK/console.log"; then + echo "node_admin never listened on $PORT" + tail -20 "$WORK/console.log" + exit 2 +fi + +# The node must run as this user: fixtures are written straight into its data dir below. A +# root-owned run (a container without --user) makes every such write fail with permission +# denied, and the resulting cascade would be reported as product failures. +if [ ! -w "$WORK/state" ]; then + echo "FATAL: $WORK/state is not writable by $(id -un); the node runs as another user" + echo " Run the node as this user (docker: --user $(id -u):$(id -g)) or use a local binary." + exit 2 +fi + +FAIL=0 +OUT="$WORK/out.json" +CODE=0 + +# call — sets CODE (http status) and OUT (body) +call() { + CODE=$(curl -s -o "$OUT" -w '%{http_code}' -X POST "$BASE" \ + -H 'Content-Type: application/json' "$@") +} +auth() { call -H "Authorization: Bearer $1" -d "$2"; } +# jq-free field extraction: python is already a dependency of the build docs +field() { python3 -c 'import json,sys +try: v=json.load(open(sys.argv[1])) +except Exception: print(""); raise SystemExit +for k in sys.argv[2].split("."): + if k.isdigit(): v=v[int(k)] + else: v=v[k] +print(json.dumps(v))' "$OUT" "$1"; } +# raw: same walk but prints the value unquoted (plan ids, paths) +raw() { python3 -c 'import json,sys +try: v=json.load(open(sys.argv[1])) +except Exception: print(""); raise SystemExit +for k in sys.argv[2].split("."): + if k.isdigit(): v=v[int(k)] + else: v=v[k] +print(v)' "$OUT" "$1"; } + +check() { # check