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


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

ピアがしゃべった！！

## はじめに

長らく Peer は無口で、未知の言語でノード間の通信を行っていると思われていました。しかし、以下のジャガーさんの記事を参考にすることで、ピアとの会話に参加できることがわかりました。（ちなみに、テクニカルホワイトペーパーにもそれとなく記載がありました。）

https://symbolblog.com/developer-guides/chatting-with-peers-for-fun-and-profit/

## ピア間の通信って？

ピア間の通信は SSL ソケット通信で行われています。ソケット通信は TCP/IP を利用する通信全般を指し、トランスポート層に属します。HTTP などのアプリケーション層よりも下位の層です。普段プログラムを書く際にはアプリケーション層を扱うことが多いため、ソケット通信を直接使用する機会は少ないでしょう。この点が、取っつきにくさを感じる理由の一つです。

## ノード証明書の生成

ノード証明書とは、よく「ノードの証明書を更新してください」といったアナウンスで耳にする、あの証明書のことです。この証明書の有効期限が切れると、他のノードとの通信ができなくなり、孤立した状態で独自のチェーンを生成し始めます。つまり、フォークが発生する原因となります。

証明書がないと通信ができないため、まずは証明書を準備します。  
`symbol-node-configurator` を使用すると簡単に証明書を作成できます。ただし、公式リポジトリにあるものは古いバージョンの Symbol SDK を使用しているため、新しい Symbol SDK に対応したバージョンを用意しました（証明書作成部分が動作することを確認済みです）。

### Linux の場合

```bash
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 は [こちら](https://slproweb.com/products/Win32OpenSSL.html) から v3.0 系をダウンロードしてください（新しすぎるバージョンは動作しない場合があります）。Win64 または Win32 はご自身の環境に合わせて選択してください。Light バージョンで問題ありません。インストーラー形式は EXE または MSI のどちらでもお好みで選んでください。

```posh
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` に接続後、すぐに切断するコードの例です。

```python
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 バイト

パケットタイプはリクエストを判別するためのコードです。

:::note info
他のパケットタイプについては、 [PacketType.h](https://github.com/symbol/symbol/blob/ac059226f966c6071cda65016c89bbf6050aab42/client/catapult/src/catapult/ionet/PacketType.h) を参照してください。
:::

ノード情報のパケットタイプ (Node_Discovery_Pull_Ping) は `0x111` です。この値はリトルエンディアン形式で格納されます。以下はそのイメージです。

![symbol_header.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622558/7c48c478-572f-7afd-0718-d0a755300afc.jpeg)

この 8 バイトを送信します。  
以下は、`sakia.harvestasya.com:7900`に接続してヘッダー作って送信するコードです。

```Python
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` に接続し、ヘッダーを作成して送信し、レスポンスを受信して出力するコード例です。

```python
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
```

実行する

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