Initial commit

This commit is contained in:
AidarKC
2026-03-18 22:28:13 +03:00
parent 37c36ffdba
commit 18bf5d65d7
31 changed files with 34524 additions and 0 deletions
+552
View File
@@ -0,0 +1,552 @@
package server.logic;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.binary.handlers.*;
import server.logic.ws_protocol.WireCodes;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Map;
/**
* Обработчик входящих сообщение на сервер.
* По коду сообщения (первые 4 байта сообщения) находи нужный хэндлер и передаёт в него сообщение
* Получает и возвращает ответ от хэндлера
*/
public final class InboundMessageProcessor {
private static final Logger log = LoggerFactory.getLogger(InboundMessageProcessor.class);
private static final Map<Integer, MessageHandler> HANDLERS = Map.of(
// WireCodes.Op.PING, new PingHandler()
// WireCodes.Op.ADD_BLOCK, new AddBlockHandler(),
// WireCodes.Op.GET_BLOCKCHAIN,new GetBlockchainHandler()
// WireCodes.Op.SEARCH_USERS, new SearchUsersHandler(),
// WireCodes.Op.GET_LAST_BLOCK_INFO,new GetLastBlockInfoHandler()
);
private InboundMessageProcessor() {}
public static byte[] process(byte[] msg) {
if (msg == null || msg.length < 4)
return intTo4Bytes(WireCodes.Status.BAD_REQUEST);
int op = first4ToInt(msg);
MessageHandler h = HANDLERS.get(op);
if (h == null) {
log.warn("Неизвестная операция: {}", op);
return intTo4Bytes(WireCodes.Status.BAD_REQUEST);
}
try {
return h.handle(msg);
} catch (Exception e) {
log.error("Ошибка при обработке операции {}", op, e);
return intTo4Bytes(WireCodes.Status.INTERNAL_ERROR);
}
}
private static int first4ToInt(byte[] msg) {
return ByteBuffer.wrap(msg, 0, 4)
.order(ByteOrder.BIG_ENDIAN)
.getInt();
}
public static byte[] intTo4Bytes(int code) {
return ByteBuffer.allocate(4)
.order(ByteOrder.BIG_ENDIAN)
.putInt(code)
.array();
}
}
package server.logic.ws_protocol.binary.handlers;
/**
* Общий интерфейс для всех обработчиков входящих сообщений.
*/
public interface MessageHandler {
/**
* Обработать входящее сообщение и вернуть бинарный ответ.
*/
byte[] handle(byte[] msg);
}
package server.ws;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.BlockchainStateDAO;
import shine.db.entities.BlockchainStateEntry;
import utils.files.FileStoreUtil;
import shine.log.BlockchainAdminNotifier;
import java.io.IOException;
import java.nio.file.*;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* ===============================================================
* BlockchainTmpRecoveryOnStartup — восстановление консистентности
* blockchain файлов при старте сервера.
*
* Сценарий проблемы:
* - при добавлении блока сначала пишется <name>.tmp_bch
* - потом коммитится БД (state.fileSizeBytes)
* - потом tmp переименовывается поверх <name>.bch (атомарно, если возможно)
*
* Если сервер упал в середине, может остаться tmp:
* - tmp есть, а основной .bch остался старым
* - tmp есть, а основной .bch уже удалили/заменить не успели
* - tmp есть, а БД успела/не успела обновиться
*
* Этот класс при старте:
* - ищет все *.tmp_bch в data/
* - сравнивает размеры:
* - tmp
* - main (если есть)
* - state.fileSizeBytes (если есть)
*
* Правила:
*
* A) state есть:
* - если stateSize == mainSize => tmp удаляем
* - если stateSize == tmpSize => tmp ставим на место main (atomicReplaceBlockchainFile)
* - иначе => КРИТИЧЕСКАЯ ОШИБКА: сервер останавливаем + уведомление администратору
*
* B) state НЕТ:
* - если main НЕТ и tmp ЕСТЬ => tmp удаляем (мусор после падения/неуспешной транзакции)
* - если main ЕСТЬ и tmp ЕСТЬ => КРИТИЧЕСКАЯ ОШИБКА: уведомление администратору + стоп сервера
*
* Логирование:
* - обо всех восстановленных/удалённых tmp пишем в лог
* - если tmp-файлов нет — тоже пишем в лог
* ===============================================================
*/
public final class BlockchainTmpRecoveryOnStartup {
private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class);
private BlockchainTmpRecoveryOnStartup() {}
/**
* Запуск восстановления.
* Если обнаружена ситуация, когда размеры не совпали и сервер сам не может чинить — бросаем исключение.
*/
public static void runRecoveryOrThrow() {
FileStoreUtil fs = FileStoreUtil.getInstance();
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
ensureDirExists(dataDir);
List<Path> tmpFiles = listTmpFiles(dataDir);
if (tmpFiles.isEmpty()) {
log.info("🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется.");
return;
}
log.warn("🟡 BlockchainTmpRecovery: найдено временных файлов: {}", tmpFiles.size());
for (Path tmpPath : tmpFiles) {
String fileName = tmpPath.getFileName().toString();
String blockchainName = extractBlockchainNameFromTmp(fileName);
if (blockchainName == null || blockchainName.isBlank()) {
// странное имя — не трогаем автоматически, но это уже повод дернуть админа
BlockchainAdminNotifier.critical(
"НАЙДЕН TMP-ФАЙЛ С НЕОЖИДАННЫМ ИМЕНЕМ: " + fileName + " (не могу определить blockchainName).",
null
);
throw new IllegalStateException("Bad tmp file name: " + fileName);
}
Path mainPath = dataDir.resolve(fs.buildBlockchainFileName(blockchainName));
long tmpSize = safeSize(tmpPath);
boolean mainExists = Files.exists(mainPath);
long mainSize = mainExists ? safeSize(mainPath) : -1L;
BlockchainStateEntry st = null;
try {
st = stateDAO.getByBlockchainName(blockchainName);
} catch (SQLException e) {
BlockchainAdminNotifier.critical(
"ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ TMP: blockchainName=" + blockchainName + " (сервер остановлен).",
e
);
throw new IllegalStateException("DB error during tmp recovery for " + blockchainName, e);
}
// ============================================================
// CASE B) state НЕТ
// ============================================================
if (st == null) {
if (!mainExists) {
// НЕТ state, НЕТ main, есть tmp => удаляем tmp
log.warn("🟠 BlockchainTmpRecovery: state отсутствует и main отсутствует, но tmp найден => удаляем tmp. blockchainName={}, tmpSize={}",
blockchainName, tmpSize);
safeDelete(tmpPath);
continue;
}
// НЕТ state, но main есть и tmp есть => это уже подозрительно
BlockchainAdminNotifier.critical(
"НЕСОГЛАСОВАННОСТЬ: ЕСТЬ main И tmp, НО НЕТ state В БД. " +
"blockchainName=" + blockchainName +
", mainSize=" + mainSize +
", tmpSize=" + tmpSize +
". СЕРВЕР ОСТАНОВЛЕН. " +
"ПОДОЗРЕНИЕ: файлы могли быть изменены вне сервера.",
null
);
throw new IllegalStateException("State missing but both main and tmp exist for " + blockchainName);
}
// ============================================================
// CASE A) state ЕСТЬ
// ============================================================
long stateSize = st.getFileSizeBytes();
// 1) stateSize == mainSize => tmp мусор
if (mainExists && mainSize == stateSize) {
log.info("🟢 BlockchainTmpRecovery: stateSize совпадает с main => tmp удаляем. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}",
blockchainName, stateSize, mainSize, tmpSize);
safeDelete(tmpPath);
continue;
}
// 2) stateSize == tmpSize => tmp это актуальная версия, ставим на место main
if (tmpSize == stateSize) {
log.warn("🟡 BlockchainTmpRecovery: stateSize совпадает с tmp => восстанавливаем main из tmp. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}",
blockchainName, stateSize, mainSize, tmpSize);
try {
// метод уже есть и делает move tmp->main с попыткой ATOMIC_MOVE
fs.atomicReplaceBlockchainFile(blockchainName);
// после move tmp должен исчезнуть сам (перемещён)
log.info("✅ BlockchainTmpRecovery: восстановление выполнено. blockchainName={}, newMainSize={}",
blockchainName, safeSize(mainPath));
} catch (Exception e) {
BlockchainAdminNotifier.critical(
"НЕ УДАЛОСЬ ВОССТАНОВИТЬ main ИЗ tmp (move failed). " +
"blockchainName=" + blockchainName +
", stateSize=" + stateSize +
", mainSize=" + mainSize +
", tmpSize=" + tmpSize +
". СЕРВЕР ОСТАНОВЛЕН.",
e
);
throw new IllegalStateException("Cannot replace main from tmp for " + blockchainName, e);
}
continue;
}
// 3) НИЧЕГО НЕ СОВПАЛО => критическая ситуация
BlockchainAdminNotifier.critical(
"ФАТАЛЬНАЯ НЕСОГЛАСОВАННОСТЬ BLOCKCHAIN ФАЙЛОВ. " +
"blockchainName=" + blockchainName +
", stateSize=" + stateSize +
", mainExists=" + mainExists +
", mainSize=" + mainSize +
", tmpSize=" + tmpSize +
". СЕРВЕР ОСТАНОВЛЕН. " +
"ТУТ НУЖНО УВЕДОМЛЕНИЕ АДМИНИСТРАТОРУ: возможно файлы изменены вручную/другой программой.",
null
);
throw new IllegalStateException("Blockchain files mismatch for " + blockchainName);
}
log.info("✅ BlockchainTmpRecovery: обработка tmp-файлов завершена.");
}
/* ===================================================================== */
/* =============================== Helpers ============================== */
/* ===================================================================== */
private static void ensureDirExists(Path dir) {
try {
if (!Files.exists(dir)) {
Files.createDirectories(dir);
}
} catch (IOException e) {
throw new IllegalStateException("Cannot create data dir: " + dir, e);
}
}
private static List<Path> listTmpFiles(Path dataDir) {
List<Path> out = new ArrayList<>();
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) {
for (Path p : ds) {
if (Files.isRegularFile(p)) out.add(p);
}
} catch (IOException e) {
throw new IllegalStateException("Cannot list tmp files in: " + dataDir, e);
}
return out;
}
/**
* Из "anya0001.tmp_bch" -> "anya0001"
*/
private static String extractBlockchainNameFromTmp(String tmpFileName) {
if (tmpFileName == null) return null;
if (!tmpFileName.endsWith(FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) return null;
String base = tmpFileName.substring(0, tmpFileName.length() - FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION.length());
// базовая защита: не допускаем слэши/.. даже если кто-то подложил файл
if (base.isBlank()) return null;
if (base.contains("/") || base.contains("\\") || base.contains("..")) return null;
return base;
}
private static long safeSize(Path p) {
try {
return Files.size(p);
} catch (IOException e) {
throw new IllegalStateException("Cannot read file size: " + p, e);
}
}
private static void safeDelete(Path p) {
try {
Files.deleteIfExists(p);
} catch (IOException e) {
throw new IllegalStateException("Cannot delete file: " + p, e);
}
}
}
package server.ws;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.WriteCallback;
import org.eclipse.jetty.websocket.api.annotations.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.InboundMessageProcessor;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.JsonInboundProcessor;
import java.nio.ByteBuffer;
import java.util.concurrent.CompletableFuture;
@WebSocket
public class BlockchainWsEndpoint {
private static final Logger log = LoggerFactory.getLogger(BlockchainWsEndpoint.class);
private Session session;
/** Контекст для текущего WebSocket-соединения. */
private final ConnectionContext connectionContext = new ConnectionContext();
@OnWebSocketConnect
public void onConnect(Session session) {
this.session = session;
// Привязываем WebSocket-сессию к ConnectionContext
connectionContext.setWsSession(session);
log.info("WS connected: {}", session.getRemoteAddress());
}
@OnWebSocketMessage
public void onBinary(byte[] payload, int offset, int length) {
byte[] msg = new byte[length];
System.arraycopy(payload, offset, msg, 0, length);
// Асинхронно обрабатываем входящее бинарное сообщение
CompletableFuture
.supplyAsync(() -> InboundMessageProcessor.process(msg))
.thenAccept(resp -> {
if (resp != null && session != null && session.isOpen()) {
session.getRemote().sendBytes(ByteBuffer.wrap(resp), new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("Failed to send response", x);
}
@Override
public void writeSuccess() {
log.debug("Response sent successfully");
}
});
}
})
.exceptionally(ex -> {
log.error("Processing failed", ex);
trySendCode(500);
return null;
});
}
private void trySendCode(int code) {
if (session != null && session.isOpen()) {
byte[] resp = InboundMessageProcessor.intTo4Bytes(code);
session.getRemote().sendBytes(ByteBuffer.wrap(resp), new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("Failed to send error code", x);
}
@Override
public void writeSuccess() {
log.debug("Error code {} sent", code);
}
});
}
}
@OnWebSocketClose
public void onClose(int statusCode, String reason) {
log.info("WS closed: {} {}", statusCode, reason);
// Удаляем это подключение из реестра активных соединений
ActiveConnectionsRegistry.getInstance().remove(connectionContext);
// На всякий случай очищаем контекст
connectionContext.reset();
}
@OnWebSocketError
public void onError(Throwable cause) {
log.error("WS error", cause);
}
// Обработка текстовых JSON-запросов
@OnWebSocketMessage
public void onText(String message) {
log.info("📥 Получено TEXT-сообщение от клиента: {}", message);
CompletableFuture
.supplyAsync(() -> JsonInboundProcessor.processJson(message, connectionContext))
.thenAccept(respJson -> {
if (respJson != null && session != null && session.isOpen()) {
log.info("📤 Отправляем ответ клиенту: {}", respJson);
session.getRemote().sendString(respJson, new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("⚠️ Не удалось отправить JSON-ответ клиенту: {}", x.toString());
}
@Override
public void writeSuccess() {
log.debug("✔ JSON-ответ успешно отправлен");
}
});
}
})
.exceptionally(ex -> {
log.error("❌ Ошибка при обработке JSON-сообщения", ex);
trySendJsonError();
return null;
});
}
private void trySendJsonError() {
if (session != null && session.isOpen()) {
String resp = "{\"op\":null,\"requestId\":null,\"status\":500,"
+ "\"payload\":{\"code\":\"INTERNAL_ERROR\",\"message\":\"Ошибка сервера\"}}";
log.info("📤 Отправляем клиенту ошибку JSON: {}", resp);
session.getRemote().sendString(resp, new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("⚠️ Не удалось отправить JSON-ответ клиенту: {}", x.toString());
}
@Override
public void writeSuccess() {
log.debug("✔ JSON-ошибка успешно отправлена");
}
});
}
}
}
package server.ws;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import utils.config.AppConfig;
import java.time.Duration;
/**
* WsServer — поднимает Jetty WS на /ws.
*
* ВАЖНО:
* - перед стартом сервера выполняем recovery tmp-блокчейнов.
* - если обнаружена несогласованность, которую сервер сам чинить не может —
* recovery бросает исключение и сервер не стартует.
*/
public final class WsServer {
private static final Logger log = LoggerFactory.getLogger(WsServer.class);
public static void main(String[] args) throws Exception {
// ============================================================
// 0) Восстановление консистентности blockchain файлов
// ============================================================
try {
BlockchainTmpRecoveryOnStartup.runRecoveryOrThrow();
} catch (Exception e) {
// Уже должно быть “большое” уведомление через BlockchainAdminNotifier,
// но на всякий случай логируем ещё раз.
log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain tmp-файлов.", e);
throw e; // останавливаем запуск
}
// ============================================================
// 1) Настройки порта
// ============================================================
AppConfig config = AppConfig.getInstance();
int port = 7070;
try {
String portStr = config.getParam("server.port");
if (portStr != null && !portStr.isBlank()) {
port = Integer.parseInt(portStr.trim());
}
} catch (Exception e) {
log.info("Не удалось прочитать параметр server.port, используем порт по умолчанию {}", port);
}
// ============================================================
// 2) Запуск Jetty WS
// ============================================================
Server server = new Server(port);
ServletContextHandler context = new ServletContextHandler();
context.setContextPath("/");
server.setHandler(context);
// Инициализация контейнера WebSocket
JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> {
// Таймаут простоя соединения (Jetty 11 синтаксис)
wsContainer.setIdleTimeout(Duration.ofMinutes(5));
// Маппинг эндпоинта
wsContainer.addMapping("/ws", (req, resp) -> new BlockchainWsEndpoint());
});
server.start();
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
server.join();
}
}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
SKIPFILE="skip.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# читаем список исключённых имён (без расширения) в массив
if [[ -f "$SKIPFILE" ]]; then
mapfile -t SKIP_LIST < "$SKIPFILE"
else
SKIP_LIST=()
fi
find . -type f -name "*.java" | sort | while read -r f; do
fname=$(basename "$f" .java) # имя файла без расширения
# проверяем, есть ли имя в списке исключений
skip=false
for skipf in "${SKIP_LIST[@]}"; do
if [[ "$fname" == "$skipf" ]]; then
skip=true
break
fi
done
if [[ "$skip" == true ]]; then
echo "Пропускаем $f"
continue
fi
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
echo "Готово! Все .java файлы собраны в $OUTFILE (кроме исключённых из $SKIPFILE)"
+172
View File
@@ -0,0 +1,172 @@
Сервер SHiNE Blockchain представляет собой бинарный WebSocket-сервис, обрабатывающий запросы клиентов в виде бинарных пакетов и возвращающий также бинарные ответы. Сервер управляет пользовательскими цепочками блоков (файлы формата .bch), проверяет подписи Ed25519 и хэши SHA-256, сохраняет данные на диск и позволяет получать информацию о состоянии цепочек и искать пользователей. Все цепочки и метаданные хранятся локально, без базы данных. Цепочки записываются в файлы data/{id}.bch, а сводная информация о пользователях хранится в JSON-файле data/blockchain_info.json.
Подключение клиента происходит по WebSocket на адрес ws://localhost:8080/ws или wss://shineup.me/ws. Протокол полностью бинарный, без текстовых или JSON-данных. Каждое сообщение клиента — отдельный бинарный пакет, сервер отвечает отдельным бинарным пакетом. Можно отправлять несколько запросов подряд, они обрабатываются независимо и асинхронно.
Любое сообщение клиента начинается с 4 байт, которые определяют тип операции (opCode). Данные кодируются в big-endian. После первых четырёх байт следует полезная нагрузка, структура которой зависит от конкретной операции. Ответ сервера также начинается с 4 байт — это код состояния (statusCode), после которого могут следовать дополнительные данные.
Основные операции протокола имеют следующие коды: 0 — PING, 1 — ADD_BLOCK, 2 — GET_BLOCKCHAIN, 30 — SEARCH_USERS. Код 0 используется для проверки соединения, сервер отвечает кодом 100 (PONG). Код 1 добавляет блок в цепочку. Код 2 возвращает полный бинарный файл блокчейна. Код 30 выполняет поиск пользователей по подстроке логина.
Коды ответов статусов: 100 (PONG) — ответ на пинг, 200 (OK) — операция успешна, 400 (BAD_REQUEST) — ошибка формата или данных, 404 (NOT_FOUND) — цепочка не найдена, 409 (ALREADY_EXISTS) — блок с таким номером уже существует, 412 (NON_SEQUENTIAL) — получен блок с номером больше ожидаемого, 422 (UNVERIFIED) — не совпал хэш или подпись, 500 (INTERNAL_ERROR) — исключение или внутренняя ошибка сервера. Все коды возвращаются в виде четырёх байт BigEndian.
Операция PING проста: клиент отправляет 4 байта со значением 0, сервер отвечает 4 байтами со значением 100. Это нужно для проверки активности соединения.
Главная операция — ADD_BLOCK (код 1). Она используется для добавления нового блока в существующую или новую цепочку. Формат запроса: первые 4 байта — число 1 (код операции), следующие 8 байт — идентификатор цепочки blockchainId в формате long BigEndian. После этого следует бинарный блок формата .bch, который полностью включает данные, подпись и хэш.
Формат файла .bch состоит из последовательности блоков без промежутков. Каждый блок имеет вид RAW + подпись (64 байта) + хэш (32 байта). RAW-часть состоит из заголовка размером 20 байт и тела произвольной длины. В заголовке содержатся поля: recordSize (4 байта, общий размер RAW), recordNumber (4 байта, порядковый номер блока), timestamp (8 байт, UNIX-время), recordType (2 байта, тип тела, 0=Header, 1=Text), recordTypeVersion (2 байта, версия структуры данного типа). После этого идёт само тело блока (body).
Тело блока определяется по типу. Тип 0 — HeaderBody, заголовок цепочки, создающий новую цепочку. Тип 1 — TextBody, простой текстовый блок. Для новых цепочек допускается только блок типа 0 с номером 0.
Когда сервер получает ADD_BLOCK, он сначала извлекает blockchainId и пытается найти информацию о цепочке в BchInfoManager. Если цепочки нет, то сервер допускает только блок-заголовок (type=0, num=0). Он парсится как HeaderBody, проверяется корректность логина, совпадение blockchainId и валидность подписи. Предыдущий хэш для первого блока считается нулевым (32 байта нулей). Если всё совпадает, создаётся новая запись о цепочке, создаётся файл data/{id}.bch, туда записывается бинарный блок, и в blockchain_info.json добавляется запись с логином, публичным ключом и текущим номером блока. Если в этом сценарии блок не является HeaderBody, или подпись невалидна, или blockchainId в теле не совпадает с заголовком — сервер возвращает код ошибки (обычно 400 или 422).
Если цепочка существует, сервер проверяет, что номер нового блока ровно на единицу больше последнего (lastBlockNumber + 1). Если номер меньше, возвращается 409 (блок уже есть), если больше — 412 (пропуск в номерах). После этого сервер берёт хэш последнего блока, собирает канонический preimage (логин UTF-8 + blockchainId + prevHash32 + rawBytes), вычисляет SHA-256, сравнивает с переданным hash32 и проверяет подпись Ed25519. Если оба совпадают, тело блока парсится (TextBody или другой тип), выполняется его логическая проверка (check). После успешной проверки блок дописывается в файл .bch, обновляется информация о состоянии цепочки (последний номер, новый хэш, общий размер). Если подпись или хэш не совпали, сервер возвращает 422.
Валидация подписи и хэша выполняется в классе BchCryptoVerifier. Preimage собирается из последовательности байт: сначала логин UTF-8 (без длины), затем 8 байт blockchainId BigEndian, затем 32 байта предыдущего хэша, затем сырые байты блока (RAW без подписи и хэша). Далее вычисляется SHA-256(preimage) и проверяется, что hash32 совпадает с ним. Подпись проверяется как Ed25519(preimage, publicKey32). Если хотя бы одна проверка не пройдена — блок отклоняется.
Операция GET_BLOCKCHAIN (код 2) используется для получения всего бинарного содержимого цепочки. Формат запроса: первые 4 байта — код 2, следующие 8 байт — blockchainId (BigEndian). Ответ сервера начинается с 4 байт (200 при успехе), затем 4 байта длины данных, затем идут байты содержимого файла целиком. Если цепочка не найдена, возвращается 404. Если произошла ошибка чтения — 500. Таким образом клиент может загрузить весь блокчейн-файл и при необходимости сам распарсить блоки.
Операция SEARCH_USERS (код 30) выполняет поиск логинов по подстроке. Формат запроса: первые 4 байта — код 30, следующие 4 байта — длина строки поиска N, затем N байт UTF-8 текста. Сервер выполняет поиск без учёта регистра по всем логинам, известным в blockchain_info.json, и возвращает максимум 5 совпадений. Ответ состоит из 4 байт (200 при успехе), затем 4 байт числа найденных пар, затем для каждой пары: 8 байт blockchainId BigEndian, 1 байт длины логина L, и L байт логина UTF-8 в оригинальном регистре. Если ничего не найдено, сервер возвращает 200 и число 0. Если запрос некорректен, возвращается 400.
Файловая система сервера устроена просто: в каталоге data хранятся все файлы. Каждый блокчейн имеет свой файл с именем {id}.bch. В этом файле последовательно записаны блоки в двоичном виде. Параллельно существует файл blockchain_info.json — JSON-объект, где ключ — blockchainId, а значение — структура с логином, публичным ключом в Base64, последним номером блока, хэшем последнего блока и размером цепочки. При добавлении нового блока сервер обновляет запись и сохраняет JSON на диск.
Сервер не требует аутентификации — безопасность обеспечивается криптографией: только владелец приватного ключа может подписывать блоки своей цепочки. Публичный ключ хранится в первом блоке HeaderBody и проверяется при добавлении.
Таким образом, чтобы написать клиент, нужно:
Уметь подключаться по WebSocket (ws или wss).
Формировать бинарные пакеты в формате BigEndian.
Для создания новой цепочки сгенерировать пару ключей Ed25519, собрать HeaderBody (с полями blockchainId, логин, публичный ключ), сериализовать его в байты, упаковать в блок типа 0 с номером 0, подписать preimage, добавить подпись и хэш, и отправить через ADD_BLOCK.
Для добавления обычного блока (тип 1) использовать тот же алгоритм: собрать preimage из логина, id, предыдущего хэша и RAW-байтов, подписать, прикрепить подпись и хэш, отправить.
Для чтения — вызвать GET_BLOCKCHAIN и распарсить ответ.
Для поиска пользователей — отправить SEARCH_USERS и распарсить пары.
Для проверки соединения — PING.
Все числовые значения всегда в BigEndian. Все строки в UTF-8. Все подписи — Ed25519 длиной 64 байта. Все хэши — SHA-256 длиной 32 байта.
Типы тел блоков:
HeaderBody (тип 0) содержит фиксированные поля: ASCII тег "SHiNE", blockchainId (8 байт), длину логина, логин UTF-8, четыре зарезервированных числа (type, number, version, prevId), и 32 байта публичного ключа.
TextBody (тип 1) содержит просто UTF-8-строку без дополнительных полей.
Клиент, который реализует этот протокол, должен уметь: открывать WebSocket-соединение, отправлять бинарные пакеты по описанным форматам, вычислять SHA-256 и подписи Ed25519, и интерпретировать ответы сервера по первым четырём байтам статуса. Вся логика взаимодействия основана на простых числовых кодах и фиксированных структурах без сложных заголовков.
Главное правило — сервер никогда не принимает блок, если не совпадает подпись или хэш. Поэтому клиент обязан правильно формировать preimage и использовать правильный публичный/приватный ключ. Также сервер строго следит за порядком номеров блоков. Валидация тела блока проверяет только базовую корректность (непустой логин, корректный UTF-8). Все остальные проверки лежат на клиенте.
В результате взаимодействия через этот протокол клиент может создавать собственные цепочки блоков, добавлять в них данные, загружать свои или чужие цепочки и искать пользователей по логину. Формат прост, двоичный и полностью детерминирован.
Формат блока .bch является центральным элементом протокола. Каждый блок хранится последовательно, без промежутков. Цепочка — это просто последовательность таких блоков, записанных подряд в бинарный файл. Каждый блок состоит из трёх частей: RAW-часть (все данные без подписи и хэша), подпись длиной 64 байта и хэш длиной 32 байта. Общая длина блока равна длине RAW + 96 байт. Сервер при чтении файла просто двигается от начала к концу, разбирая один блок за другим, опираясь на поле recordSize, которое всегда указывает длину RAW-части (тело без подписи и хэша).
RAW-часть имеет фиксированный формат и начинается с 20 байт заголовка. Эти 20 байт — это общая «шапка» для любого типа блока. В ней хранятся:
recordSize — 4 байта, целое число BigEndian, равное длине всей RAW-части (20 + длина тела).
recordNumber — 4 байта, номер блока в цепочке, начиная с нуля.
timestamp — 8 байт, время создания блока в секундах Unix Time.
recordType — 2 байта, короткое число, тип тела (0 = HeaderBody, 1 = TextBody, в будущем могут быть другие).
recordTypeVersion — 2 байта, версия структуры тела (для HeaderBody всегда 1, для TextBody тоже 1).
После этих 20 байт сразу идёт тело блока (body), которое имеет разный формат в зависимости от типа.
Тип 0 — HeaderBody, заголовочный блок, всегда имеет номер 0 и используется для создания новой цепочки. Его структура тела строго определена и содержит:
• 8 байт ASCII-строки “SHiNE” — это сигнатура формата.
• 8 байт long BigEndian — blockchainId, уникальный идентификатор цепочки.
• 1 байт — длина логина пользователя N.
• N байт — логин пользователя в UTF-8 (без завершающего нуля).
• 4 байта int BigEndian — blockchainType (пока всегда 0).
• 4 байта int BigEndian — blockchainNumber (пока всегда 0).
• 2 байта short BigEndian — версия формата пользователя (всегда 1).
• 8 байт long BigEndian — prevUserBchId (всегда 0).
• 32 байта — публичный ключ пользователя Ed25519 (publicKey32).
После этих данных никакого дополнительного контента нет. Суммарная длина тела зависит от длины логина. Таким образом, для HeaderBody: длина RAW = 20 + (8 + 8 + 1 + N + 4 + 4 + 2 + 8 + 32). Этот блок полностью описывает владельца цепочки и его публичный ключ. При создании нового блокчейна сервер разрешает только один такой блок с номером 0 и типом 0.
Тип 1 — TextBody, обычный текстовый блок. Его тело состоит только из текста UTF-8, без дополнительных метаданных. Это могут быть сообщения, комментарии, данные или команды. Любые последующие блоки (номер 1, 2, 3 и т.д.) обычно имеют тип 1. При сериализации тело просто представляет собой байты строки в кодировке UTF-8.
После RAW-части добавляются две секции: подпись (signature64) и хэш (hash32). Подпись — это 64 байта, результат Ed25519.sign(preimage, privateKey). Хэш — это 32 байта, результат SHA-256(preimage).
Ключевое понятие — preimage. Это каноническая бинарная последовательность, из которой формируется хэш и подпись. Она состоит из следующих частей, строго в указанном порядке:
байты логина пользователя в UTF-8 (без длины, просто сами байты);
8 байт идентификатора цепочки blockchainId в формате long BigEndian;
32 байта предыдущего хэша prevHash32 (для самого первого блока — это 32 нуля);
байты RAW-части (включая заголовок и тело, но без подписи и хэша).
Из этого preimage берутся два результата:
• hash32 = SHA-256(preimage)
• signature64 = Ed25519.sign(preimage, privateKey32)
Хэш хранится в блоке в конце и используется для проверки целостности при добавлении следующего блока. Следующий блок при вычислении своего preimage уже использует этот хэш как prevHash32. Таким образом, создаётся цепочка зависимых хэшей, образующих полную криптографическую связанность всех блоков. Подпись подтверждает, что именно владелец приватного ключа подписал блок. Сервер проверяет это через Ed25519.verify(preimage, signature64, publicKey32).
Итоговая структура полного блока (FULL) выглядит так:
• 4 байта recordSize
• 4 байта recordNumber
• 8 байт timestamp
• 2 байта recordType
• 2 байта recordTypeVersion
• M байт body
• 64 байта signature64
• 32 байта hash32
Общая длина блока равна recordSize + 96. При чтении из файла сервер сначала берёт recordSize, по нему знает, где заканчивается тело, а после этого считывает ещё 64 байта подписи и 32 байта хэша.
Важно понимать: сервер не принимает блок, если хотя бы одно из условий нарушено — несоответствие длины, некорректный UTF-8 в теле, неправильная подпись, неверный хэш или сбитая последовательность номеров. Сервер также не принимает новый блок, если его номер не совпадает с ожидаемым (т.е. если цепочка уже имеет последний номер N, следующий блок должен иметь номер N+1).
При генерации нового блока клиент должен выполнить последовательность шагов:
Собрать тело блока (body). Для HeaderBody — с нужным логином и публичным ключом. Для TextBody — просто текст.
Сформировать RAW-часть: заполнить 20 байт заголовка, указать длину тела, номер блока, время, тип и версию, затем добавить тело.
Собрать preimage (логин UTF-8 + blockchainId + prevHash32 + rawBytes). Для первого блока prevHash32 — 32 нуля.
Посчитать SHA-256(preimage) и сохранить как hash32.
Подписать preimage своим приватным ключом Ed25519 и получить signature64.
Объединить rawBytes + signature64 + hash32 в один массив.
Отправить этот массив серверу в запросе ADD_BLOCK (после поля blockchainId).
Если всё сделано правильно, сервер проверит подпись и хэш, убедится, что цепочка корректна, и добавит блок. В случае успеха сервер вернёт 200. Если подпись или хэш неверные — 422. Если номер блока не совпадает с ожидаемым — 409 или 412.
Таким образом, клиент может последовательно создавать цепочку блоков, где каждый следующий блок зависит от предыдущего через хэш. Формат абсолютно детерминирован и не допускает вариаций. Все целые числа записываются в формате BigEndian. Все строки кодируются в UTF-8 без завершающего нуля. Все подписи Ed25519 имеют длину 64 байта, все хэши SHA-256 имеют длину 32 байта. Цепочка считается валидной, если каждый блок проходит проверку по своей подписи и хэшу, а номера блоков непрерывны от 0 и далее.
Эта структура едина как для клиентской стороны, так и для сервера. Клиент, следуя этому формату, способен полностью создавать, подписывать и проверять блоки офлайн, а затем синхронизировать их с сервером, просто отправляя бинарные блоки в ADD_BLOCK. Сервер при получении блока повторяет ту же логику вычисления preimage и сверяет подпись и хэш, гарантируя, что ни один байт блока не был изменён.
Таким образом, формат блока .bch является криптографически связанной последовательностью структур, каждая из которых включает заголовок, тело, подпись и хэш, и все они формируют надёжную цепочку данных, проверяемую без участия центральной базы данных.
Рекомендации для клиента и описание операции GET_LAST_BLOCK_INFO.
Клиент хранит у себя приватный ключ пользователя, который никогда не передаётся серверу. Из этого приватного ключа вычисляется публичный ключ Ed25519, который используется для подписи блоков и проверки на сервере. Публичный ключ передаётся только один раз — внутри первого блока HeaderBody, создающего новую цепочку. После этого сервер хранит у себя только публичный ключ и логин пользователя, и проверяет подписи всех следующих блоков именно по нему. Приватный ключ остаётся исключительно у клиента.
Клиент может создавать приватный ключ двумя способами. Первый — случайная генерация 32 байт (seed) через криптографический генератор случайных чисел. Второй, более удобный — детерминированная генерация из пароля пользователя: берётся строка пароля UTF-8, вычисляется SHA-256 от неё, и результат (32 байта) используется как приватный ключ. Это позволяет восстановить тот же ключ из одного и того же пароля без хранения seed в явном виде. Из приватного ключа вычисляется публичный через Ed25519. Таким образом, клиент может всегда получить ту же пару (private/public), просто имея пароль.
На стороне клиента рекомендуется хранить три параметра для каждой цепочки: приватный ключ (или пароль, из которого он создаётся), публичный ключ и идентификатор цепочки blockchainId. Идентификатор цепочки — это уникальное 8-байтное число (long), которое выбирается клиентом при создании новой цепочки. Он может быть сгенерирован случайно, взят из системного счётчика или рассчитан как часть хэша от логина, но сервер не навязывает конкретный способ — важно только, чтобы значение было уникально.
Для синхронизации с сервером клиент должен отслеживать состояние последнего блока: его номер и хэш. Эти значения необходимы при формировании следующего блока, чтобы корректно подставить prevHash32 в preimage. Сервер предоставляет отдельную операцию для получения этой информации.
Операция GET_LAST_BLOCK_INFO имеет код 31 и используется для запроса состояния выбранной цепочки. Клиент отправляет запрос, состоящий из 12 байт: первые 4 байта — код операции (int 31 в BigEndian), следующие 8 байт — идентификатор цепочки blockchainId (long BigEndian). Сервер проверяет наличие цепочки и возвращает 40 байт данных.
Ответ от сервера имеет следующую структуру:
• 4 байта — код статуса (200 при успехе, 404 если цепочка не найдена, 500 при ошибке);
• 4 байта — номер последнего блока (int BigEndian, если цепочка пуста — 0);
• 32 байта — хэш последнего блока SHA-256 (если цепочка пуста, все нули).
Таким образом, клиент может в любой момент узнать, до какого блока сервер синхронизирован, чтобы не отправлять повторно уже существующие данные. Если сервер вернул 404, это значит, что цепочка с указанным blockchainId ещё не существует, и клиент может начать новую, отправив HeaderBody через ADD_BLOCK. Если сервер вернул 200, клиент использует полученный номер и хэш для формирования следующего блока: новый recordNumber должен быть на единицу больше полученного, а prevHash32 при вычислении preimage — это хэш, возвращённый сервером.
Рекомендуется, чтобы клиент при каждом запуске сначала вызывал GET_LAST_BLOCK_INFO для своей цепочки, сверял локальное состояние с сервером, и только после этого создавал или отправлял новые блоки. Это обеспечивает целостность цепочки и правильную последовательность номеров.
Таким образом, клиент хранит у себя всё необходимое для подписи и создания блоков — приватный ключ, логин, blockchainId и последнее состояние. Сервер же хранит только публичный ключ, логин, текущий номер блока и последний хэш. Все вычисления подписи и хэша выполняются клиентом локально. Это гарантирует, что сервер не может подделать или изменить ни один блок, а клиент при необходимости может полностью восстановить всю цепочку, просто имея свой пароль или приватный ключ.
File diff suppressed because it is too large Load Diff