こんにちは. 新型コロナの影響で外出を控えているのですが,ずっと自宅にいるのも結構しんどいですね. この前見始めた鬼滅の刃ももう見終わってしまいました.続きが気になります. さて,今回もネットワークシリーズです.

# ネットワークを作って理解する

ネットワークの仕組みを理解するためには作ってみるのが一番ということでプロトコルスタックを自作してみます. 前回はEthernetを実装してみました.前回のポストはこちら (opens new window) 今回はデータリンク層のアドレスとネットワーク層のアドレスを解決するARPを実装してみたいと思います.使用言語はgolangです.

# 開発環境

開発環境は前回と同様で以下の通りです.ioctlなどのシステムコールを扱うためprivilegeオプションを有効にしたLinuxコンテナを作成してプログラムをビルドします. また,実行はコンテナの中でネットワーク名前空間を分離して行います.

  • Mac OS Catalina
  • VSCode
  • Docker version 19.03.5, build 633a0e

# 仕様とRFC826 ARP

ARPの仕様はRFC826 (opens new window)に定義されています. ARPの役割はデータリンク層の物理アドレスとネットワーク層のアドレスt(IPアドレスなど)を解決することです.詳細はRFCを読んでみてください.

ARPはrequestreplyの二つのオペレーションから構成されています. 各端末がrequestとreplyを送受信することで物理アドレスと論理アドレスを対応づけます. ARPでの通信が行われる際は当然まだ論理アドレスでの通信はできないため物理アドレスを用いて通信を行います. パケットフォーマットは以下の通りです. arp-format

# 動作

各端末はARPパケットを受信したらまず受信パケットの以下のフィールドを調べます.

  • ハードウェアタイプ
  • ハードウェアアドレス長
  • プロトコルアドレス長 次に調査したアドレスが自身の変換テーブルに登録されいるかを調べます. 既に登録されている場合,テーブルの内容を更新します. 登録されていない場合テーブルに新しく情報を追加します. 次に,パケットのオペレーションコードを調べてRequestの場合は自分の物理アドレスと論理アドレスをパケットにセットしてReplyパケットを作成して返信します.

# ARP Request

Requestは通信を行いたい相手の物理アドレスを知りたい時にネットワーク内にブロードキャストされます. arp-request

# ARP Relpy

Replyは受信したRequestに応答するために送信されます. Requestパケットにセットされていた論理アドレスを持つホストによってRequestを送信したホストに向けて送信されます.Replyパケットには自身の物理アドレスがセットされます. arpreply

これにより各端末は同じネットワーク内の各端末の物理アドレスと論理アドレスを対応づけることができるようになります.

# 実装

リポジトリはこちら (opens new window)

というわけで実際に作ってみます.

# ARPパケットフォーマット

ARPパケットのフォーマット構造体です. ARPPacket構造体のHardware AddressとProtocol Addressフィールドはネットワーク内で使用されるプロトコルによってアドレス長が変化するので[]byteを使用します. ほとんどの場合がMAC AddressとIPv4アドレスですが,Ipv6アドレスの可能性も考えられます.(今回の実装ではEthernetとIPv4のみですが)

type ARPHeader struct {
	HardwareType HardwareType
	ProtocolType ProtocolType
	HardwareSize uint8
	ProtocolSize uint8
	OpCode       OperationCode
}
type ARPPacket struct {
	Header                ARPHeader
	SourceHardwareAddress []byte
	SourceProtocolAddress []byte
	TargetHardwareAddress []byte
	TargetProtocolAddress []byte
}
type HardwareType uint16
type ProtocolType uint16
type OperationCode uint16
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# パケットのパース

bytesパッケージを使用してパケットをパース・シリアライズします. 先にヘッダをパースしてアドレス長を取得することでパケットの各フィールドを長さを指定してスライスを初期化して,その後各フィールドをReadします.

arpPacket := &ARPPacket{
		Header:                *arpHeader,
		SourceHardwareAddress: make([]byte, arpHeader.HardwareSize),
		SourceProtocolAddress: make([]byte, arpHeader.ProtocolSize),
		TargetHardwareAddress: make([]byte, arpHeader.HardwareSize),
		TargetProtocolAddress: make([]byte, arpHeader.ProtocolSize),
    }
if err := binary.Read(buf, binary.BigEndian, arpPacket.SourceHardwareAddress); err != nil {
		return nil, err
	}
1
2
3
4
5
6
7
8
9
10

シリアライズする際は特に気にすることなくbytes.Write()します.便利.

# パケットを生成する

ARPではRequestとReplyの二つのタイプがあるのでRequest関数とReply関数を用意します.

func Request(srcHardwareAddress, srcProtocolAddress, targetProtocolAddress []byte, protocolType ProtocolType) (*ARPPacket, error) {
	var protocolSize uint8
	switch protocolType {
	case PROTOCOL_IPv4:
		protocolSize = uint8(4)
	case PROTOCOL_IPv6:
		protocolSize = uint8(16)
	default:
		return nil, fmt.Errorf("invalid protocol")
	}
	header := ARPHeader{
		HardwareType: HARDWARE_ETHERNET,
		ProtocolType: protocolType,
		HardwareSize: uint8(6),
		ProtocolSize: protocolSize,
		OpCode:       ARP_REQUEST,
	}
	return &ARPPacket{
		Header:                header,
		SourceHardwareAddress: srcHardwareAddress,
		SourceProtocolAddress: srcProtocolAddress,
		TargetHardwareAddress: ethernet.BroadcastAddress[:],
		TargetProtocolAddress: targetProtocolAddress,
	}, nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func Reply(srcHardwareAddress, srcProtocolAddress, targetHardwareAddress, targetProtocolAddress []byte, protocolType ProtocolType) (*ARPPacket, error) {
	var protocolSize uint8
	switch protocolType {
	case PROTOCOL_IPv4:
		protocolSize = uint8(4)
	case PROTOCOL_IPv6:
		protocolSize = uint8(16)
	default:
		return nil, fmt.Errorf("invalid protocol")
	}
	header := ARPHeader{
		HardwareType: HARDWARE_ETHERNET,
		ProtocolType: protocolType,
		HardwareSize: uint8(6),
		ProtocolSize: protocolSize,
		OpCode:       ARP_REPLY,
	}
	return &ARPPacket{
		Header:                header,
		SourceHardwareAddress: srcHardwareAddress,
		SourceProtocolAddress: srcProtocolAddress,
		TargetHardwareAddress: targetHardwareAddress,
		TargetProtocolAddress: targetProtocolAddress,
	}, nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# ARPテーブル

ARPテーブルに各ホストの論理アドレスと物理アドレスのペアを保存する構造体です. Entry構造体に物理アドレスと論理アドレスのペアを格納し,ARPTable構造体のEntrys(複数形間違ってますね笑)フィールドに格納されます. また,ARPTableは複数のgoroutineから参照されるためsync.Mutexをフィールドに持たせています.

type Entry struct {
	HardwareAddress []byte
	ProtocolAddress []byte
	ProtocolType    ProtocolType
	TimeStamp       time.Time
}
type ARPTable struct {
	Entrys []*Entry
	Mutex  sync.RWMutex
}
1
2
3
4
5
6
7
8
9
10
11

テーブル操作の一例としてUpateメソッドを示します.他のメソッドも同様にEntrysフィールドから目的のものを走査しています.Mutex.Lock,Mutex.Unlockをしっかりしましょう.

func (at *ARPTable) Update(hwaddr, protoaddr []byte) (bool, error) {
	at.Mutex.Lock()
	defer at.Mutex.Unlock()
	for _, e := range at.Entrys {
		if bytes.Equal(e.ProtocolAddress, protoaddr) {
			e.HardwareAddress = hwaddr
			e.TimeStamp = time.Now()
			return true, nil
		}
	}
	return false, nil
}
1
2
3
4
5
6
7
8
9
10
11
12

# ARPパケットを処理する

パケット,テーブルの用意ができたのでARPプロトコルを処理するパートを実装します. まずはARP型を定義します. フィールドにはARPテーブルとDevice型を持ちます.これはARPがデータリンク層で通信を行うためです.

type ARP struct {
	HardwareType ethernet.EtherType
	Table        *arp.ARPTable
	Dev          Device
}
1
2
3
4
5

ARP型はLinkNetProtocolインターフェースを満たすためLinkNetProtocol型として振舞うことができます.そのため,前回の記事 (opens new window)で紹介したDevice型を満たす型のregisteredProtocolフィールドに登録することができます.LinkNetProtocolインターフェースは以下のように定義されています.

type LinkNetProtocol interface {
	Type() ethernet.EtherType
	Handle(data []byte) error
	Write(dst []byte, protocol interface{}, data []byte) (int, error)
}
1
2
3
4
5

HandleメソッドがARPの具体的な処理を担います. 動作に記述したような処理ですね.

func (a *ARP) Handle(data []byte) error {
	packet, err := arp.NewARPPacket(data)
	if err != nil {
		return fmt.Errorf("failed to create ARP packet")
	}
	if packet.Header.HardwareType != arp.HARDWARE_ETHERNET {
		return fmt.Errorf("invalid hardware type")
	}
	if packet.Header.ProtocolType != arp.PROTOCOL_IPv4 && packet.Header.ProtocolType != arp.PROTOCOL_IPv6 {
		return fmt.Errorf("invalid protocol type")
	}
	mergeFlag, err := a.Table.Update(packet.SourceHardwareAddress, packet.SourceProtocolAddress)
	if err != nil {
		return err
	}
	if bytes.Equal(packet.TargetProtocolAddress, a.Dev.IPAddress().Bytes()) {
		if !mergeFlag {
			err := a.Table.Insert(packet.SourceHardwareAddress, packet.SourceProtocolAddress, packet.Header.ProtocolType)
			if err != nil {
				return fmt.Errorf("Failed to insert: %v", err)
			}
		}
		if packet.Header.OpCode == arp.ARP_REQUEST {
			err := a.ARPReply(packet.SourceHardwareAddress, packet.SourceProtocolAddress, packet.Header.ProtocolType)
			if err != nil {
				return err
			}
		}
	}
	return nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

# 実験

一通りの処理を実装し終えたので実際に実行して実験を行います. 実行にはDockerを使用します.

# 準備

実験するネットワーク環境と実験コードを用意します.

# 実験ネットワーク環境

docker-compose.yamlを用意しているのでコンテナを起動した後コンテナに入って./script/arp-setup.shを実行します.ファイルの中身は以下です.

#! /bin/bash
# 
#  -------                                   -------
#  |host1|host1_veth0 <---------> host2_veth0|host2|
#  ------- 192.168.0.2/24     192.168.0.3/24 -------
#
ip netns add host1
ip netns add host2
ip link add host1_veth0 type veth peer host2_veth0
ip link set host1_veth0 netns host1
ip link set host2_veth0 netns host2
ip netns exec host1 ip addr add 192.168.0.2/24 dev host1_veth0
ip netns exec host2 ip addr add 192.168.0.3/24 dev host2_veth0
ip netns exec host1 ip link set lo up
ip netns exec host2 ip link set lo up
ip netns exec host1 ip link set host1_veth0 up
ip netns exec host2 ip link set host2_veth0 up
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

ip netnsコマンドでネットワーク名前空間を分けています. ip netnsを使用することで柔軟なネットワーク実験が行えます.すごい便利です. というわけで実験の準備が整いました.

# 実験コード

実験コードはこちら.

func TestARPHandler(t *testing.T) {
	dev, err := NewDevicePFPacket("host1_veth0", 1500)
	if err != nil {
		t.Fatal(err)
	}
	dev.RegisterNetInfo("192.168.0.2/24")
	arp := NewARP(dev)
	err = dev.RegisterProtocol(arp)
	if err != nil {
		t.Fatal(err)
	}
	dev.DeviceInfo()
	defer dev.Close()
	go dev.Handle()
	dev.Next()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 実行

今回はhost1で作成したプログラムを実行します. 次のコマンドを実行します.

ip netns exec host1 go test -run TestARPHandle
1

すると,こんな感じで表示されます.

[root@13eca954d7e7 net]# ip netns exec host1 go test -run TestARPHandle
----------device info----------
name:  host1_veth0
fd =  3
hardware address =  aa:5d:24:9d:c7:d2
packet handling start
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
---------------arp---------------
hardware type = 01
protocol type = 800
hardware address size = 06
protocol address size = 04
operation code = (REQUEST)
src hwaddr = 7e:37:27:b1:cc:91
src protoaddr = 192.168.0.3
target hwaddr = 00:00:00:00:00:00
target protoaddr = 192.168.0.2
[info]reply send >>
---------------arp table---------------
hwaddr= 7e:37:27:b1:cc:91
protoaddr=192.168.0.3
time=2020-04-01 12:27:51.7183355 +0000 UTC m=+9.093416001
---------------------------------------
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
---------------arp---------------
hardware type = 01
protocol type = 800
hardware address size = 06
protocol address size = 04
operation code = (REPLY)
src hwaddr = aa:5d:24:9d:c7:d2
src protoaddr = 192.168.0.2
target hwaddr = 7e:37:27:b1:cc:91
target protoaddr = 192.168.0.3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

192.168.0.3(host2)からARPリクエストが飛んできていることがわかります. また,パケットの内容をARPテーブルに保存して,リプライパケットを送信しているのがわかります. いい感じに動作しているようです.

# まとめ

今回は前回のEthernetに引き続きARPを実装してみました. ARPがMACアドレスとIPアドレスを解決するためのプロトコルであることは理解していましたが,実際に実装してみることで詳しい処理の内容やパケットの詳しい構成を理解することができて勉強になりました. また,RFCを読みながら実装するのも勉強になりますね. 次回はIPv4編を書きたいです. 新型コロナ早く終息して欲しいですね.

# 参考