LoginSignup
57
48

More than 5 years have passed since last update.

IPFS を調べた その 1 - Identities, Network

Last updated at Posted at 2019-02-04

現在、js-ipfs を使ったアプリケーションを一つ作っているが、そもそも IPFS ってどうやって動いてるの?ってのが分からなくて怖かったので、一回きちんと調べることにしました。

ただ、とても分量が多かったので、何回かに分けることにした。

IPFS を調べた その 1 - Identities, Network
IPFS を調べた その 2 - Routing, Exchange, Discovery
IPFS を調べた その 3 - Objects, Files, Naming

IPFS ( InterPlanetary File System )

https://ipfs.io/
https://github.com/ipfs/specs
https://github.com/ipfs/awesome-ipfs

IPFS is the Distributed Web

IPFS 自体は、分散型のデータストレージだが、Web を置き換えるといった意気込みがとても強い。

原理

IPFS はさまざまな機能を担うサブプロトコルを組み合わせて構成される。

  • Identities
  • Network
  • Routing
  • Exchange
  • Discovery
  • Objects
  • Files
  • Naming

現在、一部仕様が独立していたり、実装が別ライブラリに切り出されていたりと構成が複雑になっていっているが、プリンシプルは変わっていない。

以下は、主に上記論文と specs 資料を元に進める。わからない部分は適宜実装を確認する。

P2P システム初心者であるため、寄り道しながら進めていきますので読みづらいと思います。ご容赦ください。

1. Identities

分散した Node を識別するために、NodeID を発行する。
これには、生成した公開鍵を Cryptographic Hash 関数にかけて得られた Digest を利用する。

Untitled Diagram-Page-6 (1).png

以下、導出方法 ( 元論文 より引用、一部改変 )

type NodeId Multihash
type Multihash []byte
// self-describing cryptographic hash digest

type PublicKey []byte
type PrivateKey []byte
// self-describing keys

type Node struct {
  NodeId NodeID
  PubKey PublicKey
  PriKey PrivateKey
}

difficulty = << integer parameter >>;
n = Node{};
for {
  n.PubKey, n.PrivKey = PKI.genKeyPair()
  n.NodeId = hash(n.PubKey)
  p = count_preceding_zero_bits(hash(n.NodeId))

  if p < difficulty{
    break
  }
} 

Node 同士で接続した際には、まずお互いの公開鍵を交換し、hash(n.PubKey) == NodeID のチェックを行う。

1.1 multihash ( self-describing hash format )

日本語にしたら、自己記述型ハッシュフォーマット。
Hash Digest の前に、自身の利用している Hash 関数の種類や長さ等メタデータを付与しておく形式。

<function_code><digest_length><digest_bytes>

自己記述型の利点としては、Hash 関数が違う Node 同士でも通信ができる事。
将来、セキュリティへの懸念等の理由で Hash 関数がバージョンアップした場合でも、過去の Hash 値を生成し直す必要はない。

IPFS では、multihash という方式が取られている。

github.com/multiformats/multihash

<varint_hash_function_code><varint_digest_size_in_bytes><hash_function_output>
// Binary
  hash    digest                     hash
function   size                     digest
--------  --------  ------------------------------------
00010001  00000100  101101100 11111000 01011100 10110101
sha1      4 bytes   4 byte sha1 digest
Untitled Diagram-Page-7.png

現在、IPFS 上では以下の Hash 関数の利用が認められている。

  • sha2-256
  • sha2-512
  • sha3


:book: difficulty とは :book:

途中で count_preceding_zero_bits という関数が登場し、この関数が返す値が一定の値を下回らないと生成が完了しない。
これは、BitCoin のマイニング時に Nonce を探す作業と同じだ。

マイニングの難易度を示す指標「採掘難易度」- Blockchain Biz

なぜここで利用しているのか正確な所は不明だが、シビル攻撃という大量の ID を生成しネットワークを乗っ取る攻撃への対応と考えられる。

シビル攻撃 (Sybil Attack) とは何か? - block-chain.jp

論文に明言されてはいないが、多分 Node 接続時に difficulty の検証も行われるんじゃなかろうか。

:book: :book: :book:



1.2 実装

論文中では Node と呼ばれるが、実装では Peer と呼ばれることが多い。

1.2.1 鍵生成

Node.js の場合は、js-libp2p-crypto が提供されている。ブラウザ向けに WebCrypt API への対応もしている。

Go の場合は、go-libp2p-crypto が提供されている。

1.2.2 PeerID 生成

PeerID を生成する前に、生成した鍵の取り回しを良くするために一工夫が必要。
例えば RSA 鍵を生成した場合、公開鍵を RSA DER 形式のエンコードにし、鍵情報を格納する protobuf で定義される構造体に含めて、protobuf でシリアライズしたものを Base64 にかけた文字列にする。

Node.js の場合は、js-peer-id が生成している。実際には js-peer-info が Peer 抽象モデルを作成する際に呼び出される。

Go の場合は、go-libp2p-peer が担当している。

1.2.3 Peer 情報・鍵情報の保存

交換された Peer 情報を保存しておくためのモジュールも専用に用意されている。

Node.js の場合は、js-peer-book が担当する。
Key=PeerID / Value=PeerInfo という Map を On-Memory に保持しつつ、アクセサを公開している。

Go の場合は、まず go-libp2p-peerstore で Interface が決められている。
Peer 情報と Key 情報の保存の他に、PeerMetadeta という Peer 毎の追加情報が保存できるようになっている。その他、

  • 保存情報の CRUD はスレッドセーフ
  • Peer 毎に TTL が設定できるようになっている ( が、TTL Over 時の処理は実装側に任されている )
  • Latency 監視

等の機能を持ち、Node.js に比べて格段の充実ぶり。
go-libp2p-peerstore の実装としては、以下がある。

外部ストレージや分散ストレージに Peer 情報を保存できるのであれば、色々できそうな気がしてくる。

2. Network

IPFS の Network 関係の機能は libp2p として IPFS からは独立している。

2.1 libp2p

P2P Network を構築するにあたり発生する様々な課題を解決することを目的とした仕様。
github.com/libp2p/specs

IPFS はこの仕様元に実装されたモジュールを内部で利用し P2P Network を構築する。

公式実装には、以下がある。

2.2 Transport agnostic

3.1 Transport agnostic - github.com/libp2p/specs

P2P システムの多くは、Internet 上に Overlay Network を構築する。

libp2p は、TCP(UDP)/IP には依存せず、Ethernet や Bluetooth 等様々な Transport Protocol 上であっても同様に P2P システムを構築できることを目指している。


:book: Overlay Network とは :book:

既存の Network 上に構築される、別の仮想的 ( 論理的・抽象的・Software 的 ) な Network Layer のこと。
下位層から独立した Topology・Protocol を構築している場合にこう呼ばれることが多い。

光網の上に構築された IP Network、また Internet の上に構築された P2P Network など。
それ以外にも、CDN や VPN も Overlay Network と呼ばれる。

overlay network - Wikipedia - CC BY 4.0

overlay network - Wikipedia

:book: :book: :book:



2.2.1 multihash ( self-describing addressing )

日本語にしたら、自己記述型アドレッシング。
Address を表現する文字列に自身の利用している Protocol を含める形式。multihash と同様の考え方。

libp2p では、multiaddr という方式で表現する。

multiformats/multiaddr

/<protoName string>/<value string> + <protoName string>/<value string> + ...

IPFS の Node Address の場合、以下の様に <multiaddr> + /ipfs/<Node ID> というフォーマットで表す。

/ip4/127.0.0.1/tcp/9000/ipfs/QmYJyUMAcXEw1b5bFfbBbzYu5wyyjLMRHXGUkCXpag74Fu
# Address :
#   IP          : 127.0.0.1
#   tcp         : 9000
#   IPFS NodeID : QmYJyUMAcXEw1b5bFfbBbzYu5wyyjLMRHXGUkCXpag74Fu

multihash の特徴として、~ over ~ を多層的に表現できるというのがある。

# IPFS over TCP over IPv6 (typical TCP)
/ip6/fe80::8823:6dff:fee7:f172/tcp/4001/ipfs/QmYJyUMAcXEw1b5bFfbBbzYu5wyyjLMRHXGUkCXpag74Fu

# IPFS over uTP over UDP over IPv4 (UDP-shimmed transport)
/ip4/162.246.145.218/udp/4001/utp/ipfs/QmYJyUMAcXEw1b5bFfbBbzYu5wyyjLMRHXGUkCXpag74Fu

# IPFS over IPv6 (unreliable)
/ip6/fe80::8823:6dff:fee7:f172/ipfs/QmYJyUMAcXEw1b5bFfbBbzYu5wyyjLMRHXGUkCXpag74Fu

# IPFS over TCP over DNS4
/dns4/example.com/tcp/4001/ipfs/QmYJyUMAcXEw1b5bFfbBbzYu5wyyjLMRHXGUkCXpag74Fu

# IPFS over TCP over IPv4 over TCP over IPv4 (proxy)
/ip4/162.246.145.218/tcp/7650/ip4/192.168.0.1/tcp/4001/ipfs/QmYJyUMAcXEw1b5bFfbBbzYu5wyyjLMRHXGUkCXpag74Fu

# IPFS over Ethernet (no IP)
/ether/ac:fd:ec:0b:7c:fe/ipfs/QmYJyUMAcXEw1b5bFfbBbzYu5wyyjLMRHXGUkCXpag74Fu

Layer 毎の Protocol やプロキシの構成が明示的に表現されているのが分かる。

/dns4/example.com/ ... の様に、Transport Protocol は ip4 だが DNS による名前解決が必要というコンテキストを含んだ指定もある。

ただし、あくまで表現できるというだけであり、libp2p では信頼性の低い Protocol は実装されていない様だ。

実装

multiaddr 自体は文字列であるが、使いやすくするためには Parser や Validator が必要だ。

Node.js の場合は、js-multiaddr を利用して Parse する。Validation は js-mafmt が担う。

Go の場合、go-multiaddr を利用して Parse する。
その他、multiaddr Instance 同士を操作して カプセル化/解除 やトンネリングを表現する新たな Instance を生成するなど、便利機能も有する。

2.3 Multi-multiplexing

3.2 Multi-multiplexing - github.com/libp2p/specs

libp2p には、Multi-multiplexing ( 日本語にするなら、多重-多重化だろうか ) という考え方がある。
ただ、これに関してまとまって説明されている箇所が上記リンク先しかなく、且つその説明だけでいまいち理解できない。

その為、Multi-multiplexing を構成するであろう概念、そしてその実装を参照して、理解することを試みた。
( まさかこれがあんなに大変な作業になるなんて、この時の僕は知る由もなかった … )

以降は、Multi-multiplexing のみならず、libp2p を構成するほとんど全ての概念の解説となっている ( なってしまっている )。

2.3.1 Transports

Peer 間で接続を確立する際の振る舞いは、Transports というインターフェイス仕様で定義されている。
TCP, UDP, WebRTC, WebSocket 等の様々な Transport Protocol を、互換の問題無く交換可能とすることを目的としている。

libp2p/interface-transport

  • Client
  • Server
  • インスタンス化
    • 例えば WebRTC の Signaling 情報は Dial / Listen で共通して利用するので、インスタンス化してステートを保持したい

この仕様はあくまで接続するまでで、接続後の Connection の振る舞いは、別途仕様が設けられている。

2.3.2 Connection

Transporter により作成された Connection の振る舞いは Connection というインターフェイス仕様で定義されている。

libp2p/interface-connection

  • Read/Write
  • Full/Half Duplex サポート
  • フロー制御
    • Back-Pressure
  • 切断処理
    • Half-Close

実装

Transports と Connection を実装したライブラリは以下にまとめてある

Transports - libp2p.io/implementations

  • libp2p-tcp
  • libp2p-quic ( Stream Muxer 含む)
  • libp2p-websockets
  • libp2p-webrtc-star
  • libp2p-webrtc-direct
  • libp2p-udp
  • libp2p-utp

◆ Node.js

Node.js の場合、接続によって作られた Native な Stream を一旦 Pull Stream へと変換し、それを interface-connection で規定された Connection クラス に渡しインスタンスを作る。

◆ Go

Go の場合、libp2p 全体で共通する Interface が go-libp2p-net というプロジェクトで定義されている。

Transport に関する interface は go-libp2p-transport というモジュールで定義されており、例えば接続を表す Connlibp2p-net/Conn を拡張したものとなっている。
各 Transport 実装は go-libp2p-transport の Interface を実装する形となる。

他 Node へ接続要求 または 他 Node から接続要求を受信 して確立された接続 ( ex. net.Conn, websocket.Conn ) は、①一旦 go-multiaddr-net によって multiaddr friendly な API でラップされた後、 ② go-libp2p-transport-upgrader により Stream 多重化可能通信の暗号化 された Connection へと Upgrade ( 後述 ) される。

2.3.2.1 Connection Manager

ネットワークリソースに制限をかけるためには、全 Connection を総合的に監視する役割が必要となる。
その為の役割が Connection Manager である。

  • 接続 Node 数制限
  • 帯域制限
    • 受信/送信別制限
  • レイテンシ監視
実装

2.3.2.2 Filter

接続したくない Node がある場合は、Filter を設定することができる。
Filter 対象は multiaddr によって指定される。

// make a new filterset
f := NewFilters()

// filter out addresses on the 192.168 subnet
_, ipnet, _ := net.ParseCIDR("192.168.0.0/16")
f.AddDialFilter(ipnet)

// check if an address is blocked
lanaddr, _ := ma.NewMultiaddr("/ip4/192.168.0.17/tcp/4050")
f.AddrBlocked(lanaddr) // = false
実装
  • Node
    • Transport Class は共通に filter メソッドを持つ
  • Go
    • go-maddr-filter
    • multiaddr-filter - CIDR 形式のネットマスクを multiaddr で作成できる
      • ipnet, _ := multiaddrFilter.NewMask("/ip4/192.168.0.0/ipcidr/16")

2.3.3 Network Layer

libp2p では、Network Protocol の実装は役割毎にいくつかの Layer に分かれている。
ドキュメント等にまとめられてはいる訳ではないが、理解に役立つと思い、一度立ち止まって確認する。

TCP/IP 階層モデルの場合は ( カッコ内は、OSI 参照モデルでおおよそ対応するもの )、

  • Link ( 物理層, データリンク層 )
  • Internet ( ネットワーク層 )
  • Transport
  • Application ( セッション層、プレゼンテーション層、アプリケーション層 )

という Layer を形成しており、送信されるデータグラムは送信時は Upper から Lower に向けて Encapsulation され、受診時は Lower から Upper に向けて Decapsulation される。


:book: Encapsulation ( カプセル化 ) とは :book:

ある Network Protocol ( lower layer protocol ) 向けに構成されたデータグラムを、別の Network Protocol ( upper layer protocol ) で流通可能にする為に、そのデータグラムをペイロード ( データ本体 ) としつつ upper layer 向けのヘッダ ( +トレイラ ) を付与した新たなデータグラムを作成すること。

TCP/IP 階層モデルでは上位層から下位層へ向けてカプセル化していく ( 下図 ) が、L2 VPN ( PPTP, L2TP ) などでは逆に下位層を上位層向けにカプセル化したり、L3 VPN ( IPSec, GRE ) では同層同士でカプセル化を行ったりする。

オブジェクト指向で言う所のそれとは違う。

Encapsulation (networking) - Wikipedia - CC BY-SA 3.0

Encapsulation (networking) - Wikipedia

:book: :book: :book:



libp2p では、おおよそ以下のような Layer が形成されている。

  • Transport Layer
  • Encryption Layer
  • Stream Multiplex Layer
  • Application Layer
  • Transport Layer
    • 他の Peer との Connection を確立する
    • Protocols
      • TCP
      • UDP, uTP
      • WebSocket
      • WebRTC
  • Encryption Layer
    • Connection 上のデータグラムを暗号化、また署名を追加する
    • Protocols
      • secio
  • Stream Multiplex Layer
    • Connection 上に複数の Stream を仮想的に確立する
    • Protocols
      • SPDY, HTTP/2
      • yamux
      • mplex
  • Application Layer
    • Application が利用する
    • Protocols
      • DHT
      • BitSwap

libp2p では、Encapsulation して Layer を上げることを Upgrade と呼んでいる。

実装

◆ Go の場合
Go の場合、双方向通信を行う経路 ( Connection, Stream ) は、すべて ReadWriteCloser に抽象され、それを各 Layer 実装がラップしていくことで実現する。

実装イメージ
// transport connection
type Conn io.ReadWriteCloser
func (c *Conn ) Write(data []byte) error {
   sendMesage(data)
}

// encrypted encapsulation
type encryptedConn {
  conn Conn
}
func (e *encryptedConn) Write(data []byte) error {
  header := createheader(data)
  encrypted := encrypt(data)
  buf := concat(header, encrypted)
  e.conn.Write(buf)
}
func wrapEncryptConn(insecure io.ReadWriteCloser) (*encryptedConn, error) {
    return &encryptedConn{conn: insecure}
}

// stream multiplex encapsulation
type muxedConn {
  conn encryptedConn
}
func (m *muxedConn) Write(data []byte) error {
  header := createheader(data)
  buf := concat(header, data)
  m.conn.Write(buf)
}
func wrapMuxedConn(simple io.ReadWriteCloser) (*muxedConn, error) {
    return &muxedConn{conn: simple}
}

conn, err := transportConn(multiaddr)
econn, err := wrapEncryptConn(conn)
mconn, err := wrapMuxedConn(econn)

mcon.Write(bufferarray)

◆ Node.js の場合
Node.js では、双方向通信を行う経路 ( Connection, Stream ) は、Pull Stream ( Pull 型 Stream にすることで Back Pressure 制御不要 ) として扱われ、各 Layer ではその Pull Stream へと Pipe 処理を追加することで実現する。

実装イメージ
// WIP

2.3.4 Encryption

通信の機密性・完全性を担保するためには、通信内容の暗号化と改竄検出が必要となる。

Transport Protocol によっては暗号化されているものもあるが、Transport Protocol に依存せずに暗号化・改竄検出を行うために、この層で改めて暗号化・改竄検出をしている。
一旦この層で暗号化・改竄検出されればその上の層でどんな Protocol でやり取りされようとも安全性は担保される。

PeerID 生成時に公開鍵を生成しているので、TLS Like な暗号化をするのは難しいことではない。

実装

Crypto channels を実装したライブラリは以下にまとめてある。

Crypto channels - libp2p.io/implementations

  • libp2p-secio

Go の場合、関連する interface が go-conn-security というモジュールで定義されている。
現状、secio しか実装されていない以上、実質的にこれが libp2p のデフォルトと言えるだろう。

Go の場合は、Upgrader によって Transport の Connection を Upgrade する形で適用される。
Upgrade された Connection を使ってデータ送信/受信する場合は、全てのデータグラムがこの Layer で Encapsulation/Decapsulation されて輸送される。

2.3.4.1 secio

データの暗号化と署名を行う Protocol。
Protocol レベルでの定義は見つけられず、その実装が仕様となっている感じ。

実装を見ると、TLS にとても似ている事が分かる。

● handshake

以下、データ交換は全て Protocol Buffer により行われる前提とする。

  • 0. 前提条件
    • Node 毎に PublicKey/PrivateKey は既に生成されている
  • 1. 基本情報の交換 (Hello)
    • 1.1. Nonce を作成
    • 1.2. Local Nonce, Local Node PublicKey, 利用可能な KeyExchange,Cipher,Hash 方式の候補をお互いに送り合う
      • 候補は文字列を , で区切ったもので、優先度の高いものから並べる ex P-256,P-384,P-521
      • デフォルトで利用可能な KeyExchange 方式
        • P-256
        • P-384
        • P-521
      • デフォルトで利用可能な Cipher 方式
        • AES-256
        • AES-128
        • Blowfish
      • デフォルトで利用可能な Hash ( MAC ) 方式
        • SHA256
        • SHA512
  • 2. Node ID の検証
    • 2.1 1.2 で受け取った Remote Node PublicKey が、Node ID と一致するか検証
  • 3. アルゴリズム決定
    • 3.1. Local Node, Remote Node 間で決定に違いが出ないように、優先度を決める
      • SHA256(PublicKey + Nonce) の大きい方が優先
    • 3.2. お互いの対応している方式を比べて、KeyExchange・Cipher・Hash 方式を決定
      • 3.1 で求めた優先度が高い方が優先される
  • 4. 鍵交換
    • 4.1. KeyExchange 用の PublicKey/PrivateKey を生成
      • 3.2 で決めた KeyExchange 方式
    • 4.2. 1.2 で送りあった Local Hello, Remote HelloLocal Exchange PublicKey を結合し、Local Node PrivateKey で Sign
      • Sign(LocalHello + RemoteHello + LocalExchangePublicKey)
    • 4.3 Exchange PublicKey と 4.2 の Signature を、お互いに送り合う
    • 4.4 4.3 で受け取った Remote Signature は、1.2 で受け取った Remote Node PublicKey で検証
    • 4.5 1.2 で受け取った Remote Exchange PublicKeyLocal Exchange PrivateKey から、共有された Secret を生成
  • 5. 暗号鍵生成
    • 5.1 Secret をストレッチングした後、bytes を半分に分ける
      • それぞれが、Reader と Writer の鍵の元データとなる
      • 3.1 で求めた優先度により 2 つを入れ替える
    • 5.2 5.1 で得た半分の bytes を、それぞれ Cipher IV, Cipher Key, HMAC Key の 3 つの bytes に分ける
    • 5.3 一方の Cipher IV, Cipher Key, HMAC KeyEtM ( Encrypt-then-MAC ) Writer を構築、もう片方で EtM Reader を構築する
    • 5.4 5.3 の Writer, Reader を合わせて、通信を暗号化できる Connection が完成

ポイントとしては、

  • 2 の検証で、接続したい multiaddr と PublicKey が一致することがわかるので、電子署名による第三者証明は不要
    • つまり、クライアントが multiaddr で接続先を指定する = この公開鍵を持つ人と繋ぎたい という事
  • 4.4 で署名能力をチェック
    • これで、multiaddr で示された公開鍵/秘密鍵を持つ Node であるという検証が完了
  • 5 で Read/Write の鍵を別にした理由は不明
    • tls 実装でもこうなっているのかは未確認
  • 5.1 で 2 つの bytes を優先度で入れ替えるのは、Local Writer に Remote Reader, Local Reader に Remote Writer が対応するようにする為
● send, receive

送信されるデータグラムは、handshake で生成された Cipher IV, Cipher Key で暗号化され、MAC を付与し送信される。
受信したデータグラムは MAC を検証し、Cipher Key, Cipher Key で復号する。

2.3.5 Stream Muxer

ここまでで既に、Transport Protocol に依存せず暗号化された双方向通信可能な Connection は確立されているが、libp2p では通常はそれらを直接利用するわけではなく、Stream という仮想的 ( 論理的 ) な双方向通信チャンネルを作成し、P2P Protocol フレームは Stream 上でやり取りされる事となる。

Stream は 1 つの Connection 上に複数作られ多重化 ( multiplexing ) されることで、接続処理や暗号 Protocol Handshake 等のオーバーヘッドを抑制する。


:book: 多重化 ( multiplexing ) とは :book:

ある一つの通信チャネルを使い、複数のストリームを送受信すること。

例えば、SSH は、一つの Connection で複数 Session を捌く機能を持っている。
tmux ( terminal multiplexer ) は、一つの Screen で複数の仮想 Terminal を操作する。
また sslh は、443 Port 一つで HTTP, SSH, OpenVPN など複数の Protocol を Listen し Handle する事ができる。

FDM - Multiplexing - Wikipedia - CC BY 3.0

Multiplexing - Wikipedia

Introduction to HTTP/2 - Google Developers - CC BY 3.0

Introduction to HTTP/2 - Google Developers

:book: :book: :book:



Connection 上で Stream を多重化する為の Stream Muxer というインターフェイス仕様が定義されている。

libp2p/interface-stream-muxer

  • Connection への Attach
  • Outgoing
    • 新しい Stream Open
  • Incomming
    • Stream 毎に Listener 登録

実装

各言語の実装済みのモジュールは以下にまとめてある

Stream muxers - libp2p.io/implementations

  • libp2p-spdy
  • libp2p-multiplex
  • libp2p-yamux

Go の場合、関連する interface が go-stream-muxer というモジュールで定義されている。
SPDY も yamux も既に広く利用されている Multiplexing 対応のプロトコル or 実装であり、実態は各 Protocol 実装パッケージの Factory に Connection を渡しているだけの物が多い。

Encryption と同様 Upgrader によって Connection を Upgrade する形で適用される。
Upgrade された Connection を使ってデータ送信/受信する場合は、Stream 識別子含む Frame Header で Encapsulation/Decapsulation されて輸送される。

js-libp2p-spdy
const spdy = require('spdy-transport')   // ← これが Nodejs の SPDY 実装モジュール
const toStream = require('pull-stream-to-stream')
...

const conn = toStream(rawConn)           // ← Pull Stream を普通の Stream に変換

const spdyMuxer = spdy.connection.create(conn, {   // ← Stream を受け取って SPDY Connection 作成
  protocol: 'spdy',
  isServer: isListener
})
go-smux-yamux
import (
...
    yamux "github.com/whyrusleeping/yamux"   // ← これが Go の yamux 実装モジュール
)
...

func (t *Transport) NewConn(nc net.Conn, isServer bool) (smux.Conn, error) {
    var s *yamux.Session
    var err error
    if isServer {
        s, err = yamux.Server(nc, t.Config())  // ← Connection を受け取って yamux サーバ作成
    } else {
        s, err = yamux.Client(nc, t.Config())  // ← Connection を受け取って yamux クライアント作成
    }
    return (*conn)(s), err
}

2.3.6 Protocol-Multiplexing

Protocol-Multiplexing

libp2p は様々なプラットフォーム上で等しく動作することを目的としており、その為にいくつもの Protocol に対応している。
しかし、それぞれの Peer がそれぞれ複数の Protocol に対応しているとして、事前合意なくどのような方法で Protocol を選択することができるか。

その為に、libp2p では multistream + multistream-select という方式を取る。

※ ここで言う Stream とは、双方向通信できる経路全般のことである ( Go で言う io.ReadWriteCloser )。

2.3.6.1 multistream ( self-describing protocol/encoding stream )

日本語にしたら、自己記述型プロトコルストリーム。
libp2p では、multistream という方式で表現する。multistream では、Protocol を Protocol Name + Version を表す。

multistream - properties - libp2p/specs

/<protoName string>/<version string>

※ /ipfs/<node id>/<protoName string>/<version string> のような形式で説明される箇所が多数見受けられるが、multistream の仕様自体は Node のアドレスと紐づくものではなく、実装上も /ipfs/<node id> は付けられてはいない。

multistream 自体はこれを規定しているだけであり、どの様な手順で Handshake し、Buffer を送り、合意に至るのかの手順は multistream-select という仕様が担当している。

2.3.6.2 multistream-select

multistream 形式のデータ形式や合意過程を規定しているのが multistream-select である。

multiformats/multistream-select

length-prefixed-message
その前に、length-prefixed-message について知っておく。

  • multistream-select の全ての Message Buffer は、length-prefixed-message と呼ばれる形式で送られる
    • 送信する Buffer の先頭に、Buffer サイズを Uvarint で付与する
  • multistream-select の全ての Message Buffer は、最後に \n を付与する
    • length-prefixed-message のサイズはこれを含んだサイズとなる

handshake
まずは multistream のバージョンを確認し合う。
Dial 側から自身の Protocol, Version を送り、相手から Protocol, Version を送り返す。
その過程で、Dial 側 Listen 側どちらでも検証を行う( 実質 Listen 側検証だけで終わる )。

ls
Listener 側は、自身が対応している Protocol 名一覧を返す役割を持つ。
対応している Protocol は Peer 生成時に事前に登録しておく。

select
Dialer 側は、自身が 接続したい Protocol 名を送り、Listener 側が OK であれば Protocol 名を Ack として返す。

Version の選択については、現在は Node のみ実装済みであるが SemVer を使った Version 範囲指定も可能。
Semantic Versioning 2.0.0

実装

2.3.7 Swarm

Transport, Connection, Stream を内包し、Dial, Listen 処理全体の調整役を務めるのが、この Swarm だ。
余り情報が無い Role ではあるが、とても重要な役割を担っている。

Go には、go-libp2p-swarm というそのものズバリな実装が存在する。
Node.js においては、似たような位置付けの js-libp2p-switch がある。

以降は、Go の実装を中心に進める

2.3.7.1 概要

まずは、全体をざっと確認する。

  • 複数の Transport を管理する
  • Connection は、Peer 毎に複数保持
  • Filter で接続先制限
  • PeerStore で PeerInfo を管理
  • Connection, Stream の Listener に Handler を Attach できる
  • Dial Synchronization System なる機能を持つ

以下、重要な点を見ていく

2.3.7.2 Multi-Transport

Swarm は、複数の Transport を管理することができる。
それにより、他の Peer と接続する際には、相手が利用可能な Transport Protocol を選択して接続してくれる。

例えば、/ip4/162.246.145.218/udp/4001/utp/ipfs/QmYJyUM... という multiaddr で Listen している Peer に接続する場合には、uTP Protocol を実装した Transport が必要となる。
必要な Transport がない場合は、エラーとなる。

2.3.7.3 Multi-Connection

Swarm 内のデータ構造上は Connection は複数持てるようになっている。
しかし、実装を確認すると、複数で冗長化してますという意味合いでもなさそうだ。

Swarm は Connection を秘匿していて直接の操作は公開しておらず、接続処理は Stream を作る際の以下チェック部分で必要となって初めて成される。

libp2p/go-libp2p-swarm/swarm.go
func (s *Swarm) NewStream(ctx context.Context, p peer.ID) (inet.Stream, error) {
  log.Debugf("[%s] opening stream to peer [%s]", s.local, p)

  // Algorithm:
  // 1. Find the best connection, otherwise, dial.
  // 2. Try opening a stream.
  // 3. If the underlying connection is, in fact, closed, close the outer
  //    connection and try again. We do this in case we have a closed
  //    connection but don't notice it until we actually try to open a
  //    stream.
  //
  // Note: We only dial once.
  //
  // TODO: Try all connections even if we get an error opening a stream on
  // a non-closed connection.
  dials := 0
  for {
    c := s.bestConnToPeer(p)              // ← 生きている Connection を探す
    if c == nil {
      if dials >= DialAttempts {          // ← const DialAttempts = 1 なので、試行 1 回のみ
        return nil, errors.New("max dial attempts exceeded")
      }
      dials++

      var err error
      c, err = s.dialPeer(ctx, p)         // ← ここで Dial している
      if err != nil {
        return nil, err
      }
    }
    s, err := c.NewStream()               // ← Connection が見つかったら Stream を作る
    if err != nil {
      if c.conn.IsClosed() {
        continue
      }
      return nil, err
    }
    return s, nil
  }
}
...

func (s *Swarm) bestConnToPeer(p peer.ID) *Conn {
  // Selects the best connection we have to the peer.
  // TODO: Prefer some transports over others. Currently, we just select
  // the newest non-closed connection with the most streams.
  s.conns.RLock()
  defer s.conns.RUnlock()

  var best *Conn
  bestLen := 0
  for _, c := range s.conns.m[p] {
    if c.conn.IsClosed() {
      // We *will* garbage collect this soon anyways.
      continue                    // ← 閉じてるやつは、その内 GC されるので放置
    }
    c.streams.Lock()
    cLen := len(c.streams.m)
    c.streams.Unlock()

    if cLen >= bestLen {          // ← Stream が多い = 生きてる可能性高い、という判断
      best = c
      bestLen = cLen
    }
  }
  return best
}

これらの動きを見ると、生きた Connection 一つをできるだけ使いまわしたいという意図のように感じる。

2.3.7.4 Dial Synchronization System

Swarm は、Outbound 通信量が過剰にならないように、Dial 時にいくつかの工夫がなされている。

◆ DialSync

ある Peer に対する Dial 要求が同時に複数来た場合には、前の Dial 処理が終わるまで待たされる。単位は Peer 毎。

実装としては、この辺り

◆ Backoff

既に接続できなくなった Peer に対し、延々と接続要求をしても無駄なので、
一度接続に失敗すると Backoff リストに追加され、以下式で示された時間が経過しないとリトライできない様になる。

// BackoffBase + BakoffCoef * PriorBackoffs^2
until := (5 + 1 * (retryCount + retryCount) ) * time.Second

一度接続に成功すると Backoff リストから除外される。

実装としては、この辺り

2.3.7.5 Multi-Encryption Protocol handled with multistream

Swarm の役割として、Encryption Protocol の Instance は 外部から Injection されるので本来は範囲外 となるが、libp2p の理念からして Encryption は Peer 同士で利用可能なものを選択できるべきであり、その為に multistream を利用することがある程度前提になっているように思う。

現に、go-libp2p では Encryption に go-conn-security-multistream を利用している。

go-conn-security-multistream は、

  • Encryption Protocol をあらかじめ複数登録
    • これが multistream の対応 Protocol となる
  • Upgrader により Connection が Upgrade される時、multistream-select に則って Encryption Protocol の Negotiation をする
    • Negotiation が上手くいったら、その Encryption Protocol で Connection をラップする

2.3.7.6 Multi-StreamMuxer Protocol handled with multistream

Encryption 同様に、 Stream Muxer の Instance も 外部から Injection されるので範囲外 ではあるが、libp2p の理念からして Stream Muxer Protocol も選択可能であるべきであり、multistream を利用することが前提になっているように思う。

go-libp2p では Stream Muxer に go-smux-multistream を利用しているし、Node.js では js-libp2p-switch で Muxer に利用されている。

go-smux-multistream は、

  • Stream Muxer Protocol をあらかじめ複数登録
    • これが対応 Protocol となる
  • Upgrader により Connection が Upgrade される時、multistream-select に則って Stream Muxer Protocol の Negotiation をする
    • Negotiation が上手くいったら、その Stream Muxer Protocol で Connection をラップする

2.3.7.7 Multi-Stream Protocol handled with multistream

ついでに、開いた Stream 上で何をやり取りするかも Swarm の範囲外だが、これも libp2p の理念からして multistream を利用することがある程度前提になっているように思う。

現に、go-libp2p/p2p/host/basic/basic_host.go では以下のように使えるようになっている。

Dial側
func (h *BasicHost) NewStream(ctx context.Context, p peer.ID, pids ...protocol.ID) (inet.Stream, error) {
  ...

  var protoStrs []string 
  for _, pid := range pids {
    protoStrs = append(protoStrs, string(pid))       // ← 繋ぎたい Protocol をリストに
  }

  s, err := h.Network().NewStream(ctx, p)            // ← 新しい Stream 生成
  if err != nil {
    return nil, err
  }

  selected, err := msmux.SelectOneOf(protoStrs, s)   // ← その Stream 上で multistream negotiation
  if err != nil {
    s.Reset()
    return nil, err
  }
  selpid := protocol.ID(selected)
  s.SetProtocol(selpid)
  h.Peerstore().AddProtocols(p, selected)            // ← 一度つながった Protocol は保存

  return s, nil
}
Listen側
// Host インスタンス生成時
func NewHost(ctx context.Context, net inet.Network, opts *HostOpts) (*BasicHost, error) {
  h := &BasicHost{
    network:      net,                             // ← Swarm インスタンス
    mux:          msmux.NewMultistreamMuxer(),     // ← go-multistream インスタンス
    negtimeout:   DefaultNegotiationTimeout,
    AddrsFactory: DefaultAddrsFactory,
    maResolver:   madns.DefaultResolver,
  }
  ...

  net.SetStreamHandler(h.newStreamHandler)         // ← Swarm で Stream 確立した時に呼ばれる handler 登録
  return h, nil  
}

// その呼ばれる handler
func (h *BasicHost) newStreamHandler(s inet.Stream) {
  ...

  lzc, protoID, handle, err := h.Mux().NegotiateLazy(s)    // ← 要求を受け取り、Negotiation
  took := time.Now().Sub(before)
  ...

  s.SetProtocol(protocol.ID(protoID))
  log.Debugf("protocol negotiation took %s", took)

  go handle(protoID, s)                                    // ← ここで、ユーザが登録した handler が呼ばれる
}

// ユーザが handler を登録する
func (h *BasicHost) SetStreamHandler(pid protocol.ID, handler inet.StreamHandler) {
  h.Mux().AddHandler(string(pid), func(p string, rwc io.ReadWriteCloser) error {
    is := rwc.(inet.Stream)
    is.SetProtocol(protocol.ID(p))
    handler(is)
    return nil
  })
}
使う場合
// Register protocol
host.SetStreamHandler("/echo/1.2", handleStream)

// OpenStream for protocol
s, err := host.NewStream(context.Background(), info.ID, "/echo/1.2")

2.3.7.8 Swarm について まとめると

  • Transport Layer
    • Protocol 選択は、Swarm が Connection が確立時に multiaddr を見ながら判断する
  • Encryption Layer
    • Protocol 選択は、利用側に依存
      • ダイレクトに使っている場合は、選択は必要ない
      • conn-security-multistream を使った場合、Transport Connection 確立時、multistream Negotiation で選択
  • Stream Multiplex Layer
    • Protocol 選択は、利用側に依存
      • ダイレクトに使っている場合は、選択は必要ない
      • smux-multistream の場合は、Transport Connection 確立時、multistream Negotiation で選択
  • Application Layer
    • Protocol 選択は、利用側に依存
      • multistream を利用した場合、Stream 生成時、multistream Negotiation で選択

各層毎に Protocol を Multiple にしておき、そこから Peer 同士で合意を取りながら Protocol を決めていく、という方向性を持っていることが分かる。

2.3.8 Host ( Node )

Swarm を内包し、Network 以外の Modules も統合して管理する役割で、言わば Node ( Peer ) 自体を表すモデル。
特に仕様が決められているわけではない。

実装

Go の場合、go-libp2p-host で Interface が定義されている。

2.3.9 最後に。Multi-multiplexing とは

結局の所、この言葉が意味することろを完全に理解することはできなかったし、未だに この説明 の意味がピンときてはいない。
調べていく中で、偶然以下の図を発見したが、やはりこれを見ても、分かるような分からないような。

go-libp2p-net - MIT

何となく Swarm のやっていることを示しているんだろうな、程度には分かった。
まあいいや。次に進もう。

しかし、Multiplexing, Multiplex, Muxing, Muxed という言葉がとても広い意味に使われていて、とても辛かった。

2.4 NAT Traversal

今日のクライアント PC は、その殆どが NAT の後方に位置し、Outbound は通すが Inbound は不用意には通さない作りとなっている。

2.4.1 NAT ( Network address translation ) とは

NAT 対応機器 ( 主にルータ ) をパケットが通過する際に IP Header を書き換えて、ある IP Address Space を別の IP Address Space にマッピングする方法。

マッピング情報は NAT Table と呼ばれる。

Network Address Translation - Wikipedia

2.4.1.1 NAPT ( Network address port translation )

NAT の場合は、IP Address と IP Address のマッピングであったが、その場合は、変換前 IP Address Space に 10 個の IP Address があったら変換後の IP Address も 10 個必要となってしまう ( 同時に利用する場合 )。

NAPT は、IP Address だけでなく Port も合わせて変換後発信元を一意にすることで、変換後 IP : 変換前 IP = 1 : N となり変換後の IP Address を節約することができる。
下図の External network では、どちらも IP は 10.20.30.40 で Port だけ違うのが分かる。

NAPT は、IP masquerade とも呼ばれ、主に Private Network ( Internal network ) から Internet ( External network ) へアクセスする際に使われる。
これを利用することで Global IP 1 つで複数の LAN 内の ホストマシンが Internet へとアクセスすることが可能となり、IPv4 の Global Address 枯渇を防ぐ事ができる。

2.4.1.2 静的 NAT / 動的 NAT

● 静的 NAT, 静的 NAPT

NAT Table をあらかじめ登録し静的に持つこと。

NAT の場合は IP Address : IP Address のマッピング、NAPT の場合は、IP Address + Port : IP Address + Port + Protocol のマッピングとなる。
静的 NAPT は Port Mapping ( Port Forwarding ) と呼ばれる事もある。

また、P2P システム等で重要となる Internet 側からの Outbound なパケットは、Global 側の IP ( + Port ) が固定となるので受け取ることが可能だ。

● 動的 NAT

利用可能な Global IP をプールしておき、ルータへリクエストが来た段階でそこから一つ貸出し、終わったらまたプールに返すという方式で、少ない Global IP で多くのリクエストをさばくことができる。DHCP とも相性が良い。

しかし、アクセスが多くプール内の IP が枯渇してしまうと、リクエスト待ちが発生してしまうのが難点。

Internet 側からの Outbound なパケットは、ホストにマッピングされた Global IP が毎度変わってしまうため届かない。

● 動的 NAPT ( 動的 IP マスカレード )
利用可能な Port 範囲を指定しておき、ルータへリクエストが来た段階でそこから一つ割り当てる方式。
動的 NAT の IP プールと比べると、Port は Well-Known Port を除いても 6 万以上利用可能であり、枯渇しづらい。

Internet 側からの Outbound なパケットは、ホストにマッピングされた Port

家庭用など一般のルータであれば、ほとんどがこの動的 IP マスカレードを利用している。

2.4.1.3 Symmetric NAT, Cone NAT

● Symmetric NAT

発信元 IP:Port, 発信先 IP:Port の組み合わせに対し、一つの IP:Port を割り当てる方式。
発信先が異なれば別の Port がマッピングされる。

基本的には、発信を受けた発信先のみ送り返すことができる。

Network address translation - Wikipedia - CC BY-SA 3.0
Source mapped for Destination
Client Client → Server 1 Server1
Client Client → Server 2 Server2
Server1 Client → Server 1 Client
Server2 Client → Server 2 Client
Server1 Client → Server 2 Client ×
Server2 Client → Server 1 Client ×
● Cone NAT

発信元 IP:Port に対し、一つの IP:Port を割り当てる方式。発信先は考慮されない ( Full-cone の場合 )。

その為、発信先以外の外部ホストからもアクセスが可能。

Network address translation - Wikipedia - CC BY-SA 3.0
Source mapped Destination
Client Client → Outer Server1
Client Client → Outer Server2
Server1 Client → Outer Client
Server2 Client → Outer Client

ただし、Client が初めてアクセスした外部 Host からのパケットを受け付けるかどうかは細かく制御でき、それによって Full-cone NAT, Address-restricted-cone NAT, Port-restricted cone NAT に分類される。

2.4.2 NAT Traversal とは

ということで、NAT は少ないの Global IP で Private Network 内の複数の Host から Internet にアクセスできるとても便利な技術だが、こと P2P システムにおいては、外部 Network から見て相手が誰なのか分からないという点で難しさを生む。

この、外部から NAT の中にいる Host へとアクセスする方法を総称して NAT Traversal と呼ぶ。
やり方は様々あるので、以下で代表的な方法を簡単に見ていく。

NAT Traversalって知ってますか - Cerevo TechBlog

2.4.2.1 Port Mapping ( Port Forwarding )

まずは、正攻法 ( 実現できれは成功可能性が最も高い ) のやり方として Port Mapping がある。

NAT に、WAN 側受け取り IP:Port ⇔ LAN 側送り先 Host IP:Port の組み合わせをあらかじめ登録しておき、その WAN 側 Port へ来た Outbound パケットを送り先 Host へと転送する機能。

2.4.2.1.1 手動設定

ルータの設定ファイルの直接編集や、Web Console を利用して設定することができる。

確実な方法であるが、ルータ設定を直接編集できる場合にしか利用できないのが難点。

2.4.2.1.2 UPnP ( Universal Plug and Play ) + IGD ( Internet Gateway Device Protocol )

UPnP は、ネットワーク機器同士が自律的に検索・情報交換・設定し合う機能。IP Layer で動作し、HTTP Protocol で XML ベースの情報交換を行う。
Universal Plug and Play - Wikipedia

IGD は、Internet Gateway Device つまりはルータを操作する為の Protocol で、IP の取得や Port Mapping 情報の参照・変更等が可能だ。
Internet Gateway Device Protocol - Wikipedia

多くのルータは UPnP-IGD ( UPnP で検索・接続し、その上で IGO で対話する ) に対応していて、これと UPnP クライアントがやり取りすることでアプリケーション側から必要な Port Mapping 設定が追加できるようになる。

これも NAT Traversal としては安定した方法であるが、問題点としては、NAT に穴を開けるという特性上攻撃対象となりやすく、現にこれまで幾度となくセキュリティホールを突かれて問題を起こしている。その為か、近年では 『UPnP やばいからルータの UPnP は切っておこう』という判断がなされる場合も多く、またルータがデフォルトで Off にしていたりする。

2.4.2.1.3 NAT-PMP ( NAT Port Mapping Protocol )

主に Apple 製品で利用される、UPnP + IGD の Port Mapping 操作機能と同等の機能を実現する Protocol。
Windows 機であっても、 Bonjour for Windows を導入することで対応可能。
NAT Port Mapping Protocol - Wikipedia

2.4.2.1.4 PCP ( Port Control Protocol )

NAT-PMP の後継 Protocol で、UPnP-IGD との互換性もある ( IGD 側に IWF ( inter-working function ) を組み込む必要あり )。
Port Control Protocol - Wikipedia

2.4.2.2 Hole Punching

Port Forwarding が利用できなくても、NAPT によって動的に与えられる Port が確認・予測できれば、その Port を通して Outbound パケットを通過させることは可能だ。

NAT-A の内にいる A と、NAT-B の内にいる B が通信する場合、WAN 側にいる第三者サーバ S ( A, B 共にアクセス化 ) からは NAT-A, NAT-B の WAN 側 Port が見えるので、それを Peer 間で交換し合えば通信は可能となる。このやり方を Hole Punching と呼ぶ。

下記は UPD による Hole Punching の手順である。

  • 1. A, B が S へと UDP パケットを送る
    • この際、NAT-A は A に対し A-ext_IP:A-ext_Port を、NAT-B は B に対して B-ext_IP:B-ext_Port を割り当てる
  • 2. S は受け取った UDP パケットのヘッダから A-ext_IP:A-ext_PortB-ext_IP:B-ext_Port を知り、A-ext_IP:A-ext_Port を B へ、B-ext_IP:B-ext_Port を A へ送り返す
  • 3. A は教えられた B-ext_IP:B-ext_Port へ、B も教えられた A-ext_IP:A-ext_Port へ UPDパケットを送信
    • この際、NAT-A は A_IP → A-ext_IP:A-ext_Port → B-ext_IP:B-ext_Port、NAT-B は B_IP → B-ext_IP:B-ext_Port → A-ext_IP:A-ext_Port という変換テーブルを作る
  • 4. 3 で送った UDP パケットをお互いに受け取り、接続完了
    • パケットを受け取った際に、まだ X_IP → X-ext_IP:X-ext_Port → Y-ext_IP:Y-ext_Port のレコードが変換テーブルにない場合は、パケットはロストしてしまう
    • しかし、その反対方向は通るので問題ない

ただし、これが完全に機能するのは接続相手を強く限定しない Full-cone NAT と Restricted-cone NAT の場合のみ。
Restricted-cone NAT の場合、送信前に受信してしまうと 知らない相手だ と判断され NAT に一旦は破棄されるも、一度送った後は送信実績ありと判断され以降は受け付ける ( らしい )。

Symmetric NAT の場合は、送信先も含めての変換テーブルが構成されるので、S と B は別の Port が割り当てられ、そのままでは機能しない。
その場合の対処としては、Port がどの様なルールで割り当てられるのか ( インクリメント、リクエスト元と同じ、等々) を予測して、可能性がありそうな Port に向けて送り合うという方法が取られる。

TCP でも Hole Punching は可能だが成功率はグッと落ちるので、UDP の方がよく利用されている。
2005 年の論文だが、UDP の成功率は 82 % で、TCP の成功率は 64 % とのこと。
https://www.usenix.org/legacy/event/usenix05/tech/general/full_papers/ford/ford.pdf

Hole punching ( Networking ) - Wikipedia
UDP hole punching - Wikipedia
TCP hole punching - Wikipedia

2.4.2.2.1 STUN ( Session Traversal Utilities for NAT )

上記 UDP Hole Punching 手順の 1. の部分を担当する Protocol。
リクエスト元の、WAN 側から見える IP:Port を返すサーバの事を STUN Server と呼ぶ。

STUN - Wikipedia

2.4.2.2.2 TURN ( Traversal Using Relays around NAT )

2 つの Peer 間パケットを中継する Protocol。そもそも Hole Punching ではないが、STUN と併用されることが多いためここにまとめた。

WAN 側 ( 主に Internet ) で A, B 共にアクセス可能な場所に存在し、

  • A → B パケットを A → TURN Server → B
  • B → A パケットを B → TURN Server → A

と中継することで、A, B 間で通信できるようになる。
TURN の成功率は高く、ほとんどの NAT を通過することができるが、通信量がとても多く TURN サーバの負担が大きい。

Traversal Using Relays around NAT - Wikipedia

2.4.2.2.3 ICE ( Interactive Connectivity Establishment )

Peer 間で接続を確立するために、 STUN と TURN を調整する方法について規定している Protocol。
STUN で挑戦して、駄目なら TURN を利用する。

Interactive Connectivity Establishment - Wikipedia

2.4.3 実装

各言語の実装済みのモジュールは以下にまとめてある。現在は Go のみの実装のようだ。

NAT Traversal - libp2p.io/implementations

  • libp2p-nat

go-libp2p-nat では、内部的に go-nat が利用されており、go-nat は以下の方式を利用する。

  • UPnP IGD v1
  • UPnP IGD v2
  • NAT-PMP

Hole Punching にはまだ対応していない。
go-nut は、上記 3 つの方法を同時に試み、上手く言ったものを採用する。駄目だったらエラーを返す。

func DiscoverGateway() (NAT, error) {
    select {
    case nat := <-discoverUPNP_IG1():
        return nat, nil
    case nat := <-discoverUPNP_IG2():
        return nat, nil
    case nat := <-discoverNATPMP():
        return nat, nil
    case <-time.After(10 * time.Second):
        return nil, ErrNoNATFound
    }
}

2.4.4 自動検出

これは NAT Traversal とは直接関係はしない話。

ipfs の Peer が立ち上がった時に、自動で NAT 内にいるかどうかを確認できるようにしよう、という議論がなされている。
NAT Autodetection - Issues - Github

この中で、go-libp2p-autonat という実装が作られる事になっている。
libp2p-autonat-svc 機能を持つ Peer と接続し、WAN から見えているアドレスを受け取って NAT 内かどうかを確認しているようだ。

go-libp2p-autonat
go-libp2p-autonat-svc

現在、go-libp2p-autonat は relay-host 実装で利用されている。
起動時に NAT 検出を行い、NAT 内と分かれば Relay ( 後述 ) に切り替えるという用途で使われる。

2.4.5 UDP Hole Punching

現在は UPnP と NAT-PMP のみ実装済みだが、 UDP Hole Punching も実装中だ。
何故か libp2p-nat ではなく go-libp2p 本体上で実装が進められている。

Proposal for NAT UDP hole-punching - Issues - Github
[WIP] UDP-based NAT hole-punching - Pull Request - Github

2.5 Ralay

ある Peer が NAT や Firewall の背後にいる場合、他の Peer と直接 Link するのは難しい。
これは、共通の Transport を持たない Peer 同士でも同様だ。

このような場合に、Peer 同士の通信を中継する役割が、この Relay (Circuit Relay) だ。

Circuit Relay v0.1.0

libp2p は NAT Traversal 機能も有しているが必ず解決できるわけでない以上、フォールバックとして Relay は無ければならない。
これは、ICE Protocol が、STUN での Hole Punching を試し、駄目なら TURN での接続を試みるのと似ている。

Note :
Relay 関係の Protocol では、Node ( Peer ) を表すのに、/ipfs/Qm... ではなく /p2p/Qm... を使っているが、これは libp2p は ipfs だけでなく一般的な p2p システムを構築できるんだという事らしい。

2.5.1 Addressing

まずは、Relay を表すための Address 表現から。
Relay では、以下の形式で表現する。

<relay peer multiaddr>/p2p-circuit/<destination peer multiaddr>

peer multiaddr の部分には、p2p でカプセル化した形式で Peer ID が入る。

  • 特定の Relay を指定しないで任意の Relay を使う場合、省略可能
    • /p2p-circuit/p2p/QmTarget
    • QmTarget に複数の Relay が紐付いている場合の指定の仕方
  • 経路の Protocol を指定する
    • Dial -- Relay
    • /ip4/10.20.30.40/tcp/5001/p2p/QmRelay/p2p-circuit/p2p/QmTarget
    • Relay -- Target
    • /p2p/QmRelay/p2p-circuit/ip4/192.168.10.40/tcp/8000/p2p/QmTarget
  • Relay を複数通っていく場合
    • /p2p/QmRelayOne/p2p-circuit/p2p/QmRelayTwo/p2p-circuit/p2p/QmTarget
    • 現在は未実装

2.5.2 Circuit-Relay Mechanism

通常の Proxy の様に転送してしまうと、送る側は相手が分かっているが、受け取る側が誰から来たのか正確に分からなくなる。

Circuit Relay では、multiaddr をカプセル化して表現することで発信元情報 ( Peer ID ) は失わない。
それにより、multiaddr, PeerID, Transport, Connection 等の振る舞いを通常の接続とほとんど変えずに扱うことができる。

Circuit Relay が使われるユースケースとしては、

  • NAT Traversal できない NAT 内 Peer
  • ブラウザ上で動く Peer
    • Outbound は可能だが、不特定多数からの Inbound が通らない
  • Transport Protocol を共有しない Peer 間の仲介
    • IP Network に属さない Peer ( ex. Bluetooth ) を IP Network に仲介

実装

実装的には、Circuit は Transport として実装され、Swarm からは他の Transport と同じ様に利用できる。

libp2p/js-libp2p-circuit
libp2p/go-libp2p-circuit

Go の basic_host の場合、Relay Option を On にすると、libp2p-circuit Transport が Swarm に追加される。
Relay が On になった Peer が /p2p-circuit/p2p/Qm ~ に Dial する際、Swarm の中で mutiaddr Protocol を見て自動で libp2p-circuit Transport を選出し、勝手に利用するようになる。

2.5.3 Discovery

ある Peer が Relay Node を担う場合、他の Peer に『私は Relay です』 と宣言しなければ誰も繋いでこれない。
その Relay 宣言と、宣言済み Relay の検索を担当するのが libp2p-discover だ。

libp2p/interface-peer-discovery
libp2p/go-libp2p-discovery

Go では独立してパッケージ化されているが、Node.js では Routing パッケージと一緒になっている。

実態としては、DHT ( Distributed Hash Table ) に登録し Peer 全体で共有されることとなるが、詳細は次回にして以下では Relay に必要な部分のみまとめる。

2.5.3.1 Namespace

DHT は、いくつかの空間を持っていて、それらはみな Namespace で一意に特定できるようになっている。
Relay として宣言する場合には、/libp2p/relay Namespace に Peer を登録する。

2.5.3.2 Advertise

ある Peer が自ら『私は〇〇 Namespace に所属する Peer である』と宣言することを Advertise と呼ぶ。

他の Peer から Relay として認識されるためには、/libp2p/relay Namespace に所属すると宣言する必要がある。
go-libp2p-discovery では、宣言後は 24 時間は有効となる ( どの様に Refresh されるかまでは読めていない )。

2.5.3.3 FindPeers

Namespace 内に登録されている Peer を取得する。

通常は Limit をつけて取得 Peer 数を制限をする。
数ある Peer の中からどの様に選ばれるのか、多分次回には分かるはず。

2.5.4 実装

実装例として、Go の場合は go-libp2pRelay HostAutorelay Host が実装されている。

2.5.4.1 Relay Host

libp2p/go-libp2p/p2p/host/relay/relay.go

これは、Relay としての Host を実装している。コード内コメントでは Relay Hop services と呼ばれている。
インスタンス生成後に Advertise され、中継要求を待つ。

2.5.4.2 Autorelay Host

libp2p/go-libp2p/p2p/host/relay/relay.go

これは、Relay に繋ぐクライアント側を実装している。
インスタンス生成時には、

  1. 一定時間待つ
  2. その後、Listen する可能性のある multiaddr を全て集める。
    1. 生成時に指定した Local Address
    2. 1 の待ち時間の間に Dial された実績がある Address
    3. NAT Traversal して得た Global IP Address
  3. 2. で得た multiaddr 全てに対し autonat を使って WAN ( 主に Internet ) からアクセス可能かチェック
    1. Protocol は /libp2p/autonat/1.0.0
    2. この Protocol に対応している Peer のみ返信してくる
      1. その為、1. の待っている間に autonat service と Dial しておく必要がある
  4. 全てアクセス不可の場合は、Discover を利用し Relay 宣言している Peer リストを取得
  5. 取得した Relay 達に接続を試みる
  6. 接続できたら、Listen Address を /p2p-circuit/p2p でカプセル化した multiaddr に変更
  7. 既に接続している Peer へ Listen Address が変わった事を通知

という感じに進む。

テストコード確認

実際にテストコードを見ながら確認する。
go-libp2p にある autorelay_test.go を参考にする。

func TestAutoRelay(t *testing.T) {
  ...
  // 0. 事前に待ち時間などを調整
  func init() {
    autonat.AutoNATIdentifyDelay = 500 * time.Millisecond
    autonat.AutoNATBootDelay = 1 * time.Second
    relay.BootDelay = 1 * time.Second
    relay.AdvertiseBootDelay = 1 * time.Millisecond
    manet.Private4 = []*net.IPNet{}
  }
  ...

  // 1.  ここで relay host を作っている
  //    ついでに、autonat-svc 機能も持たせていて、常に 『君は NAT の中に居るよ』と返事をするように実装されている
  h1 := makeAutoNATServicePrivate(ctx, t)
  _, err := libp2p.New(ctx, libp2p.EnableRelay(circuit.OptHop), libp2p.EnableAutoRelay(), libp2p.Routing(makeRouting))
  if err != nil {
    t.Fatal(err)
  }

  // 2. ここで autorelay host を作っている
  //    内部の autonat はまだ待ち状態 
  h3, err := libp2p.New(ctx, libp2p.EnableRelay(), libp2p.EnableAutoRelay(), libp2p.Routing(makeRouting))
  if err != nil {
    t.Fatal(err)
  }

  // 3. target host を作成
  h4, err := libp2p.New(ctx, libp2p.EnableRelay())

  // 4. この段階で、autorelay host のアドレスが `/p2p-circuit/p2p/Qm ~` になっていない事を確認
  for _, addr := range h3.Addrs() {
    _, err := addr.ValueForProtocol(circuit.P_CIRCUIT)
    if err == nil {
      t.Fatal("relay addr advertised before auto detection")
    }
  }

  // 5. relay host と autorelay host が接続
  // sleep の間に autonat がチェックを始める
  // 既に relay host 兼 autonat service と繋がっているため、そこから『NAT 内だよ』と返事が返ってくる
  // その後は Relay 接続の手続きへ進み、autorelay host が relay host を経由できる状態となる
  connect(t, h1, h3)
  time.Sleep(3 * time.Second)

  // 6. autorelay host の Listen Address が `/p2p-circuit/p2p/Qm ~` になっている事を確認
  unspecificRelay, err := ma.NewMultiaddr("/p2p-circuit")
  if err != nil {
    t.Fatal(err)
  }

  haveRelay := false
  for _, addr := range h3.Addrs() {
    if addr.Equal(unspecificRelay) {
      t.Fatal("unspecific relay addr advertised")
    }

    _, err := addr.ValueForProtocol(circuit.P_CIRCUIT)
    if err == nil {
      haveRelay = true
    }
  }

  if !haveRelay {
    t.Fatal("No relay addrs advertised")
  }

  // 7. target host と autorelay host を接続
  // target host が接続する multiaddr は、6 で確認した relay 経由のものとなっている
  var raddrs []ma.Multiaddr
  for _, addr := range h3.Addrs() {
    _, err := addr.ValueForProtocol(circuit.P_CIRCUIT)
    if err == nil {
      raddrs = append(raddrs, addr)
    }
  }

  err = h4.Connect(ctx, pstore.PeerInfo{ID: h3.ID(), Addrs: raddrs})
  if err != nil {
    t.Fatal(err)
  }
  ...
}

Relay の感じは掴めた。

2.6 Network まとめ

これで、大方の Network に関する部分は分かった。
次回は、DHT はどの様に構築され、どの様な Topology を形成し、どうやってルートを決めるのかという部分に進んで行く。
ここまでで相当時間がかかってしまっているが、P2P システムとしてはここからが本番のはずなので、もう少し頑張ろう。

参考

57
48
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
57
48