From a06b76b800fe3d5f85ab4622bdb4e53c35647febbdac79ea873e4f205268d482 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 2 Jun 2026 15:52:22 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D1=91?= =?UTF-8?q?=D0=BD=20server=20UI=20=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B2=20base58?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + CLAUDE.md | 2 + Dev_Docs/Blockchain/sync-between-servers.md | 100 +++ Dev_Docs/Future_Features/README.md | 1 + ...2026-06-02_сессионные_саб_серверы_в_pda.md | 103 +++ SHiNE-server/AGENTS.md | 69 ++ VERSION.properties | 4 +- .../js/services/solana-register-service.js | 7 +- shine-server-UI/AGENTS.md | 82 ++ shine-server-UI/create-server-pda.html | 468 ++++++++++++ shine-server-UI/index.html | 58 ++ shine-server-UI/js/server-pda-core.js | 710 ++++++++++++++++++ shine-server-UI/styles.css | 193 +++++ shine-server-UI/update-server-pda.html | 484 ++++++++++++ 14 files changed, 2277 insertions(+), 5 deletions(-) create mode 100644 Dev_Docs/Blockchain/sync-between-servers.md create mode 100644 Dev_Docs/Future_Features/medium/2026-06-02_сессионные_саб_серверы_в_pda.md create mode 100644 SHiNE-server/AGENTS.md create mode 100644 shine-server-UI/AGENTS.md create mode 100644 shine-server-UI/create-server-pda.html create mode 100644 shine-server-UI/index.html create mode 100644 shine-server-UI/js/server-pda-core.js create mode 100644 shine-server-UI/styles.css create mode 100644 shine-server-UI/update-server-pda.html diff --git a/AGENTS.md b/AGENTS.md index d5eb785..377fd50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ ## Структура проекта (кратко) - Серверный код SHiNE находится в папке `SHiNE-server/`. - Код клиентского UI SHiNE находится в папке `shine-UI/`. +- Веб-панель администратора сервера (управление Solana PDA сервера) — папка `shine-server-UI/`. - Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения. - Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя. diff --git a/CLAUDE.md b/CLAUDE.md index 59206b9..1cf3fd3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,3 +7,5 @@ ## Справка по подпроектам - При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.md`. - При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`. +- При работе внутри `shine-server-UI/` — читать `shine-server-UI/AGENTS.md`. +- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`. diff --git a/Dev_Docs/Blockchain/sync-between-servers.md b/Dev_Docs/Blockchain/sync-between-servers.md new file mode 100644 index 0000000..427f954 --- /dev/null +++ b/Dev_Docs/Blockchain/sync-between-servers.md @@ -0,0 +1,100 @@ +# Синхронизация блоков и DM между серверами SHiNE + +Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE. + +## 1. Зачем нужна синхронизация + +Пользователи SHiNE могут быть «приписаны» к разным серверам. +Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y): + +1. Сервер X принимает сообщение; +2. Сервер X должен переслать DM-блок серверу Y; +3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B. + +Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться, +чтобы любой партнёрский сервер мог отдать полную историю пользователя. + +## 2. Список серверов синхронизации (`sync_servers`) + +Каждый сервер регистрирует в своей Solana PDA список `sync_servers` — +логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется. + +- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера. +- Адрес каждого партнёрского сервера читается из его PDA на Solana. +- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`. + +## 3. Что синхронизируется + +### 3.1 Личные сообщения (DM) + +- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt). +- Сервер-отправитель: при получении пары блоков от клиента перенаправляет их серверу получателя. +- Сервер-получатель: сохраняет блоки в `signed_messages_v2`, доставляет в активные сессии. +- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`. + +### 3.2 Блоки пользовательского блокчейна + +- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него. +- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`. +- Порядок блоков сохраняется (по глобальному номеру блока и хэшу). +- Дедупликация по глобальному номеру блока и хэшу. + +## 4. Протокол синхронизации (целевой, не реализован) + +### 4.1 Межсерверное соединение + +- Серверы устанавливают постоянное WebSocket-соединение друг с другом. +- Адрес партнёра определяется по `server_address` из его Solana PDA. +- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA). +- При разрыве — переподключение с экспоненциальным backoff. + +### 4.2 Доставка новых данных (push) + +- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам. +- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff. + +### 4.3 Начальная синхронизация (backfill) + +- При первом подключении к партнёру серверы обмениваются «курсорами» состояния: + последний глобальный номер блока, последний известный DM-ключ. +- Сервер с более полной историей досылает недостающее партнёру. + +### 4.4 Разрешение конфликтов + +- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока. + Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`). +- DM: конфликтов нет, `message_key` уникален. + +## 5. Маршрутизация DM между серверами + +При отправке DM от пользователя A к пользователю B: + +1. Клиент A отправляет пару блоков на свой сервер X. +2. Сервер X определяет, на каком сервере зарегистрирован пользователь B. + - Сначала проверяет локально (если B зарегистрирован на X). + - Иначе читает PDA пользователя B из Solana и смотрит `access_servers`. + - Выбирает первый доступный сервер из `access_servers` и перенаправляет туда DM. +3. Сервер Y (из `access_servers` B) сохраняет и доставляет блоки. + +Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения). + +## 6. Безопасность + +- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое. +- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа). +- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере. + +## 7. Статус реализации + +| Компонент | Статус | +|-----------|--------| +| Регистрация серверной PDA в Solana | ✅ Реализовано | +| Чтение `sync_servers` из PDA | Нужна реализация | +| Межсерверный WebSocket-канал | Нужна реализация | +| Push новых DM партнёрам | Нужна реализация | +| Push блоков блокчейна партнёрам | Нужна реализация | +| Backfill при первом подключении | Нужна реализация | +| Маршрутизация DM через access_servers | Нужна реализация (заглушка) | + +Текущая версия сервера работает без межсерверной синхронизации. +Синхронизация — задача следующего этапа разработки. diff --git a/Dev_Docs/Future_Features/README.md b/Dev_Docs/Future_Features/README.md index bbc5545..09da4a9 100644 --- a/Dev_Docs/Future_Features/README.md +++ b/Dev_Docs/Future_Features/README.md @@ -36,6 +36,7 @@ - `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах. - `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн. - `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений. +- `medium/2026-06-02_сессионные_саб_серверы_в_pda.md` - несколько саб-серверов пользователя как типизированные сессии в PDA с версией записи. ### Дальнее будущее diff --git a/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_саб_серверы_в_pda.md b/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_саб_серверы_в_pda.md new file mode 100644 index 0000000..0f480b0 --- /dev/null +++ b/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_саб_серверы_в_pda.md @@ -0,0 +1,103 @@ +# Сессионные саб-серверы в PDA пользователя + +- Статус: + `future` + +- Горизонт: + `medium` + +- Ориентир: + после завершения первого этапа по пользовательским сессиям + +- Основание: + Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних саб-серверов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли. + +## Зачем нужна фича + +У одного пользователя может быть несколько доверенных внутренних саб-серверов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели. + +Это нужно, чтобы: + +- хранить несколько саб-серверов у одного пользователя одновременно; +- различать обычные клиентские сессии и серверные сессии по явному типу; +- дать расширяемый формат записи с версией; +- использовать единый подход для DM, звонков и внутренних команд между сессиями. + +## Целевая идея + +В пользовательском PDA должен появиться список записей сессий, где каждая запись содержит как минимум: + +- `sessionType` (`u8`); +- `sessionVersion` (`u8`); +- `sessionName`; +- `sessionPubKey`. + +Предварительные значения: + +- тип `1` - обычная пользовательская сессия; +- тип `10` - саб-сервер пользователя; +- версия `1` - первая рабочая версия формата записи сессии. + +Важно: саб-серверов у одного пользователя может быть несколько. + +## Архитектурный принцип + +Внутренний протокол взаимодействия должен оставаться транспортным. + +То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки саб-сервера, а должен: + +- доставлять сообщения между сессиями; +- доставлять сигналы звонков между сессиями; +- хранить и маршрутизировать адресацию; +- не принимать на себя бизнес-логику содержимого внутренних команд. + +## Что уже подтверждается текущим кодом + +- Личные сообщения уже доставляются по всем сессиям целевого пользователя с отдельным учётом доставки на каждую сессию. +- Подтверждение доставки DM уже идёт отдельно по каждой сессии. +- Вызов звонка уже рассылается по нескольким активным сессиям пользователя. +- Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя. + +Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол саб-сервера". + +## Что нужно сделать при возврате к задаче + +1. Согласовать финальный бинарный формат записи сессии в PDA пользователя. +2. Проверить, не меняет ли это уже опубликованный формат пользовательской PDA-записи. +3. Если формат PDA меняется, заранее предупредить пользователя и получить отдельное подтверждение. +4. Решить, где именно хранится массив сессий: + - в основной записи пользователя; + - в отдельной PDA-структуре расширения; + - или в смешанной схеме с базовой записью и внешними индексами. +5. Зафиксировать ограничения: + - максимальное число сессий; + - максимальную длину `sessionName`; + - правила удаления и обновления записи; + - правила ротации `sessionPubKey`. +6. Продумать, как UI и сервер будут отличать тип `1` и тип `10`. +7. Определить, какие внутренние сообщения саб-сервера останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации. +8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов. +9. После реализации обязательно обновить документацию. + +## Что нужно обновить при реализации + +- `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md` +- `Dev_Docs/Solana_Architecture/README.md` +- `Dev_Docs/Инициализация_Solana_регистрации/README.md` +- `Dev_Docs/Keys/README.md` +- `Dev_Docs/Personal_Messages/README.md`, если изменится адресация DM по типам сессий +- `Dev_Docs/API/`, если появятся новые серверные операции или изменятся ответы + +## Что пока не делать + +- Не включать это автоматически в основной deploy сервера. +- Не менять сейчас Solana PDA-формат без отдельного подтверждения. +- Не добавлять временные поля в публичный API "на всякий случай". + +## С какого места продолжать + +Продолжать после завершения первой части: + +1. описать минимальный формат записи пользовательской сессии; +2. отдельно решить, живут ли саб-серверы в том же списке, что и обычные сессии; +3. затем уже проектировать операции регистрации, обновления и отключения таких сессий. diff --git a/SHiNE-server/AGENTS.md b/SHiNE-server/AGENTS.md new file mode 100644 index 0000000..793f54d --- /dev/null +++ b/SHiNE-server/AGENTS.md @@ -0,0 +1,69 @@ +# AGENTS.md — SHiNE-server + +## Назначение + +SHiNE-server — серверная часть мессенджера SHiNE: WebSocket-сервер, хранение блоков блокчейна +пользователей, доставка личных сообщений (DM), звонки. + +## Структура папок + +- `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера +- `shine-server-net-protocol/` — обработчики операций (RPC и события WS) +- `shine-server-db/` — DAO, SQL-схема, SQLite +- `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна +- `shine-server-crypto/` — криптографические утилиты +- `shine-server-config/` — конфигурация сервера +- `shine-server-log/` — логирование +- `shine-server-geo/` — геолокация IP + +## Настройка сервера в Solana (Solana PDA) + +Серверный аккаунт SHiNE регистрируется в Solana в виде `user_pda` с флагом `is_server=true`. +В PDA хранятся: + +- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`); +- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров, + с которыми синхронизируются блоки и DM; +- **корневой ключ** сервера (`root_key`). + +Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться. + +**Управление серверной PDA выполняется через Web-панель администратора:** + +``` +shine-server-UI/index.html +``` + +Страницы: +- `create-server-pda.html` — первичная регистрация серверного аккаунта; +- `update-server-pda.html` — обновление адреса или списка sync_servers. + +Для регистрации нужен полный keyBundle (root + device + blockchain). +Для обновления — только root + device (blockchain-ключ не нужен). + +Актуальные адреса программ Solana (devnet): +- `shine_users`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` +- `shine_payments`: `m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR` + +Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md` + +## Синхронизация с партнёрскими серверами + +Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`. +Детали: `Dev_Docs/Blockchain/sync-between-servers.md` + +## Деплой + +``` +./gradlew deployServer +``` + +Хост по умолчанию: `player@93.170.12.154` (shineup.me). + +Логи на проде: +- `/home/player/SHiNE/shine-server/logs/app.log` +- `/home/player/SHiNE/shine-server/logs/call-delivery-events.log` + +## Язык + +Комментарии в коде, документация и commit-сообщения — на русском языке. diff --git a/VERSION.properties b/VERSION.properties index a45515f..ab54975 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.110 -server.version=1.2.102 +client.version=1.2.111 +server.version=1.2.103 diff --git a/shine-UI/js/services/solana-register-service.js b/shine-UI/js/services/solana-register-service.js index 32f3de2..93913fd 100644 --- a/shine-UI/js/services/solana-register-service.js +++ b/shine-UI/js/services/solana-register-service.js @@ -175,9 +175,10 @@ function serializeCreateUserPdaArgs( b.vecU8(new Uint8Array(32)); // last_block_hash b.vecU8(lastBlockSig64); // last_block_signature b.str(''); // arweave_tx_id - b.bool(false); // is_server - b.bytes32(new Uint8Array(32)); // server_key (default) - b.str(''); // server_address + b.bool(false); // is_server + b.u8(0); // address_format_type + b.u8(0); // address_format_version + b.str(''); // server_address b.vecStr([]); // sync_servers b.vecStr(['shineup.me']); // access_servers b.u8(0); // trusted_count diff --git a/shine-server-UI/AGENTS.md b/shine-server-UI/AGENTS.md new file mode 100644 index 0000000..0aa2c74 --- /dev/null +++ b/shine-server-UI/AGENTS.md @@ -0,0 +1,82 @@ +# AGENTS.md — shine-server-UI + +## Назначение + +`shine-server-UI/` — автономная веб-панель администратора для управления серверным аккаунтом SHiNE +в Solana (регистрация и обновление `user_pda` с флагом `is_server=true`). + +Это не часть основного клиентского SPA (`shine-UI/`). Страницы — самостоятельные HTML-файлы, +открываемые напрямую в браузере. Никакого бэкенда нет. + +## Структура файлов + +``` +shine-server-UI/ + index.html — главная страница с навигацией + create-server-pda.html — регистрация нового серверного аккаунта + update-server-pda.html — обновление адреса/sync_servers существующей PDA + styles.css — тёмная тема + js/ + server-pda-core.js — вся логика: парсинг PDA, Borsh, криптография, Solana +``` + +## Как пользоваться + +### Регистрация сервера (`create-server-pda.html`) + +Открыть страницу в браузере (требуется HTTPS для WebCrypto — локально либо через сервер). + +Ввести: +- **Логин сервера** — уникальный логин в Solana (только a-z, 0-9, _ ; без точки ; макс. 20 символов). +- **Адрес сервера** — полный WebSocket/HTTP URL, например `https://shineup.me/ws`. +- **sync_servers** — логины SHiNE-аккаунтов серверов-партнёров (по одному на строку). + +**Способ ввода ключей (переключатель):** + +- **«Из пароля»** — ввести пароль. Ключи автоматически выводятся из логина + пароля + по той же схеме, что SHiNE-клиент (Argon2id + Ed25519). Занимает 2–5 сек. + На страницах сервера публичные и приватные ключи показываются в base58, приватный ключ + хранится как 32-байтовый seed в base58. +- **«JSON ключей»** — вставить keyBundle JSON с тремя парами (rootPair, devicePair, blockchainPair). + +На **device-ключе** должно быть достаточно SOL для оплаты транзакции регистрации. + +### Обновление настроек сервера (`update-server-pda.html`) + +1. Ввести логин и нажать **«Загрузить PDA»** — страница прочитает существующую PDA из Solana и + покажет текущие данные. +2. Изменить адрес сервера или список sync_servers. +3. Выбрать способ ввода ключей: + - **«Из пароля»** — ввести пароль (логин берётся из поля выше); + - **«JSON ключей»** — вставить keyBundle (достаточно rootPair + devicePair). + Blockchain-ключ для обновления не нужен — существующая подпись из PDA переиспользуется. + При ручном вводе допустим base58 seed; если blockchain seed не указан, обновление + использует уже сохранённую подпись последнего блока. +4. Нажать **«Обновить PDA»**. + +## Ключевой файл логики + +`js/server-pda-core.js` — автономный ES-модуль (без зависимостей на shine-UI). + +Экспортирует: +- `readServerPdaData({ login, solanaEndpoint })` — читает и парсит PDA из Solana; +- `registerServerOnSolana({ login, keyBundle, serverAddress, syncServers, ... })`; +- `updateServerOnSolana({ login, keyBundle, serverAddress, syncServers, ... })`; +- `parsePdaData(rawBytes)` — парсит бинарный формат PDA (matches Rust `deserialize_record_from_pda`). + +## Связанные документы + +- Формат PDA: `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md` +- Деплой Solana-программ: `Dev_Docs/Инициализация_Solana_регистрации/README.md` +- Синхронизация между серверами: `Dev_Docs/Blockchain/sync-between-servers.md` +- Настройки сервера: `SHiNE-server/AGENTS.md` + +## Правила при доработке + +- Формат Borsh-аргументов в `server-pda-core.js` должен строго соответствовать + `UserMutableFields` в `shine-solana/shine/programs/shine_users/src/users.rs`. +- Бинарный формат PDA в `buildUnsignedRecordBytesServer` должен совпадать с + `serialize_unsigned_record` в Rust. +- При любом изменении формата Solana-программы (`users.rs`) — обновлять `server-pda-core.js` + и документ формата PDA в том же коммите. +- Язык кода и комментариев: русский. diff --git a/shine-server-UI/create-server-pda.html b/shine-server-UI/create-server-pda.html new file mode 100644 index 0000000..4a8a16a --- /dev/null +++ b/shine-server-UI/create-server-pda.html @@ -0,0 +1,468 @@ + + + + + + Регистрация сервера — SHiNE Server Admin + + + + +
+ + +

Регистрация серверного аккаунта

+

Создаёт user_pda в Solana с флагом is_server=true

+ +
+

Параметры Solana

+
+ + +
devnet: https://api.devnet.solana.com · mainnet: https://api.mainnet-beta.solana.com
+
+
+ +
+

Данные сервера

+
+ + +
Только a-z, 0-9, _ · без точки · макс. 20 символов
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Ключи сервера

+ +
+ +
+ + +
+
Нажмите «Сгенерировать» — поля ниже заполнятся из логина + пароля (Argon2id).
Или введите ключи вручную.
+
+ +
+ +
+
+ +
Секрет (master secret, base58)
+
+ +
+ +
Ключевые пары (base58)
+ +
+
Root Key — подпись PDA-записи
+
Публичный
+
Приватный
+
+
+
Blockchain Key — подпись LastBlockState
+
Публичный
+
Приватный
+
+
+
Device Key — оплата транзакции Solana
+
Публичный
+
Приватный
+
+
Положите SOL на этот адрес перед регистрацией:
+
+
Это Solana-адрес device-ключа (public key в base58 = Solana-адрес). С него оплачивается создание PDA.
+
+
+
+ +
+ +
+
+
+ + + + diff --git a/shine-server-UI/index.html b/shine-server-UI/index.html new file mode 100644 index 0000000..955b62a --- /dev/null +++ b/shine-server-UI/index.html @@ -0,0 +1,58 @@ + + + + + + SHiNE Server Admin + + + +
+

SHiNE Server Admin

+

Панель управления Solana PDA для серверного аккаунта SHiNE

+ + + +
+

Как это работает

+

+ Каждый SHiNE-сервер регистрирует свой аккаунт в Solana в виде user_pda + с флагом is_server=true.

+ В PDA хранятся:
+  • адрес сервера (например, https://shineup.me/ws);
+  • список серверов-партнёров для синхронизации блокчейна и DM;
+  • криптографический корневой ключ сервера.

+ Клиенты читают PDA прямо из Solana при попытке дозвониться до пользователя или + установить WebSocket-соединение через сервер. +

+
+ +
+

Что потребуется

+

+ Для создания: полный keyBundle сервера (rootPair + devicePair + blockchainPair), + логин сервера (без точки, не более 20 символов), URL-адрес сервера, Solana-эндпоинт, + достаточный баланс SOL на device-ключе для комиссии.

+ Для обновления: только rootPair + devicePair (blockchain-ключ не нужен). +

+
+
+ + diff --git a/shine-server-UI/js/server-pda-core.js b/shine-server-UI/js/server-pda-core.js new file mode 100644 index 0000000..480b6d6 --- /dev/null +++ b/shine-server-UI/js/server-pda-core.js @@ -0,0 +1,710 @@ +// Логика управления серверной PDA в Solana (shine_users) +// Автономный модуль для панели администратора сервера SHiNE + +const SHINE_USERS_PROGRAM_ID = 'FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm'; +const SHINE_PAYMENTS_PROGRAM_ID = 'm48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR'; +const SHINE_LOGIN_GUARD_PROGRAM_ID = '3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo'; +const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111'; +const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111'; + +// Discriminator create_user_pda (sha256("global:create_user_pda")[0..8]) +const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]); + +let _solanaLib = null; +async function loadSolanaLib() { + if (!_solanaLib) _solanaLib = await import('https://esm.sh/@solana/web3.js@1.98.4?bundle'); + return _solanaLib; +} + +let _argon2Lib = null; +async function loadArgon2() { + if (!_argon2Lib) _argon2Lib = await import('https://esm.sh/@noble/hashes@1.8.0/argon2.js'); + return _argon2Lib; +} + +// ------------------------------------------------------------------- +// Crypto (WebCrypto, Ed25519) +// ------------------------------------------------------------------- + +async function sha256Bytes(bytes) { + const buf = await crypto.subtle.digest('SHA-256', bytes); + return new Uint8Array(buf); +} + +async function signEd25519(pkcs8B64, messageBytes) { + const pkcs8 = Uint8Array.from(atob(pkcs8B64), c => c.charCodeAt(0)); + const key = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']); + const sig = await crypto.subtle.sign({ name: 'Ed25519' }, key, messageBytes); + return new Uint8Array(sig); +} + +function base64ToBytes(b64) { + return Uint8Array.from(atob(b64), c => c.charCodeAt(0)); +} + +function extractSeed32FromPkcs8B64(pkcs8B64) { + // Ed25519 PKCS8 (48 байт): seed расположен начиная с байта 16 + return base64ToBytes(pkcs8B64).slice(16, 48); +} + +async function anchorDiscriminator(name) { + const hash = await sha256Bytes(new TextEncoder().encode(`global:${name}`)); + return hash.slice(0, 8); +} + +// ------------------------------------------------------------------- +// Borsh-кодирование (Anchor-совместимое) +// ------------------------------------------------------------------- + +function pushU32LE(buf, v) { + const n = v >>> 0; + buf.push(n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF, (n >> 24) & 0xFF); +} + +function pushU64LE(buf, bigV) { + const b = typeof bigV === 'bigint' ? bigV : BigInt(bigV); + const lo = Number(b & 0xFFFFFFFFn) >>> 0; + const hi = Number((b >> 32n) & 0xFFFFFFFFn) >>> 0; + pushU32LE(buf, lo); + pushU32LE(buf, hi); +} + +class BorshBuf { + constructor() { this._b = []; } + u8(v) { this._b.push(v & 0xFF); } + u32(v) { pushU32LE(this._b, v); } + u64(v) { pushU64LE(this._b, v); } + bool(v) { this.u8(v ? 1 : 0); } + bytes32(b) { for (const x of b) this._b.push(x); } + vecU8(b) { this.u32(b.length); for (const x of b) this._b.push(x); } + str(s) { + const enc = new TextEncoder().encode(s); + this.u32(enc.length); + for (const x of enc) this._b.push(x); + } + vecStr(arr) { + this.u32(arr.length); + for (const s of arr) this.str(s); + } + raw(bytes) { for (const x of bytes) this._b.push(x); } + result() { return new Uint8Array(this._b); } +} + +// ------------------------------------------------------------------- +// Построение бинарного формата PDA (matches Rust serialize_unsigned_record) +// ------------------------------------------------------------------- + +function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber, lastBlockHash32, usedBytes) { + const enc = new TextEncoder(); + const buf = []; + for (const x of enc.encode('SHiNE_LAST_BLOCK')) buf.push(x); + const loginB = enc.encode(login); + buf.push(loginB.length); for (const x of loginB) buf.push(x); + const bchB = enc.encode(blockchainName); + buf.push(bchB.length); for (const x of bchB) buf.push(x); + pushU32LE(buf, lastBlockNumber); + for (const x of lastBlockHash32) buf.push(x); + pushU64LE(buf, usedBytes); + return new Uint8Array(buf); +} + +function buildUnsignedRecordBytesServer({ + login, createdAtMs, updatedAtMs, recordNumber, prevHash32, + rootKey32, deviceKey32, blockchainKey32, blockchainName, + paidLimitBytes, usedBytes, lastBlockNumber, lastBlockHash32, lastBlockSig64, arweaveTxId, + serverAddress, addressFormatType, addressFormatVersion, syncServers, accessServers, trustedCount, +}) { + const enc = new TextEncoder(); + const loginB = enc.encode(login); + const bchB = enc.encode(blockchainName); + const buf = []; + + // Заголовок: MAGIC(5) + FORMAT_MAJOR(1) + FORMAT_MINOR(1) + record_len_placeholder(2) + buf.push(0x53, 0x48, 0x69, 0x4E, 0x45, 1, 0, 0, 0); // байты 0..8 + + pushU64LE(buf, createdAtMs); + pushU64LE(buf, updatedAtMs); + pushU32LE(buf, recordNumber); + for (const x of prevHash32) buf.push(x); + + buf.push(loginB.length); + for (const x of loginB) buf.push(x); + + buf.push(6); // blocks_count = 6 (сервер) + + // RootKeyBlock (type=1, ver=0) + buf.push(1, 0); + for (const x of rootKey32) buf.push(x); + + // DeviceKeyBlock (type=2, ver=0) + buf.push(2, 0); + for (const x of deviceKey32) buf.push(x); + + // BlockchainRegistryBlock (type=3, ver=0, count=1, blockchain_type=1) + buf.push(3, 0, 1, 1); + buf.push(bchB.length); for (const x of bchB) buf.push(x); + for (const x of blockchainKey32) buf.push(x); + pushU64LE(buf, paidLimitBytes); + pushU64LE(buf, usedBytes); + pushU32LE(buf, lastBlockNumber); + for (const x of lastBlockHash32) buf.push(x); + for (const x of lastBlockSig64) buf.push(x); + if (arweaveTxId) { + buf.push(1); + const aTxB = enc.encode(arweaveTxId); + buf.push(aTxB.length); for (const x of aTxB) buf.push(x); + } else { + buf.push(0); + } + + // ServerProfileBlock (type=30, ver=0) + buf.push(30, 0); + buf.push(1); // is_server = 1 + buf.push(addressFormatType & 0xFF); + buf.push(addressFormatVersion & 0xFF); + const srvB = enc.encode(serverAddress); + buf.push(srvB.length); for (const x of srvB) buf.push(x); + buf.push(syncServers.length); + for (const srv of syncServers) { + const sB = enc.encode(srv); + buf.push(sB.length); for (const x of sB) buf.push(x); + } + + // AccessServersBlock (type=40, ver=0) + buf.push(40, 0, accessServers.length); + for (const srv of accessServers) { + const sB = enc.encode(srv); + buf.push(sB.length); for (const x of sB) buf.push(x); + } + + // TrustedStateBlock (type=50, ver=0) + buf.push(50, 0, trustedCount & 0xFF); + + // Записываем record_len: (длина буфера + 64 байта подписи) + const recLen = buf.length + 64; + buf[7] = recLen & 0xFF; + buf[8] = (recLen >> 8) & 0xFF; + + return new Uint8Array(buf); +} + +// ------------------------------------------------------------------- +// Borsh-сериализация Anchor-инструкций +// ------------------------------------------------------------------- + +function serializeCreateServerPdaArgs({ + login, rootKey32, createdAtMs, deviceKey32, blockchainKey32, + blockchainName, usedBytes, lastBlockNumber, lastBlockHash32, + lastBlockSig64, arweaveTxId, serverAddress, addressFormatType, + addressFormatVersion, syncServers, accessServers, trustedCount, rootSig64, +}) { + const b = new BorshBuf(); + b.raw(CREATE_USER_PDA_DISCRIMINATOR); + b.str(login); + b.bytes32(rootKey32); + b.u64(createdAtMs); + b.u64(0n); // additional_limit + // UserMutableFields: + b.bytes32(deviceKey32); + b.bytes32(blockchainKey32); + b.str(blockchainName); + b.u64(usedBytes); + b.u32(lastBlockNumber); + b.vecU8(lastBlockHash32); + b.vecU8(lastBlockSig64); + b.str(arweaveTxId); + b.bool(true); // is_server + b.u8(addressFormatType); + b.u8(addressFormatVersion); + b.str(serverAddress); + b.vecStr(syncServers); + b.vecStr(accessServers); + b.u8(trustedCount); + b.vecU8(rootSig64); + return b.result(); +} + +async function serializeUpdateServerPdaArgs({ + login, rootKey32, createdAtMs, updatedAtMs, version, prevHash32, + deviceKey32, blockchainKey32, blockchainName, usedBytes, + lastBlockNumber, lastBlockHash32, lastBlockSig64, arweaveTxId, + serverAddress, addressFormatType, addressFormatVersion, + syncServers, accessServers, trustedCount, rootSig64, +}) { + const discriminator = await anchorDiscriminator('update_user_pda'); + const b = new BorshBuf(); + b.raw(discriminator); + b.str(login); + b.bytes32(rootKey32); + b.u64(createdAtMs); + b.u64(updatedAtMs); + b.u32(version); + b.vecU8(prevHash32); + b.u64(0n); // additional_limit + // UserMutableFields: + b.bytes32(deviceKey32); + b.bytes32(blockchainKey32); + b.str(blockchainName); + b.u64(usedBytes); + b.u32(lastBlockNumber); + b.vecU8(lastBlockHash32); + b.vecU8(lastBlockSig64); + b.str(arweaveTxId); + b.bool(true); // is_server + b.u8(addressFormatType); + b.u8(addressFormatVersion); + b.str(serverAddress); + b.vecStr(syncServers); + b.vecStr(accessServers); + b.u8(trustedCount); + b.vecU8(rootSig64); + return b.result(); +} + +// ------------------------------------------------------------------- +// Построитель Ed25519-инструкции Solana +// ------------------------------------------------------------------- + +function buildEd25519IxData(sig64, pubkey32, msgHash32) { + const sigOff = 16, pkOff = 80, msgOff = 112; + const data = new Uint8Array(msgOff + 32); + const v = new DataView(data.buffer); + data[0] = 1; data[1] = 0; + v.setUint16(2, sigOff, true); v.setUint16(4, 0xFFFF, true); + v.setUint16(6, pkOff, true); v.setUint16(8, 0xFFFF, true); + v.setUint16(10, msgOff, true); v.setUint16(12, 32, true); v.setUint16(14, 0xFFFF, true); + data.set(sig64, sigOff); + data.set(pubkey32, pkOff); + data.set(msgHash32, msgOff); + return data; +} + +// ------------------------------------------------------------------- +// Парсер бинарных данных PDA (matches Rust deserialize_record_from_pda) +// ------------------------------------------------------------------- + +export function parsePdaData(raw) { + const d = raw instanceof Uint8Array ? raw : new Uint8Array(raw); + if (d.length < 9) throw new Error('PDA слишком короткая'); + if (String.fromCharCode(d[0], d[1], d[2], d[3], d[4]) !== 'SHiNE') { + throw new Error('Неверный magic в PDA'); + } + + const view = new DataView(d.buffer, d.byteOffset); + const recordLen = view.getUint16(7, true); + if (recordLen < 9 + 64 || recordLen > d.length) throw new Error('Неверный record_len'); + + // Подписанная часть = байты [0 .. recordLen-64) + const unsignedBytes = d.slice(0, recordLen - 64); + + let cur = 9; + const ru8 = () => d[cur++]; + const ru32 = () => { const v = view.getUint32(cur, true); cur += 4; return v; }; + const ru64 = () => { const v = view.getBigUint64(cur, true); cur += 8; return v; }; + const rBytes = n => { const s = d.slice(cur, cur + n); cur += n; return s; }; + const rStr = () => { const len = ru8(); return new TextDecoder().decode(rBytes(len)); }; + + const createdAtMs = ru64(); + const updatedAtMs = ru64(); + const recordNumber = ru32(); + const prevRecordHash = rBytes(32); + const login = rStr(); + const blocksCount = ru8(); + + let rootKey32 = null, deviceKey32 = null, blockchainData = null; + let isServer = false, serverData = null; + let accessServers = [], trustedCount = 0; + + for (let i = 0; i < blocksCount; i++) { + const blockType = ru8(); + ru8(); // block_version + + if (blockType === 1) { + rootKey32 = rBytes(32); + } else if (blockType === 2) { + deviceKey32 = rBytes(32); + } else if (blockType === 3) { + const count = ru8(); + const blockchainType = ru8(); + const blockchainName = rStr(); + const blockchainPublicKey = rBytes(32); + const paidLimitBytes = ru64(); + const usedBytes = ru64(); + const lastBlockNumber = ru32(); + const lastBlockHash = rBytes(32); + const lastBlockSignature = rBytes(64); + const arweavePresent = ru8(); + const arweaveTxId = arweavePresent === 1 ? rStr() : ''; + blockchainData = { + blockchainType, blockchainName, blockchainPublicKey, + paidLimitBytes, usedBytes, lastBlockNumber, + lastBlockHash, lastBlockSignature, arweaveTxId, + }; + } else if (blockType === 30) { + if (ru8() === 1) { + isServer = true; + const addressFormatType = ru8(); + const addressFormatVersion = ru8(); + const serverAddress = rStr(); + const syncCount = ru8(); + const syncServers = []; + for (let j = 0; j < syncCount; j++) syncServers.push(rStr()); + serverData = { addressFormatType, addressFormatVersion, serverAddress, syncServers }; + } + } else if (blockType === 40) { + const cnt = ru8(); + for (let j = 0; j < cnt; j++) accessServers.push(rStr()); + } else if (blockType === 50) { + trustedCount = ru8(); + } + } + + const signature = d.slice(cur, cur + 64); + + return { + recordLen, unsignedBytes, + createdAtMs, updatedAtMs, recordNumber, prevRecordHash, + login, rootKey32, deviceKey32, blockchainData, + isServer, serverData, accessServers, trustedCount, signature, + }; +} + +// ------------------------------------------------------------------- +// Вспомогательная: читает start_bonus_limit из economy config PDA +// ------------------------------------------------------------------- + +function readStartBonusLimit(data) { + // Borsh: version(u8=1) + reg_fee(u64) + lamports_per_step(u64) = 17 байт до start_bonus_limit + return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigUint64(17, true); +} + +// ------------------------------------------------------------------- +// Читает и парсит существующую PDA с блокчейна +// ------------------------------------------------------------------- + +export async function readServerPdaData({ login, solanaEndpoint }) { + const solana = await loadSolanaLib(); + const connection = new solana.Connection(String(solanaEndpoint), 'confirmed'); + const loginNorm = String(login).trim().toLowerCase(); + const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID); + const enc = new TextEncoder(); + const [userPda] = solana.PublicKey.findProgramAddressSync( + [enc.encode('login='), enc.encode(loginNorm)], + usersProgram, + ); + const ai = await connection.getAccountInfo(userPda, 'confirmed'); + if (!ai) throw new Error(`PDA не найдена для логина «${loginNorm}»`); + const parsed = parsePdaData(ai.data); + parsed.pdaAddress = userPda.toBase58(); + return parsed; +} + +// ------------------------------------------------------------------- +// Регистрация нового серверного аккаунта в Solana +// ------------------------------------------------------------------- + +export async function registerServerOnSolana({ + login, keyBundle, serverAddress, + addressFormatType = 1, addressFormatVersion = 0, + syncServers = [], accessServers = [], + solanaEndpoint, +}) { + const solana = await loadSolanaLib(); + const connection = new solana.Connection(String(solanaEndpoint), 'confirmed'); + + const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID); + const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID); + const loginGuardProgram = new solana.PublicKey(SHINE_LOGIN_GUARD_PROGRAM_ID); + const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID); + const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID); + + const enc = new TextEncoder(); + const loginNorm = String(login).trim().toLowerCase(); + const blockchainName = `${loginNorm}-001`; + const zeroHash32 = new Uint8Array(32); + + const [userPda] = solana.PublicKey.findProgramAddressSync( + [enc.encode('login='), enc.encode(loginNorm)], usersProgram); + const [economyConfigPda] = solana.PublicKey.findProgramAddressSync( + [enc.encode('shine_users_economy_config')], usersProgram); + const [inflowVault] = solana.PublicKey.findProgramAddressSync( + [enc.encode('shine_payments_inflow_vault')], paymentsProgram); + + const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64); + const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64); + const deviceKey32 = base64ToBytes(keyBundle.devicePair.publicKeyB64); + const deviceKeypair = solana.Keypair.fromSeed( + extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64)); + + const ecoAccount = await connection.getAccountInfo(economyConfigPda); + if (!ecoAccount) throw new Error('Economy config не инициализирован'); + const paidLimitBytes = readStartBonusLimit(ecoAccount.data); // additional_limit = 0 + const createdAtMs = BigInt(Date.now()); + + // Подписываем LastBlockState ключом блокчейна (начальное состояние: всё нули) + const lbsBytes = buildLastBlockStateBytes(loginNorm, blockchainName, 0, zeroHash32, 0n); + const lbsHash = await sha256Bytes(lbsBytes); + const lastBlockSig64 = await signEd25519(keyBundle.blockchainPair.privatePkcs8B64, lbsHash); + + // Строим и подписываем беззнаковую запись PDA корневым ключом + const unsignedRecord = buildUnsignedRecordBytesServer({ + login: loginNorm, createdAtMs, updatedAtMs: createdAtMs, + recordNumber: 0, prevHash32: zeroHash32, + rootKey32, deviceKey32, blockchainKey32, blockchainName, + paidLimitBytes, usedBytes: 0n, lastBlockNumber: 0, + lastBlockHash32: zeroHash32, lastBlockSig64, arweaveTxId: '', + serverAddress, addressFormatType, addressFormatVersion, + syncServers, accessServers, trustedCount: 0, + }); + const unsignedHash = await sha256Bytes(unsignedRecord); + const rootSig64 = await signEd25519(keyBundle.rootPair.privatePkcs8B64, unsignedHash); + + const ixData = serializeCreateServerPdaArgs({ + login: loginNorm, rootKey32, createdAtMs, deviceKey32, blockchainKey32, + blockchainName, usedBytes: 0n, lastBlockNumber: 0, + lastBlockHash32: zeroHash32, lastBlockSig64, arweaveTxId: '', + serverAddress, addressFormatType, addressFormatVersion, + syncServers, accessServers, trustedCount: 0, rootSig64, + }); + + const tx = new solana.Transaction().add( + new solana.TransactionInstruction({ + programId: ed25519Program, keys: [], + data: buildEd25519IxData(rootSig64, rootKey32, unsignedHash), + }), + new solana.TransactionInstruction({ + programId: ed25519Program, keys: [], + data: buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash), + }), + new solana.TransactionInstruction({ + programId: usersProgram, + keys: [ + { pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true }, + { pubkey: userPda, isSigner: false, isWritable: true }, + { pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: inflowVault, isSigner: false, isWritable: true }, + { pubkey: sysvarInstructions, isSigner: false, isWritable: false }, + { pubkey: economyConfigPda, isSigner: false, isWritable: false }, + { pubkey: loginGuardProgram, isSigner: false, isWritable: false }, + ], + data: ixData, + }), + ); + + const signature = await solana.sendAndConfirmTransaction( + connection, tx, [deviceKeypair], { commitment: 'confirmed' }); + + return { signature, pdaAddress: userPda.toBase58(), blockchainName }; +} + +// ------------------------------------------------------------------- +// Обновление серверного профиля в существующей PDA +// Для обновления нужен только root-ключ (подпись записи) + device-ключ (оплата). +// Blockchain-ключ не нужен — переиспользуем существующую подпись LastBlockState из PDA. +// ------------------------------------------------------------------- + +export async function updateServerOnSolana({ + login, keyBundle, serverAddress, + addressFormatType, addressFormatVersion, + syncServers, + solanaEndpoint, +}) { + const solana = await loadSolanaLib(); + const connection = new solana.Connection(String(solanaEndpoint), 'confirmed'); + + const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID); + const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID); + const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID); + const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID); + + const enc = new TextEncoder(); + const loginNorm = String(login).trim().toLowerCase(); + + const [userPda] = solana.PublicKey.findProgramAddressSync( + [enc.encode('login='), enc.encode(loginNorm)], usersProgram); + const [economyConfigPda] = solana.PublicKey.findProgramAddressSync( + [enc.encode('shine_users_economy_config')], usersProgram); + const [inflowVault] = solana.PublicKey.findProgramAddressSync( + [enc.encode('shine_payments_inflow_vault')], paymentsProgram); + + // Читаем существующую PDA + const ai = await connection.getAccountInfo(userPda, 'confirmed'); + if (!ai) throw new Error(`PDA не найдена для логина «${loginNorm}»`); + const pda = parsePdaData(ai.data); + if (!pda.isServer) throw new Error('Эта PDA не является серверной (is_server = false)'); + + const bch = pda.blockchainData; + const deviceKeypair = solana.Keypair.fromSeed( + extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64)); + + // Формат адреса: берём из аргументов или из существующей PDA + const fmtType = addressFormatType ?? pda.serverData?.addressFormatType ?? 1; + const fmtVersion = addressFormatVersion ?? pda.serverData?.addressFormatVersion ?? 0; + + // prev_hash = sha256(unsigned_bytes предыдущей записи) + const prevHash32 = await sha256Bytes(pda.unsignedBytes); + const updatedAtMs = BigInt(Date.now()); + const newVersion = pda.recordNumber + 1; + + // Строим новую беззнаковую запись + const unsignedRecord = buildUnsignedRecordBytesServer({ + login: loginNorm, + createdAtMs: pda.createdAtMs, updatedAtMs, + recordNumber: newVersion, prevHash32, + rootKey32: pda.rootKey32, deviceKey32: pda.deviceKey32, + blockchainKey32: bch.blockchainPublicKey, blockchainName: bch.blockchainName, + paidLimitBytes: bch.paidLimitBytes, usedBytes: bch.usedBytes, + lastBlockNumber: bch.lastBlockNumber, + lastBlockHash32: bch.lastBlockHash, lastBlockSig64: bch.lastBlockSignature, + arweaveTxId: bch.arweaveTxId, + serverAddress, addressFormatType: fmtType, addressFormatVersion: fmtVersion, + syncServers, accessServers: pda.accessServers, trustedCount: pda.trustedCount, + }); + const unsignedHash = await sha256Bytes(unsignedRecord); + const rootSig64 = await signEd25519(keyBundle.rootPair.privatePkcs8B64, unsignedHash); + + // Хэш LastBlockState из существующей PDA (те же данные — та же подпись) + const lbsBytes = buildLastBlockStateBytes( + loginNorm, bch.blockchainName, + bch.lastBlockNumber, bch.lastBlockHash, bch.usedBytes); + const lbsHash = await sha256Bytes(lbsBytes); + + const ixData = await serializeUpdateServerPdaArgs({ + login: loginNorm, rootKey32: pda.rootKey32, + createdAtMs: pda.createdAtMs, updatedAtMs, + version: newVersion, prevHash32, + deviceKey32: pda.deviceKey32, blockchainKey32: bch.blockchainPublicKey, + blockchainName: bch.blockchainName, + usedBytes: bch.usedBytes, lastBlockNumber: bch.lastBlockNumber, + lastBlockHash32: bch.lastBlockHash, lastBlockSig64: bch.lastBlockSignature, + arweaveTxId: bch.arweaveTxId, + serverAddress, addressFormatType: fmtType, addressFormatVersion: fmtVersion, + syncServers, accessServers: pda.accessServers, trustedCount: pda.trustedCount, + rootSig64, + }); + + const tx = new solana.Transaction().add( + // Ed25519: подпись новой записи корневым ключом + new solana.TransactionInstruction({ + programId: ed25519Program, keys: [], + data: buildEd25519IxData(rootSig64, pda.rootKey32, unsignedHash), + }), + // Ed25519: переиспользуем существующую подпись LastBlockState из PDA + new solana.TransactionInstruction({ + programId: ed25519Program, keys: [], + data: buildEd25519IxData(bch.lastBlockSignature, bch.blockchainPublicKey, lbsHash), + }), + new solana.TransactionInstruction({ + programId: usersProgram, + keys: [ + { pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true }, + { pubkey: userPda, isSigner: false, isWritable: true }, + { pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: inflowVault, isSigner: false, isWritable: true }, + { pubkey: sysvarInstructions, isSigner: false, isWritable: false }, + { pubkey: economyConfigPda, isSigner: false, isWritable: false }, + ], + data: ixData, + }), + ); + + const signature = await solana.sendAndConfirmTransaction( + connection, tx, [deviceKeypair], { commitment: 'confirmed' }); + + return { signature, pdaAddress: userPda.toBase58() }; +} + +// ------------------------------------------------------------------- +// Деривация keyBundle из логина + пароля +// Идентична логике SHiNE-клиента (crypto-utils.js): +// masterSecret = Argon2id(login+"\n"+password, salt=sha256("shine-auth-v2|login=...|suffix=master.secret")) +// rootPair = Ed25519(sha256(base64(master) + "|root.key")) +// blockchainPair = Ed25519(sha256(base64(master) + "|bch.key")) +// devicePair = Ed25519(sha256(base64(master) + "|dev.key")) +// ------------------------------------------------------------------- + +function _b64urlToStd(s) { + const n = s.replace(/-/g, '+').replace(/_/g, '/'); + return n + '='.repeat((4 - n.length % 4) % 4); +} + +function _ed25519Pkcs8FromSeed(seed32) { + const prefix = new Uint8Array([ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, + ]); + const out = new Uint8Array(prefix.length + 32); + out.set(prefix); out.set(seed32, prefix.length); + return out; +} + +async function _deriveEd25519PairFromMasterSecret(masterSecret32, suffix) { + const enc = new TextEncoder(); + const material = `${btoa(String.fromCharCode(...masterSecret32))}|${suffix}`; + const seed = await sha256Bytes(enc.encode(material)); + const pkcs8 = _ed25519Pkcs8FromSeed(seed); + const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']); + const jwk = await crypto.subtle.exportKey('jwk', privateKey); + if (!jwk.x) throw new Error(`Не удалось получить публичный ключ (suffix=${suffix})`); + const pubBytes = base64ToBytes(_b64urlToStd(jwk.x)); + return { + publicKeyB64: btoa(String.fromCharCode(...pubBytes)), + privatePkcs8B64: btoa(String.fromCharCode(...pkcs8)), + }; +} + +/** + * Выводит полный keyBundle из логина и пароля. + * Та же самая логика, что используется в SHiNE-клиенте при регистрации. + * + * @param {string} login — логин сервера (нормализуется в нижний регистр) + * @param {string} password — пароль + * @param {function} [onProgress] — коллбэк(0..1) прогресса Argon2id + * @returns {{ rootPair, blockchainPair, devicePair }} + */ +export async function deriveKeyBundleFromPassword({ login, password, onProgress }) { + const { argon2idAsync } = await loadArgon2(); + const enc = new TextEncoder(); + const loginNorm = String(login || '').trim().toLowerCase(); + const pwd = String(password ?? ''); + + // Salt для master secret = sha256("shine-auth-v2|login=...|suffix=master.secret")[0..16] + const saltSource = `shine-auth-v2|login=${loginNorm}|suffix=master.secret`; + const saltFull = await sha256Bytes(enc.encode(saltSource)); + const salt = saltFull.slice(0, 16); + + const passBytes = enc.encode(`${loginNorm}\n${pwd}`); + const masterRaw = await argon2idAsync(passBytes, salt, { + t: 2, m: 65536, p: 1, dkLen: 32, + onProgress, + }); + const masterSecret32 = new Uint8Array(masterRaw); + + const [rootPair, blockchainPair, devicePair] = await Promise.all([ + _deriveEd25519PairFromMasterSecret(masterSecret32, 'root.key'), + _deriveEd25519PairFromMasterSecret(masterSecret32, 'bch.key'), + _deriveEd25519PairFromMasterSecret(masterSecret32, 'dev.key'), + ]); + + const masterSecretB64 = btoa(String.fromCharCode(...masterSecret32)); + return { masterSecretB64, rootPair, blockchainPair, devicePair }; +} + +// ------------------------------------------------------------------- +// Кодирование байт в base58 (для отображения Solana-адреса) +// ------------------------------------------------------------------- + +export function base58Encode(bytes) { + const ALPHA = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + let num = 0n; + for (const b of bytes) num = (num << 8n) | BigInt(b); + let result = ''; + while (num > 0n) { + result = ALPHA[Number(num % 58n)] + result; + num /= 58n; + } + for (const b of bytes) { + if (b !== 0) break; + result = '1' + result; + } + return result; +} diff --git a/shine-server-UI/styles.css b/shine-server-UI/styles.css new file mode 100644 index 0000000..5f87282 --- /dev/null +++ b/shine-server-UI/styles.css @@ -0,0 +1,193 @@ +/* SHiNE Server Admin UI — тёмная тема */ +:root { + --bg: #111; + --surface: #1a1a1a; + --border: #2a2a2a; + --text: #e0e0e0; + --text-muted: #888; + --accent: #4a9eff; + --accent-hover: #6ab4ff; + --success: #4caf50; + --error: #f44336; + --warning: #ff9800; + --radius: 8px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; + font-size: 14px; + line-height: 1.5; + padding: 24px 16px; +} + +.container { + max-width: 640px; + margin: 0 auto; +} + +h1 { + font-size: 20px; + font-weight: 600; + color: var(--accent); + margin-bottom: 4px; +} + +.subtitle { + color: var(--text-muted); + margin-bottom: 24px; + font-size: 13px; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; +} + +.card h2 { + font-size: 15px; + font-weight: 600; + margin-bottom: 16px; + color: var(--text); +} + +.field { + margin-bottom: 14px; +} + +label { + display: block; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +input[type="text"], input[type="password"], textarea { + width: 100%; + background: #0d0d0d; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: monospace; + font-size: 13px; + padding: 10px 12px; + outline: none; + transition: border-color 0.15s; + resize: vertical; +} + +input[type="text"]:focus, input[type="password"]:focus, textarea:focus { + border-color: var(--accent); +} + +input[type="text"][readonly] { + opacity: 0.6; +} + +textarea { + min-height: 80px; +} + +.hint { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; +} + +.btn-row { + display: flex; + gap: 10px; + margin-top: 20px; + flex-wrap: wrap; +} + +button { + padding: 10px 20px; + border-radius: var(--radius); + border: none; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s, background 0.15s; +} + +button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-primary { + background: var(--accent); + color: #fff; +} + +.btn-primary:hover:not(:disabled) { background: var(--accent-hover); } + +.btn-secondary { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); +} + +.btn-secondary:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.status { + padding: 12px 16px; + border-radius: var(--radius); + font-size: 13px; + margin-top: 16px; + word-break: break-all; + display: none; +} + +.status.info { display: block; background: #1a2433; border: 1px solid #2a4a6a; color: #7bb8ff; } +.status.success { display: block; background: #1a2e1a; border: 1px solid #2a4a2a; color: #7dcc7d; } +.status.error { display: block; background: #2e1a1a; border: 1px solid #5a2a2a; color: #f08080; } + +.pda-info { + display: none; + margin-top: 12px; +} + +.pda-row { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; +} + +.pda-row:last-child { border-bottom: none; } + +.pda-key { color: var(--text-muted); min-width: 160px; } +.pda-value { color: var(--text); font-family: monospace; text-align: right; word-break: break-all; } + +.nav-links { + margin-bottom: 20px; +} + +.nav-links a { + color: var(--accent); + text-decoration: none; + margin-right: 16px; + font-size: 13px; +} + +.nav-links a:hover { text-decoration: underline; } + +.section-divider { + border: none; + border-top: 1px solid var(--border); + margin: 20px 0; +} diff --git a/shine-server-UI/update-server-pda.html b/shine-server-UI/update-server-pda.html new file mode 100644 index 0000000..02ed43e --- /dev/null +++ b/shine-server-UI/update-server-pda.html @@ -0,0 +1,484 @@ + + + + + + Обновление PDA сервера — SHiNE Server Admin + + + + +
+ + +

Обновление PDA сервера

+

Меняет адрес сервера или список серверов синхронизации

+ +
+

Параметры Solana

+
+ + +
+
+ +
+

Загрузить существующую PDA

+
+ + +
+
+ +
+
+
+
PDA адрес
+
Версия
+
Создан
+
Обновлён
+
Адрес сервера
+
sync_servers
+
Blockchain
+
Paid limit
+
+
+ + + +
+
+ + + +