ピア「聞こえますか……?」
ピアがしゃべった!!
はじめに
というわけで、長らく Peer は無口というか未知の言語でノード間で会話していると思ってましたが、下記ジャガーさんの記事により会話に参加することが可能ということがわかりました。(あとで気付いたけどテクニカルホワイトペーパーにふんわり書いてあった
ピア間の通信って?
ピア間の通信は SSL ソケット通信で行われてます。ソケット通信は TCP/IP を利用する通信全般のことであるため、トランスポート層になります。HTTP などのアプリケーション層より下位の層になります。普段プログラム書くのもアプリケーション層部分を使用して書くので、通常ソケット通信なんて使用しません。この辺りが、取っつきにくさ感が出てくる所ですね。
ノード証明書の生成
ノード証明書というと、よく「ノードの証明書を更新してください。」などアナウンスがある、あの証明書です。ちなみに期限が切れると他のノードとの通信が確立しなくなり、孤立して独自のチェーンを紡ぎ始めます。つまりフォークします。
そんなわけで証明書がないと通信出来ないので、証明書を用意します。
symbol-node-configurator を使用すると簡単に証明書を作成することが出来ます。ただ、公式リポジトリにあるやつだと古い SymbolSDK を使用するので、新しい方の SymbolSDK に対応したのを置いてます(証明書作成するところだけ動くの確認済)。
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 はココからダウンロード。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 でラップしてピアと仲良く握手。
以下は、symbol02.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 = "symbol02.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 バイトを送信します。
以下は、symbol02.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 = "symbol02.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 |
以下は、symbol02.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 = "symbol02.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": "symbol02.harvestasya.com",
"friendlyName": "_Symbol_TestNet_HarvestasyaNode02/."
}
短い間隔で連続して実行すると空データを返します。
再実行は間を開けてやる。
えっ?nodePublicKey がないって?
それは ssock から取れる証明書から取得してね。
さいごに
これでピアノードから情報を引き出すことが出来るようになりました。ノード運用する上で必要な情報は取れるので、ハーベスト目的であればデュアルノードを運用する必要もなくなりますので、VPSならスペックダウンでき費用も下げれます。