15
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SymbolピアノードとPythonで会話する

Last updated at Posted at 2023-12-03

ピア「聞こえますか……?」

ピアがしゃべった!!

はじめに

長らく 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 です。この値はリトルエンディアン形式で格納されます。以下はそのイメージです。

symbol_header.jpg

この 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 のスペックを下げることでコスト削減が可能です。

15
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?