ピア「聞こえますか……?」
ピアがしゃべった!!
はじめに
長らく Peer は無口で、未知の言語でノード間の通信を行っていると思われていました。しかし、以下のジャガーさんの記事を参考にすることで、ピアとの会話に参加できることがわかりました。(ちなみに、テクニカルホワイトペーパーにもそれとなく記載がありました。)
ピア間の通信って?
ピア間の通信は SSL ソケット通信で行われています。ソケット通信は TCP/IP を利用する通信全般を指し、トランスポート層に属します。HTTP などのアプリケーション層よりも下位の層です。普段プログラムを書く際にはアプリケーション層を扱うことが多いため、ソケット通信を直接使用する機会は少ないでしょう。この点が、取っつきにくさを感じる理由の一つです。
ノード証明書の生成
ノード証明書とは、よく「ノードの証明書を更新してください」といったアナウンスで耳にする、あの証明書のことです。この証明書の有効期限が切れると、他のノードとの通信ができなくなり、孤立した状態で独自のチェーンを生成し始めます。つまり、フォークが発生する原因となります。
証明書がないと通信ができないため、まずは証明書を準備します。
symbol-node-configurator
を使用すると簡単に証明書を作成できます。ただし、公式リポジトリにあるものは古いバージョンの Symbol SDK を使用しているため、新しい Symbol SDK に対応したバージョンを用意しました(証明書作成部分が動作することを確認済みです)。
Linux の場合
pip install symbol-sdk-python zenlog --user
git clone https://github.com/ccHarvestasya/symbol-node-configurator.git
openssl genpkey -algorithm ed25519 -outform PEM -out ca.key.pem
python3 ../symbol-node-configurator/certtool.py --working cert --name-ca "my cool CA" --name-node "my cool node name" --ca ca.key.pem
cat cert/node.crt.pem cert/ca.crt.pem | sudo tee cert/node.full.crt.pem
Windows の場合
OpenSSL は こちら から v3.0 系をダウンロードしてください(新しすぎるバージョンは動作しない場合があります)。Win64 または Win32 はご自身の環境に合わせて選択してください。Light バージョンで問題ありません。インストーラー形式は EXE または MSI のどちらでもお好みで選んでください。
pip install symbol-sdk-python zenlog --user
git clone https://github.com/ccHarvestasya/symbol-node-configurator.git
openssl genpkey -algorithm ed25519 -outform PEM -out ca.key.pem
python ../symbol-node-configurator/certtool.py --working cert --name-ca "my cool CA" --name-node "my cool node name" --ca ca.key.pem
cat cert/node.crt.pem > cert/node.full.crt.pem
cat cert/ca.crt.pem >> cert/node.full.crt.pem
証明書の準備が整ったら、ソケット通信を SSL でラップしてピアと通信を行います。
以下は、sakia.harvestasya.com:7900
に接続後、すぐに切断するコードの例です。
import os
import ssl
import socket
import json
from pathlib import Path
from symbolchain.BufferReader import BufferReader
from symbolchain.BufferWriter import BufferWriter
NODE_HOST = "sakia.harvestasya.com"
NODE_PORT = 7900
CERTIFICATE_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) + "/cert"
certificate_directory = Path(CERTIFICATE_DIRECTORY)
timeout = 5
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
ssl_context.load_cert_chain(
certificate_directory / "node.full.crt.pem",
keyfile=certificate_directory / "node.key.pem",
)
try:
with socket.create_connection((NODE_HOST, NODE_PORT), timeout) as sock:
with ssl_context.wrap_socket(sock) as ssock:
print("接続しました")
except socket.timeout as ex:
raise ConnectionRefusedError from ex
リクエスト
無事にピアと接続できたら、次はリクエストを送信します。
ブロック高の取得については、ジャガーさんの記事で既に解説されているため、今回はノード情報の取得に焦点を当てます。
送信するデータはヘッダーのみで、ペイロードは不要です(現時点ではペイロード付きの実装はまだ行っていません)。
ヘッダーの内容は以下の 8 バイトです。
- ヘッダーサイズ 4 バイト
- パケットタイプ 4 バイト
パケットタイプはリクエストを判別するためのコードです。
他のパケットタイプについては、 PacketType.h を参照してください。
ノード情報のパケットタイプ (Node_Discovery_Pull_Ping) は 0x111
です。この値はリトルエンディアン形式で格納されます。以下はそのイメージです。
この 8 バイトを送信します。
以下は、sakia.harvestasya.com:7900
に接続してヘッダー作って送信するコードです。
import os
import ssl
import socket
import json
from pathlib import Path
from symbolchain.BufferReader import BufferReader
from symbolchain.BufferWriter import BufferWriter
NODE_HOST = "sakia.harvestasya.com"
NODE_PORT = 7900
CERTIFICATE_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) + "/cert"
certificate_directory = Path(CERTIFICATE_DIRECTORY)
timeout = 5
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
ssl_context.load_cert_chain(
certificate_directory / "node.full.crt.pem",
keyfile=certificate_directory / "node.key.pem",
)
try:
with socket.create_connection((NODE_HOST, NODE_PORT), timeout) as sock:
with ssl_context.wrap_socket(sock) as ssock:
packet_type = 0x111
# ヘッダー作成
header_writer = BufferWriter()
header_writer.write_int(8, 4)
header_writer.write_int(packet_type , 4)
# ヘッダ送信
ssock.send(header_writer.buffer)
except socket.timeout as ex:
raise ConnectionRefusedError from ex
レスポンスを解読
正常に処理されると、ピアから期待したレスポンスが返ってきます。一方、エラーが発生した場合は空データが返されます。
レスポンスの構造は以下の通りです:
- ヘッダー: 8 バイト
- ペイロードサイズ: 4 バイト
- ペイロード: n バイト
ヘッダーにはリクエストした値がそのまま含まれているため、リクエスト内容と一致しているか確認してください。期待通りのレスポンスであれば、ペイロード部分を読み取ります。
Node_Discovery_Pull_Ping のペイロードフォーマットは以下の通りです。
項目 | 長さ |
---|---|
version | 4 バイト |
publicKey | 32 バイト |
networkGenerationHashSeed | 32 バイト |
roles | 4 バイト |
port | 2 バイト |
networkIdentifier | 1 バイト |
host_length | 1 バイト |
friendly_name_length | 1 バイト |
host | host_length |
friendlyName | friendly_name_length |
以下は、sakia.harvestasya.com:7900
に接続し、ヘッダーを作成して送信し、レスポンスを受信して出力するコード例です。
import os
import ssl
import socket
import json
from pathlib import Path
from symbolchain.BufferReader import BufferReader
from symbolchain.BufferWriter import BufferWriter
class NodeDiscoveryPullPing:
def __init__(self):
self.version = 0
self.publicKey = ""
self.networkGenerationHashSeed = ""
self.roles = 0
self.port = 0
self.networkIdentifier = 0
self.host = ""
self.friendlyName = ""
def default_method(item):
if isinstance(item, object) and hasattr(item, "__dict__"):
return item.__dict__
else:
raise TypeError
NODE_HOST = "sakia.harvestasya.com"
NODE_PORT = 7900
CERTIFICATE_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) + "/cert"
certificate_directory = Path(CERTIFICATE_DIRECTORY)
timeout = 5
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
ssl_context.load_cert_chain(
certificate_directory / "node.full.crt.pem",
keyfile=certificate_directory / "node.key.pem",
)
try:
with socket.create_connection((NODE_HOST, NODE_PORT), timeout) as sock:
with ssl_context.wrap_socket(sock) as ssock:
packet_type = 0x111
# ヘッダー作成
header_writer = BufferWriter()
header_writer.write_int(8, 4)
header_writer.write_int(packet_type, 4)
# ヘッダ送信
ssock.send(header_writer.buffer)
# レスポンス空チェック
read_buffer = ssock.read()
if 0 == len(read_buffer):
raise ConnectionRefusedError(f"{NODE_HOST} から受信したデータが空っぽですよ!!")
# レスポンスデータ読み込み
size = BufferReader(read_buffer).read_int(4)
while len(read_buffer) < size:
read_buffer += ssock.read()
# レスポンスヘッダーチェック
reader = BufferReader(read_buffer)
size = reader.read_int(4)
actual_packet_type = reader.read_int(4)
if packet_type != actual_packet_type:
raise ConnectionRefusedError(
f"戻りのパケットタイプは、 {packet_type} を期待したけど {actual_packet_type} でした。ダメです!"
)
# ペイロードをノード情報クラスに詰め込み
node_discovery_pull_ping = NodeDiscoveryPullPing()
reader.offset = 12 # 先頭12バイトオフセット(ヘッダー8バイト+ペイロードサイズ4バイト)
node_discovery_pull_ping.version = reader.read_int(4)
node_discovery_pull_ping.publicKey = reader.read_hex_string(32)
node_discovery_pull_ping.networkGenerationHashSeed = reader.read_hex_string(32)
node_discovery_pull_ping.roles = reader.read_int(4)
node_discovery_pull_ping.port = reader.read_int(2)
node_discovery_pull_ping.networkIdentifier = reader.read_int(1)
host_length = reader.read_int(1)
friendly_name_length = reader.read_int(1)
node_discovery_pull_ping.host = reader.read_string(host_length)
node_discovery_pull_ping.friendlyName = reader.read_string(friendly_name_length)
# ノード情報クラスをJsonで出力
print(json.dumps(node_discovery_pull_ping, default=default_method, indent=2))
except socket.timeout as ex:
raise ConnectionRefusedError from ex
実行する
> python main.py
{
"version": 16777990,
"publicKey": "7587ECE8D3FA11A075E533E83F2F1CC8E09F7D2E1D1BD547A44AC5D4D4C78242",
"networkGenerationHashSeed": "49D6E1CE276A85B70EAFE52349AACCA389302E7A9754BCF1221E79494FC665A4",
"roles": 1,
"port": 7900,
"networkIdentifier": 152,
"host": "sakia.harvestasya.com",
"friendlyName": "_Symbol_TestNet_HarvestasyaNode02/."
}
短い間隔で連続して実行すると空データが返される場合があります。その際は、少し時間を置いて再実行してください。
「えっ? /node/info
にあるはずの nodePublicKey
が見当たらない?」
その場合は、ssock
から取得できる証明書情報を利用して取得してください。
さいごに
これでピアノードから必要な情報を取得できるようになりました。ノード運用に必要なデータが得られるため、ハーベストを目的とする場合にはデュアルノードの運用が不要となり、VPS のスペックを下げることでコスト削減が可能です。