見出し画像

【Symbol Blog】楽しみと利益のためのピアノードとの対話(2021/12/2)

この記事はSymbol/NEMのコア開発者であるJaguarさんの記事「CHATTING WITH PEERS FOR FUN AND PROFIT」を機械翻訳したものです。


背景

Symbolは当初から異種ノードのネットワークとして設計されました。Symbolを設計・構築する際、ピアノードをネットワークのバックボーンとし、いくつかのAPIやデュアルノードを散りばめ、我々の最大の支援者、サービスプロバイダー、取引所が運営することを想定していました。

その代わり、起こったことはまったく違う。大半のノードがデュアルノードになっているのです。デュアルノードはmongoデータベースを搭載し、RESTによるクエリをサポートしているため、ピアノードよりも多くの機能を公開できるのは確かです。しかし、残念なことに、この追加機能によって攻撃対象が増えることになります。たとえば、mongoの脆弱性によってサーバーがダウンする可能性があります。これに対し、ピアノードは最小限の機能を持つノードと考えることができます。性能は劣るものの、ネットワークを完全にサポートし、攻撃対象も最小に抑えることができます。

ノードを説明するためにpeer、api、dualという言葉を聞いたことがあるかもしれませんが、これらは実際に可能なノードタイプの宇宙を表しているわけではありません。実際、これらは可能なノード構成のほんの一握りに過ぎません。ノードはロードする拡張機能のセットによってカスタマイズされます。api ノードでは、拡張機能のコアセットと mongo および zeromq 拡張機能を有効にするだけです。しかし、あるノードが mongo 拡張を有効にして zeromq 拡張を有効にしない、あるいはその逆を妨げるものは何もありません。

ピアノードは他のノード構成と同様に完全に機能し、ネットワークを保護するため、ネットワークツールがそれらをサポートすることは重要です。以下のセクションでは、RESTノードとベアボーンピアノードからチェーン統計情報を取得するためのPythonコードをいくつか記述します。この例では、xymharvesting.net と通信します。

チュートリアル 

まず、チェーン統計の意味を定義しておこう。現在の高さ、チェーンスコア、最終的な高さについての情報を提供する構造体を埋めてみましょう。これらを組み合わせることで、ノードが同期しているかどうかを簡単に示すことができます。

class ChainStatistics:
    def __init__(self):
        self.height = 0
        self.finalized_height = 0
        self.score_high = 0
        self.score_low = 0

説明のために、ChainStatistics用のフォーマッタも追加してみましょう。

    def __str__(self):
        score = self.score_high << 64 | self.score_low
        return '\n'.join([
            f'          height: {self.height}',
            f'finalized height: {self.finalized_height}',
            f'           score: {score}'
        ])

REST QUERY

まず、Symbol REST API を使ってノードの情報を取得します。このAPIについてはすでによくご存知だと思いますが、このセクションで簡単に復習しておきましょうチェーンの統計情報を取得するには、/chain/info エンドポイントにクエリを発行する必要があります。

curl -s "http://xymharvesting.net:3000/chain/info" | python -m json.tool

このコマンドは次のような出力をします。これには、私たちが欲しい4つのプロパティ、height, scoreHigh, scoreLow, latestFinalizedBlock.heightが含まれています。

{
    "height": "747467",
    "scoreHigh": "4",
    "scoreLow": "5750302566595492499",
    "latestFinalizedBlock": {
        "finalizationEpoch": 521,
        "finalizationPoint": 4,
        "height": "747440",
        "hash": "D4EA26FE43937AB9D41A580AA7EFE8865C359F0D0D871C135201A1DD20D5D865"
    }
}

pythonでもrequestsを使えば簡単に同等のことが書けます。

import requests

def get_chain_statistics_rest(host, port):
    json_response = requests.get(f'http://{host}:{port}/chain/info').json()

    statistics = ChainStatistics()
    statistics.height = int(json_response['height'])
    statistics.finalized_height = int(json_response['latestFinalizedBlock']['height'])
    statistics.score_high = int(json_response['scoreHigh'])
    statistics.score_low = int(json_response['scoreLow'])
    return statistics

xymharvesting.netに対してコードを実行すると、次のようになります。

chain_statistics_rest = get_chain_statistics_rest('xymharvesting.net', 3000)
print(chain_statistics_rest)
          height: 747748
finalized height: 747720
           score: 79568623189124512723

簡単なウォームアップになったでしょうか?これから少し難しくなりますよ。

PEER QUERY

さて、ここからが楽しいところです。同じ情報をピアノードAPIで取得しましょう。このAPIはすべてのノードからアクセス可能で、ピアだけのノードからもアクセスできます。それは可能です、約束します。

環境設定

すべてのピアノードはTLSで通信します。任意のノードに接続するためには、SSL証明書を準備する必要があります。

シンボル互換証明書は、2レベルの証明書チェーンで構成される。2レベルチェーンは、ノードと重要度スコアの関連付けを安全に行うことができるため、使用されています。

LEVEL1: メインアカウントの秘密鍵で(自己)署名されたCA証明書。これは、ノードと重要度スコアの間の接続を確立するために使用される。ノードの重要度スコアは、ノードの選択や時刻の同期など、特定の操作でノードを重み付けするために使用される。重要なのは、この証明書の公開鍵だけがリモートサーバに存在することである(ca.pubkey.pem)。ランダムなキーを使用することもできますが、その場合、ノードの重要度はゼロとなります。

LEVEL2:LEVEL1 の CA によって署名されたノード/トランスポート証明書。この証明書に関連する秘密鍵は、リモートサーバ上に存在する(node.key.pem)。この秘密鍵は、ピアコミュニケーションのためのSSHセッションを確立するため、またハーベスト委任要求を復号化するために使用される。

Symbolは、各ホストが厳密に1つのユニークなCAを使用することを要求します。1つのノードから複数のCAを使用することも、複数のノードから同じCAを使用することもサポートされていません。

もしあなたがcatapult-serverのインスタンスが動作しているノード上でこのコードを実行するつもりなら、サーバーによって使用される同じ証明書を使用する必要があります。これらは通常、あなたのデプロイメント方法に応じて、 cert または certificates と名付けられたディレクトリに見つけることができます。

もしあなたがcatapult-serverを実行していないノードで実行しているなら、ちょっとした準備をして、Symbol互換の証明書チェーンを生成する必要があります。幸いなことに、これは以下のコマンドで実現できる。

# generate CA private key
openssl genpkey -algorithm ed25519 -outform PEM -out ca.key.pem

# get the certtool
git clone https://github.com/symbol/symbol-node-configurator.git

# generate the certificate chain
PYTHONPATH=./symbol-node-configurator 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/ca.crt.pem > cert/node.full.crt.pem

すべて正しく実行されていれば、次のコマンドでコンソールにOKが書き込まれるはずです。

openssl verify -CAfile cert/ca.crt.pem cert/node.full.crt.pem

OKが表示されない場合は、何かが間違っており、opensslとの伝説的な戦いが待っています。 幸運を祈ります。そして、反対側でお会いしましょう。

接続準備

この例はRESTよりも少し複雑なので、接続を初期化するクラスから始めましょう。このスニペットは、いくつかの変数のセットアップと ssl_context の初期化以外にはあまり多くのことを行いません。簡潔にするために、検証モードはCERT_NONEを使用しています。実稼働環境では、catapult-clientおよびcatapult-restプロジェクトで行われているのと同様のカスタム検証モードの実装を検討するとよいでしょう。sslモジュールがこのレベルのカスタマイズをサポートしているかどうかについては、さらなる調査が必要である。

import socket
import ssl
from pathlib import Path
from symbolchain.core.BufferReader import BufferReader
from symbolchain.core.BufferWriter import BufferWriter


class SymbolPeerClient:
    def __init__(self, host, port, certificate_directory):
        (self.node_host, self.node_port) = (host, port)
        self.certificate_directory = Path(certificate_directory)
        self.timeout = 10

        self.ssl_context = ssl.create_default_context()
        self.ssl_context.check_hostname = False
        self.ssl_context.verify_mode = ssl.CERT_NONE
        self.ssl_context.load_cert_chain(
            self.certificate_directory / 'node.full.crt.pem',
            keyfile=self.certificate_directory / 'node.key.pem')

node.full.crt.pem は,LEVEL1 と LEVEL2 の証明書を連結して Symbol 互換の 2 レベル証明書チェーンを形成したものである.

Symbolでは、すべてのピアコミュニケーションはパケットに包まれています。各パケットは、そのサイズとタイプを示す小さなヘッダーと、オプションのペイロードで構成されています。パケットはリクエストとレスポンスの両方に使用されます。あるリクエストはレスポンスを引き起こし、他のリクエストは引き起こさない。

チェーン統計エンドポイントや、多くの興味深いエンドポイントは、リクエスト/レスポンスのセマンティクスを持っています。リクエストパケットにはデータを含むものもありますが、そうでないものも多くあります。後者は「シンプル」または「ヘッダのみ」パケットと呼ばれます。

このリクエスト/レスポンスフローを制御するためのヘルパー関数を書いてみましょう。簡潔にするために、単純なリクエストパケットを想定します。ここではあまり面白いことは何もありません。このコードはソケット接続を作成し、それをSSLでラッピングしているだけです。

    def _send_socket_request(self, packet_type, parser):
        try:
            with socket.create_connection((self.node_host, self.node_port), self.timeout) as sock:
                with self.ssl_context.wrap_socket(sock) as ssock:
                    self._send_simple_request(ssock, packet_type)
                    return parser(self._read_packet_data(ssock, packet_type))
        except socket.timeout as ex:
            raise ConnectionRefusedError from ex

要求事項

単純なパケットを送信するためのヘルパー関数を書いてみましょう。単純なパケットはサイズと型だけで構成されることを思い出してください。そのため、python sdk のシンボルにある BufferWriter を使えば、簡単にパケットを作成することができます。

    @staticmethod
    def _send_simple_request(ssock, packet_type):
        writer = BufferWriter()
        writer.write_int(8, 4)
        writer.write_int(packet_type, 4)
        ssock.send(writer.buffer)

回答数

パケットを受信してそのデータをBufferReaderでラップするヘルパー関数を、同じくsymbol python sdkから書いてみましょう。まず、パケットサイズを読み取り、パケットデータ全体を受信するまでソケットからチャンクを読み取ります。次に、読み込んだバイトをBufferReaderで囲み、パケットヘッダを検査します。最後に、応答パケットが期待通りの型を持っていることを確認します。

    def _read_packet_data(self, ssock, packet_type):
        read_buffer = ssock.read()

        if 0 == len(read_buffer):
            raise ConnectionRefusedError(f'socket returned empty data for {self.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'socket returned packet type {actual_packet_type} but expected {packet_type}')

        return reader

PAYOFF

まだ読んでくれているなら、完全に混乱せず、私たちが何をしようとしたのか覚えていることを望みます。 忘れてしまった人のために説明すると、それはピアノードからチェーンの統計情報を取得することです。

チェーン統計のリクエストを送信する必要があります。チェーンの統計情報を問い合わせるには、Chain_Statisticsというタイプのシンプルなリクエストパケットを送る必要があります。この値や他の値は、ここで見つけることができます。もしそのリンクが見覚えのあるものなら、それは私がすでに共有したものなので、おそらく重要なものでしょう。そのファイルの中でチェーン統計を探すと、以下のようになります。

/* Chain statistics have been requested by a peer. */ \
ENUM_VALUE(Chain_Statistics, 5) \

これで、上記のヘルパー関数を呼び出すことができ、ほぼ完了です。

  def get_chain_statistics(self):
        packet_type = 5
        return self._send_socket_request(packet_type, self._parse_chain_statistics_response)

さて、あとはチェーン統計のレスポンスをパースするだけだが、それはどのようなものだろうか?それは、catapult-clientのどこかで定義されているはずですよね?その通り、それはChainStatisticsResponseという全く意外性のない名前を持っています。それを見てみると、まさに私たちが欲しい4つのフィールドを持っていることがわかります。

それがわかれば、フィールドを解析して、そこからChainStatisticsを作成することができます。

  @staticmethod
    def _parse_chain_statistics_response(reader):
        chain_statistics = ChainStatistics()

        chain_statistics.height = reader.read_int(8)
        chain_statistics.finalized_height = reader.read_int(8)
        chain_statistics.score_high = reader.read_int(8)
        chain_statistics.score_low = reader.read_int(8)

        return chain_statistics

最後に、すべてをまとめて、ピアクエリを作成し、それが動作することを期待します。

# CERTIFICATE_DIRECTORY should point to a directory containing Symbol-compatible certificates
peer_client = SymbolPeerClient('xymharvesting.net', 7900, CERTIFICATE_DIRECTORY)
chain_statistics_peer = peer_client.get_chain_statistics()
print(chain_statistics_peer)

すべて正しく行ったのであれば、前のセクションとまったく同じ出力が表示されるはずです。

      height: 747748
finalized height: 747720
           score: 79568623189124512723

何か学んで、ピアノードと会話できるようになることを期待しています。

READER EXERCISES

  1. node/info エンドポイントと Node_Discovery_Pull_Ping パケットでノード情報を取得する。

  2. サンプルを同期式から非同期式に変換する。

  3. その他、コードの改善案を作成し、miscellaneousにPRする。

この記事が気に入ったらサポートをしてみませんか?