🍻

golangで作るTCPIPプロトコル

2022/03/21に公開約27,100字

はじめに

とりあえずIT業界に入ったら読んでおけという名著はいろいろありますが、その中の1冊がマスタリングTCP/IP入門編でしょう。
僕も買ってはいたものの読むのを途中で挫折していたので、今回しっかり読んでTCP/IPを再勉強してみたいと思います。

マスタリングTCP/IPを読みながらその他わからんことはググりつつ、golangでTCPIPプロトコルそのものを自作してみます。
方針は以下のようにします。

  • ethernetから作る
  • データのやり取りにnetパッケージは一切使わない
    (訂正、PCのIPやMacアドレスを取るのにだけ使用しますた)
  • データのやり取りに使うのはsyscallのsendtoとrecvfromだけ
  • socketはRAW_SOCKETを使う

golangやネットワークについても初心者の駆け出しですので間違えや実装ミス、変なコードがあるかもですが、生暖かい目でよろしゅうお願いします。🙇‍♂️🙇‍♂️🙇‍♂️
TCP/IP自体の解説はあまりせず作ったコードの解説が主なのでそのへんもご了承ください。コードは以下にあります。

https://github.com/sat0ken/go-tcpip

Ethernet

まずは"アプセトネデブ"で言うところの"デ"=データリンク層、L2ですね。
ここから作っていきます。

Ethernetのフレームを構造体で定義します。

type EthernetFrame struct {
	DstMacAddr    []byte
	SourceMacAddr []byte
	Type          []byte
}

Ethernetフレームは宛先のMacアドレスが、自分のMacアドレス、タイプからなります。
Macアドレスが6byte、タイプが2byteなので合計すると長さは14byteになります。

構造体をセットする関数を用意しておきます。

func NewEthernet(dstMacAddr, sourceMacAddr []byte, ethType string) EthernetFrame {
	ethernet := EthernetFrame{
		//宛先のMac Addressをセット
		DstMacAddr: dstMacAddr,
		//PCのMac Addressをセット
		SourceMacAddr: sourceMacAddr,
	}
	// https://www.infraexpert.com/study/ethernet4.html
	switch ethType {
	case "IPv4":
		// 0800 = IPv4
		ethernet.Type = IPv4
	case "ARP":
		// 0806 = ARP
		ethernet.Type = ARP
	}
	return ethernet
}

IP

"デ"が出来たので次は"ネ"、ネットワーク層です。
ここではARP, IP, ICMPプロトコルを定義してPingをしてみます。

まずはARPを定義します。
ARPのフォーマットに従って構造体を定義します。

type Arp struct {
	HardwareType  []byte
	ProtocolType  []byte
	HardwareSize  []byte
	ProtocolSize  []byte
	Opcode        []byte
	SenderMacAddr []byte
	SenderIpAddr  []byte
	TargetMacAddr []byte
	TargetIpAddr  []byte
}

定義したらARP構造体を作成する関数を用意しておきます。

func NewArpRequest(localif LocalIpMacAddr, targetip string) Arp {
	return Arp{
		// イーサネットの場合、0x0001で固定
		HardwareType: []byte{0x00, 0x01},
		// IPv4の場合、0x0800で固定
		ProtocolType: []byte{0x08, 0x00},
		// MACアドレスのサイズ(バイト)。0x06
		HardwareSize: []byte{0x06},
		// IPアドレスのサイズ(バイト)。0x04
		ProtocolSize: []byte{0x04},
		// ARPリクエスト:0x0001
		Opcode: []byte{0x00, 0x01},
		// 送信元MACアドレス
		SenderMacAddr: localif.LocalMacAddr,
		// 送信元IPアドレス
		SenderIpAddr: localif.LocalIpAddr,
		// ターゲットMACアドレス broadcastなのでAll zero
		TargetMacAddr: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
		// ターゲットIPアドレス
		TargetIpAddr: iptobyte(targetip),
	}
}

EthernetとARPができたので、送る関数をSocketを使用して作ります。
addrには syscall.SockaddrLinklayerでデータリンク層にARPパケット出すようにします。
socketにはAF_PACKETを指定してRaw Socketで送れるようにソケット用のファイルディスクリプタ(以下よりfdとする)を作成します。

fdを作成したら、パケットのbyteデータをSendtoで送ります。

SendtoでARPリクエストを送ったら、ReplyをRecvfromで受け取ります。
受け取ったら、byteデータをARP構造体にUnpackして返します。

func (*Arp) Send(ifindex int, packet []byte) Arp {
	addr := syscall.SockaddrLinklayer{
		Protocol: syscall.ETH_P_ARP,
		Ifindex:  ifindex,
		Hatype:   syscall.ARPHRD_ETHER,
	}
	sendfd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(htons(syscall.ETH_P_ALL)))
	if err != nil {
		log.Fatalf("create sendfd err : %v\n", err)
	}
	defer syscall.Close(sendfd)

	err = syscall.Sendto(sendfd, packet, 0, &addr)
	if err != nil {
		log.Fatalf("Send to err : %v\n", err)
	}

	for {
		recvBuf := make([]byte, 80)
		_, _, err := syscall.Recvfrom(sendfd, recvBuf, 0)
		if err != nil {
			log.Fatalf("read err : %v", err)
		}
		// EthernetのTypeがArpがチェック
		if recvBuf[12] == 0x08 && recvBuf[13] == 0x06 {
			// ArpのOpcodeがReplyかチェック
			if recvBuf[20] == 0x00 && recvBuf[21] == 0x02 {
				return parseArpPacket(recvBuf[14:])
			}
		}
	}
}

Sendを作ったので実際にARPパケットを出してみます。
これまで作った関数と構造体をまとめます。

Ethernetのパケットを作る時にARPリクエストをBroadcastするので宛先のMacアドレスにはffをセットしています。
Arpのパケットを作る時にMacアドレスを知りたいIPアドレスをセットします。

EthとArpの構造体を作ったらそれをbyteデータにして送信します。

func arp() {
	localif, err := getLocalIpAddr("wlp4s0")
	if err != nil {
		log.Fatalf("getLocalIpAddr err : %v", err)
	}

	ethernet := NewEthernet([]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, localif.LocalMacAddr, "ARP")
	arpReq := NewArpRequest(localif, "192.168.0.17")

	var sendArp []byte
	sendArp = append(sendArp, toByteArr(ethernet)...)
	sendArp = append(sendArp, toByteArr(arpReq)...)

	arpreply := arpReq.Send(localif.Index, sendArp)
	fmt.Printf("ARP Reply : %s\n", printByteArr(arpreply.SenderMacAddr))
}

結果がちゃんと返ってきました。

$ sudo ./tcpip 
ARP Reply : b8 27 eb e1 65 e3

Macアドレスを確認したIPアドレスは手元で動いていたラズパイです。
ログインしてMacアドレスを見てみると、返ってきた結果と一致していますね。

pi@raspberrypi:~ $ ip addr show eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether b8:27:eb:e1:65:e3 brd ff:ff:ff:ff:ff:ff
    inet 192.168.0.17/24 brd 192.168.0.255 scope global noprefixroute eth0
       valid_lft forever preferred_lft forever

次はIPプロトコルです。
formatに従い構造体を定義します。
IPヘッダの長さは20byteになります。

https://www.infraexpert.com/study/tcpip1.html
type IPHeader struct {
	VersionAndHeaderLenght []byte
	ServiceType            []byte
	TotalPacketLength      []byte
	PacketIdentification   []byte
	Flags                  []byte
	TTL                    []byte
	Protocol               []byte
	HeaderCheckSum         []byte
	SourceIPAddr           []byte
	DstIPAddr              []byte
}

構造体を作る関数も用意しておきます。
上位プロトコルのLength、Checksumはいったん0にしておいて後から埋めます。
使用する上位プロトコルによりセットするプロトコルを切り替えられるようにしておきます。

func NewIPHeader(sourceIp, dstIp []byte, protocol string) IPHeader {

	ip := IPHeader{
		VersionAndHeaderLenght: []byte{0x45},
		ServiceType:            []byte{0x00},
		TotalPacketLength:      []byte{0x00, 0x00},
		PacketIdentification:   []byte{0x00, 0x00},
		Flags:                  []byte{0x40, 0x00},
		TTL:                    []byte{0x40},
		HeaderCheckSum:         []byte{0x00, 0x00},
		SourceIPAddr:           sourceIp,
		DstIPAddr:              dstIp,
	}

	switch protocol {
	case "IP":
		ip.Protocol = []byte{0x01}
	case "UDP":
		ip.Protocol = []byte{0x11}
	case "TCP":
		ip.Protocol = []byte{0x06}
	}
	
	return ip
}

IPができたので次はICMPを作ってみます。pingコマンドでおなじみのものです。
まずICMP用の構造体を定義します。

type ICMP struct {
	Type           []byte
	Code           []byte
	CheckSum       []byte
	Identification []byte
	SequenceNumber []byte
	Data           []byte
}

構造体を作る関数も用意しておきます。

func NewICMP() ICMP {
	// https://www.infraexpert.com/study/tcpip4.html
	icmp := ICMP{
		// ping request
		Type:           []byte{0x08},
		Code:           []byte{0x00},
		CheckSum:       []byte{0x00, 0x00},
		Identification: []byte{0x00, 0x10},
		SequenceNumber: []byte{0x00, 0x01},
		Data:           []byte{0x01, 0x02},
	}

	icmpsum := sumByteArr(toByteArr(icmp))
	icmp.CheckSum = checksum(icmpsum)

	return icmp
}

ICMPパケットを送る関数を用意します。
SocketはARPを送る関数と同じAF_PACKETなので省略します。

Recvfromのところで、Ethernet(14byte), IPヘッダ(20byte)の後ろにICMPパケットと続くので34byte目からUnpackして戻します。

	for {
		recvBuf := make([]byte, 1500)
		_, _, err := syscall.Recvfrom(sendfd, recvBuf, 0)
		if err != nil {
			log.Fatalf("read err : %v", err)
		}
		// IPヘッダのProtocolがICMPであることをチェック
		if recvBuf[23] == 0x01 {
			// Ethernetが14byte, IPヘッダが20byteなので34byte目からがICMPパケット
			return parseICMP(recvBuf[34:])
		}
	}

Sendを作ったのでこれまでの関数と構造体をまとめてPingを作ります。
こんな感じにしてみました。

func sendArpICMP(destip string) {

	localif, err := getLocalIpAddr("wlp4s0")
	if err != nil {
		log.Fatalf("getLocalIpAddr err : %v", err)
	}

	// ARPのパケットを作る
	var arp Arp
	arp = NewArpRequest(localif, destip)

	var sendArp []byte
	// Ethernetのパケットを作る
	sendArp = append(sendArp, toByteArr(NewEthernet([]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, localif.LocalMacAddr, "ARP"))...)
	sendArp = append(sendArp, toByteArr(arp)...)

	// ARPを送る
	arpreply := arp.Send(localif.Index, sendArp)
	fmt.Printf("ARP Reply : %s\n", printByteArr(arpreply.SenderMacAddr))

	var sendIcmp []byte
	// ICMPパケットを作る
	icmpPacket := NewICMP()
	// IPヘッダを作る
	header := NewIPHeader(localif.LocalIpAddr, iptobyte(destip), "IP")
	// IPヘッダの長さとICMPパケットの長さの合計をIPヘッダのLengthにセットする
	header.TotalPacketLength = uintTo2byte(toByteLen(header) + toByteLen(icmpPacket))

	// チェックサムを計算する
	ipsum := sumByteArr(toByteArr(header))
	header.HeaderCheckSum = checksum(ipsum)

	// Ethernet, IPヘッダ, ICMPパケットの順序でbyteデータにする
	sendIcmp = append(sendIcmp, toByteArr(NewEthernet(arpreply.SenderMacAddr, localif.LocalMacAddr, "IPv4"))...)
	sendIcmp = append(sendIcmp, toByteArr(header)...)
	sendIcmp = append(sendIcmp, toByteArr(icmpPacket)...)

	// ICMPパケットを送る
	icmpreply := icmpPacket.Send(localif.Index, sendIcmp)
	if icmpreply.Type[0] == 0 {
		fmt.Printf("ICMP Reply is %d, OK!\n", icmpreply.Type[0])
	}
}

これを動かしてみましょう。
echo replyの0が返ってきてるので正常に動作していますね。

$ sudo ./tcpip
ARP Reply : b8 27 eb e1 65 e3 
send icmp packet
ICMP Reply is 0, OK!

対向のラズパイでtcpdumpするとicmpがちゃんと飛んできています。

pi@raspberrypi:~ $ sudo tcpdump icmp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
20:52:57.721117 IP 192.168.0.6 > 192.168.0.17: ICMP echo request, id 16, seq 1, length 10
20:52:57.721254 IP 192.168.0.17 > 192.168.0.6: ICMP echo reply, id 16, seq 1, length 10

閑話休題、チェックサムの計算方法

IPヘッダにはIPヘッダが壊れていないことを示すためにチェックサムを計算してセット必要があります。
マスタリングTCP/IPのP.180に以下のように説明が書いてあります。

「チェックサムの計算は、まずチェックサムのフィールドを0にして、16bit単位で1の補数の和を求めます。
 そして求まった値の1の補数をチェックサムフィールドに入れます。」

最初読んだ時はマジで意味不明でした、こいつ何言ってるの?みたいなですね(恥ずかしい限りですが🤦‍♂️🤦‍♂️🤦‍♂️)
いろいろググって以下のページを見てようやっと計算方法がわかりました。

https://o21o21.hatenablog.jp/entry/2019/01/31/120436

① 16bit=2byteずつ足してIPヘッダの合計値を出す
② 16bitオーバーした値、↑のページの例だと1f7ddの1をf7ddに足す
③ f7dd+1をbit反転する

1~3の処理をするために以下のようにします。
まずIPヘッダ内のフィールドのbyteを2byteずつ合計します。

toByteArrで構造体内の各フィールドが持つbyteを1つのbyte配列にまとめます。
sumByteArrでまとめたbyte配列を2byteずつ整数にして合計値を出します。

ipsum := sumByteArr(toByteArr(header))

チェックサムを計算してIPヘッダにセットします。

icmp.CheckSum = checksum(icmpsum)

チェックサムを計算する関数は以下のようになりました。
コメントにメモしてあるリンクはTCPのチェックサムを計算する方法の解説ですが、IPヘッダのチェックサムでも計算方法は同じです。

func checksum(sum uint) []byte {
	// https://el.jibun.atmarkit.co.jp/hiro/2013/07/tcp-f933.html
	// 22DA6 - 20000 + 2 = 2DA8となり、2DA8をビット反転
	val := sum - (sum>>16)<<16 + (sum >> 16) ^ 0xffff
	return uintTo2byte(uint16(val))
}

UDP

さてネットワーク層のプロトコルは出来たので次はトランスポート層に入ります。
TCPを作る前に簡単なUDPを処理してしまいます。

UDP用の構造体を定義します。
ダミーヘッダーはUDPのチェックサムを計算する時に使われるものです、いっしょに定義しておきます。

https://www.infraexpert.com/study/tcpip12.html
type UDPHeader struct {
	SourcePort  []byte
	DestPort    []byte
	PacketLenth []byte
	Checksum    []byte
}

type UDPDummyHeader struct {
	SourceIPAddr []byte
	DstIPAddr    []byte
	Protocol     []byte
	Length       []byte
}

UDPの構造体を作る関数を用意します。

func NewUDPHeader(sourceport, destport []byte) UDPHeader {
	return UDPHeader{
		SourcePort:  sourceport,
		DestPort:    destport,
		PacketLenth: []byte{0x00, 0x00},
		Checksum:    []byte{0x00, 0x00},
	}
}

func NewUDPDummyHeader(header IPHeader) UDPDummyHeader {
	return UDPDummyHeader{
		SourceIPAddr: header.SourceIPAddr,
		DstIPAddr:    header.DstIPAddr,
		Protocol:     []byte{0x00, 0x11},
		Length:       []byte{0x00, 0x00},
	}
}

UDPを送るのはこれまでARPやICMPを送ってきた処理からRecvfromを抜いたSendtoだけのものになるので省略します。
コネクションレスで信頼性がないと言われる理由ですね。

ではこれまでの処理をまとめてUDPパケットを送ってみます。

func udpSend() {
	localmac := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}

	localif, err := getLocalIpAddr("lo")
	if err != nil {
		log.Fatalf("getLocalIpAddr err : %v", err)
	}
	
	ipheader := NewIPHeader(localif.LocalIpAddr, localif.LocalIpAddr, "UDP")

	//var udp UDPHeader
	udpheader := NewUDPHeader(uintTo2byte(42279), uintTo2byte(12345))
	udpdata := []byte(`hogehoge`)

	ipheader.TotalPacketLength = uintTo2byte(uint16(20) + toByteLen(udpheader) + uint16(len(udpdata)))
	udpheader.PacketLenth = uintTo2byte(toByteLen(udpheader) + uint16(len(udpdata)))

	// IPヘッダのチェックサムを計算する
	ipsum := sumByteArr(toByteArr(ipheader))
	ipheader.HeaderCheckSum = checksum(ipsum)

	dummyHeader := NewUDPDummyHeader(ipheader)
	dummyHeader.Length = udpheader.PacketLenth

	sum := sumByteArr(toByteArr(dummyHeader))
	sum += sumByteArr(toByteArr(udpheader))
	sum += sumByteArr(udpdata)

	// UDPヘッダ+データのチェックサムを計算する
	udpheader.Checksum = checksum(sum)

	var packet []byte
	packet = append(packet, toByteArr(NewEthernet(localmac, localmac, "IPv4"))...)
	packet = append(packet, toByteArr(ipheader)...)
	packet = append(packet, toByteArr(udpheader)...)
	packet = append(packet, udpdata...)

	udpheader.Send(packet)
}

動かしてみます。

$ sudo ./tcpip
UDP packet send

localでtcpdumpするとUDPパケットが確認できましたのでよさそうです。

$ sudo tcpdump udp -i lo port 12345 -nn -vv
tcpdump: listening on lo, link-type EN10MB (Ethernet), capture size 262144 bytes
22:21:19.212759 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto UDP (17), length 36)
    127.0.0.1.42279 > 127.0.0.1.12345: [udp sum ok] UDP, length 8

TCP

さていよいよTCPです。
curlでnginxにHTTPリクエストを送った時にWiresharkでパケットを見ると以下のようになります。

このWiresharkで見るcurlの処理を今回は全部オレオレ実装します。
TCPが一番面倒くさかったです、シーケンス番号やハンドシェイクなど。
まぁ面倒くさいがゆえにデータがちゃんと送れる信頼性を担保してくれているわけですが笑

僕の愚痴はさておき、TCPヘッダとダミーヘッダの構造体から定義していきます。
TCPのオプションもコードでは定義していますが、実際には使っていません。

https://www.infraexpert.com/study/tcpip8.html
type TCPHeader struct {
	SourcePort       []byte
	DestPort         []byte
	SequenceNumber   []byte
	AcknowlegeNumber []byte
	HeaderLength     []byte
	ControlFlags     []byte
	WindowSize       []byte
	Checksum         []byte
	UrgentPointer    []byte
	TCPOptionByte    []byte
	TCPData          []byte
}

type TCPDummyHeader struct {
	SourceIPAddr []byte
	DstIPAddr    []byte
	Protocol     []byte
	Length       []byte
}

構造体を作る関数を用意します。
constでtcpのflag値を定義しておいて、switchでそれをセットするようにしています。

func NewTCPHeader(sourceport, destport []byte, tcpflag string) TCPHeader {
	var tcpflagByte byte

	switch tcpflag {
	case "SYN":
		tcpflagByte = SYN
	case "ACK":
		tcpflagByte = ACK
	case "PSHACK":
		tcpflagByte = PSHACK
	case "FINACK":
		tcpflagByte = FINACK
	}

	return TCPHeader{
		SourcePort:       sourceport,
		DestPort:         destport,
		SequenceNumber:   []byte{0x00, 0x00, 0x00, 0x00},
		AcknowlegeNumber: []byte{0x00, 0x00, 0x00, 0x00},
		HeaderLength:     []byte{0x00},
		ControlFlags:     []byte{tcpflagByte},
		// WindowSize = とりま適当な値を入れてる
		WindowSize:    []byte{0x16, 0xd0},
		Checksum:      []byte{0x00, 0x00},
		UrgentPointer: []byte{0x00, 0x00},
	}
}
func NewTCPDummyHeader(header IPHeader, length uint16) TCPDummyHeader {
	return TCPDummyHeader{
		SourceIPAddr: header.SourceIPAddr,
		DstIPAddr:    header.DstIPAddr,
		Protocol:     []byte{0x00, 0x06},
		Length:       []byte{0x00, byte(length)},
	}
}

オレオレハンドシェイクの実装

TCPはUDPとは違いデータをちゃんと送るためコネクション型のプロトコルです。
SYN、SYNACK、ACKをクライアントとサーバ間でやり取りに成功して初めてデータを送ることが出来ます。

普段はカーネルがよろしくやってくれます。

以下はcurlコマンドのhttpリクエストをstraceしたものですが、途中にあるconnectがよろしくハンドシェイクをしてくれます。
connectで接続を確立したら、fdにsendto、recvfromをするわけですね。

https://linuxjm.osdn.jp/html/LDP_man-pages/man2/connect.2.html
$ sudo strace -e network curl localhost:8080
socket(AF_INET6, SOCK_DGRAM, IPPROTO_IP) = 3
socketpair(AF_UNIX, SOCK_STREAM, 0, [3, 4]) = 0
socketpair(AF_UNIX, SOCK_STREAM, 0, [5, 6]) = 0
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 5
setsockopt(5, SOL_TCP, TCP_NODELAY, [1], 4) = 0
setsockopt(5, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0
setsockopt(5, SOL_TCP, TCP_KEEPIDLE, [60], 4) = 0
setsockopt(5, SOL_TCP, TCP_KEEPINTVL, [60], 4) = 0
connect(5, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS (現在処理中の操作です)
getsockopt(5, SOL_SOCKET, SO_ERROR, [0], [4]) = 0
getpeername(5, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("127.0.0.1")}, [128->16]) = 0
getsockname(5, {sa_family=AF_INET, sin_port=htons(38090), sin_addr=inet_addr("127.0.0.1")}, [128->16]) = 0
sendto(5, "GET / HTTP/1.1\r\nHost: localhost:"..., 78, MSG_NOSIGNAL, NULL, 0) = 78
recvfrom(5, "HTTP/1.1 200 OK\r\nServer: nginx/1"..., 102400, 0, NULL, NULL) = 85

今回はconnectを使わないと決めているので、ハンドシェイクを自分で行います。
socketはAF_PACKETではなくcurlコマンドも使用しているAF_INETを使用します。

sendfd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_TCP)

まずSYN,ACKなどクライアント側から出すパケットを作る関数を用意します。

以下のNewTCPIP関数では引数のTCPIP内にあるフラグに応じてパケットを作成します。
IPヘッダ、TCPヘッダ、TCPダミヘッダと作成していきチェックサムを計算したら、fdにsendするためbyteデータにして返します。

func NewTCPIP(tcpip TCPIP) []byte {
	localIP := iptobyte(tcpip.DestIP)

	var ipheader IPHeader
	ipheader = NewIPHeader(localIP, localIP, "TCP")

	var tcpheader TCPHeader
	// 送信先ポート8080=1f90
	// 自分のポートは42279でとりま固定
	tcpheader = NewTCPHeader(uintTo2byte(42279), uintTo2byte(tcpip.DestPort), tcpip.TcpFlag)

	if tcpip.TcpFlag == "ACK" || tcpip.TcpFlag == "PSHACK" || tcpip.TcpFlag == "FINACK" {
		tcpheader.SequenceNumber = tcpip.SeqNumber
		tcpheader.AcknowlegeNumber = tcpip.AckNumber
	} else if tcpip.TcpFlag == "SYN" {
		// SYNのときは乱数をセット
		tcpheader.SequenceNumber = createSequenceNumber()
	}

	// IPヘッダにLengthをセットする
	// IP=20byte + tcpヘッダの長さ + (tcpオプションの長さ) + dataの長さ
	if tcpip.TcpFlag == "PSHACK" {
		ipheader.TotalPacketLength = uintTo2byte(20 + toByteLen(tcpheader) + uint16(len(tcpip.Data)))
	} else {
		// ACKのときはTCPヘッダまで
		ipheader.TotalPacketLength = uintTo2byte(20 + toByteLen(tcpheader)) // + toByteLen(tcpOption))
	}

	// Lengthをセットしたらチェックサムを計算する
	ipsum := sumByteArr(toByteArr(ipheader))
	ipheader.HeaderCheckSum = checksum(ipsum)

	// TCPヘッダのLengthをセットする
	num := toByteLen(tcpheader) //+ toByteLen(tcpOption)
	tcpheader.HeaderLength = []byte{byte(num << 2)}

	// TCPダミーヘッダを作成する
	var dummy TCPDummyHeader
	if tcpip.TcpFlag == "PSHACK" {
		// PSHACKの時はTCPデータも全長に入れる
		dummy = NewTCPDummyHeader(ipheader, num+uint16(len(tcpip.Data)))
	} else {
		dummy = NewTCPDummyHeader(ipheader, num)
	}

	//ダミーヘッダとTCPヘッダとTCPデータのbyte値を合計してチェックサムを計算する
	sum := sumByteArr(toByteArr(dummy))
	sum += sumByteArr(toByteArr(tcpheader))
	if tcpip.TcpFlag == "PSHACK" {
		// https://atmarkit.itmedia.co.jp/ait/articles/0401/29/news080_2.html
		// TCPデータの長さが奇数の場合は、最後に1byteの「0」を補って計算する
		if len(tcpip.Data)%2 != 0 {
			checksumData := tcpip.Data
			checksumData = append(checksumData, byte(0x00))
			sum += sumByteArr(checksumData)
		} else {
			sum += sumByteArr(tcpip.Data)
		}
	}
	tcpheader.Checksum = checksum(sum)

	// IPヘッダ、TCPヘッダを1つのbyteの配列にする
	var tcpipPacket []byte
	tcpipPacket = append(tcpipPacket, toByteArr(ipheader)...)
	tcpipPacket = append(tcpipPacket, toByteArr(tcpheader)...)
	if tcpip.TcpFlag == "PSHACK" {
		tcpipPacket = append(tcpipPacket, tcpip.Data...)
	}

	return tcpipPacket
}

パケットを作ったらクライアント起点のハンドシェイクの処理を実装します。
まずSYNをSendIPv4SocketでSendします。
SendしたらサーバからSYNACKが返ってくるのでそれをRecvIPSocketで受け取ります。

SYNACKのフラグを受け取ったら、サーバにACKパケットを作ってSendします。
送信→受信→送信と処理をしているわけですね。

func startTCPConnection(sendfd int, tcpip TCPIP) (TCPIP, error) {

	synPacket := NewTCPIP(tcpip)
	destIp := iptobyte(tcpip.DestIP)
	destPort := uintTo2byte(tcpip.DestPort)

	addr := setSockAddrInet4(destIp, int(tcpip.DestPort))

	// SYNを送る
	err := SendIPv4Socket(sendfd, synPacket, addr)
	if err != nil {
		return TCPIP{}, fmt.Errorf("Send SYN packet err : %v\n", err)
	}
	fmt.Println("Send SYN packet")

	// SYNACKを受け取る
	synack := RecvIPSocket(sendfd, destIp, destPort)

	var ack TCPIP
	// 0x12 = SYNACK, 0x11 = FINACK, 0x10 = ACK
	if synack.ControlFlags[0] == SYNACK || synack.ControlFlags[0] == FINACK || synack.ControlFlags[0] == ACK {
		// SYNACKに対してACKを送り返す
		ack = TCPIP{
			DestIP:    tcpip.DestIP,
			DestPort:  tcpip.DestPort,
			TcpFlag:   "ACK",
			SeqNumber: synack.AcknowlegeNumber,
			AckNumber: calcSequenceNumber(synack.SequenceNumber, 1),
		}
		ackPacket := NewTCPIP(ack)
		err = SendIPv4Socket(sendfd, ackPacket, addr)
		if err != nil {
			return TCPIP{}, fmt.Errorf("Send ACK packet err : %v\n", err)
		}
	}

	return ack, nil
}

ここまで作ったらサーバとハンドシェイクが出来ることを確認するために処理を書きます。
処理の中で接続と切断をしているので、SYNとFINACKから作成したstartTCPConnection関数を2回呼んでいます。

func synack_finack() {
	localip := "127.0.0.1"
	var port uint16 = 8080

	syn := TCPIP{
		DestIP:   localip,
		DestPort: port,
		TcpFlag:  "SYN",
	}

	sendfd := NewTCPSocket()
	defer syscall.Close(sendfd)
	ack, err := startTCPConnection(sendfd, syn)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("TCP Connection is success!!\n")
	time.Sleep(10 * time.Millisecond)

	fin := TCPIP{
		DestIP:    localip,
		DestPort:  port,
		TcpFlag:   "FINACK",
		SeqNumber: ack.SeqNumber,
		AckNumber: ack.AckNumber,
	}
	_, err = startTCPConnection(sendfd, fin)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("TCP Connection Close is success!!\n")
}

nginxのコンテナを起動しておいて、実行してみます。

$ sudo ./tcpip
Send SYN packet
TCP Connection is success!!
Send SYN packet
TCP Connection Close is success!

Wiresharkで見ると以下のようになります。

解説ページのようにちゃんとハンドシェイク出来てそうですね!

https://www.infraexpert.com/study/tcpip9.html

HTTP

ハンドシェイクで接続が確立できたら、HTTPのパケットを作ってTCPデータとして送ればHTTPリクエストになるのでやってみます。
とりあえずcurlと同じようにHTTP GETを送れるようにします。

まず、HTTPリクエストとヘッダ用の構造体を定義します。

type HttpRequest struct {
	Request []byte
	Header  HttpHeader
	Body    []byte
}

type HttpHeader struct {
	Host       []byte
	UserAgent  []byte
	Accept     []byte
	Connection []byte
}

HTTP GETリクエストを作る関数を用意します。
以下な文字列をそのままbyteにしているだけですね、わざわざ構造体とか定義しなくてもよかったかも笑

GET / HTTP/1.1 
Host: localhost:8080 
User-Agent: curl/7.68.0 
Accept: */* 
func NewHttpGetRequest(url, host string) HttpRequest {
	header := HttpHeader{
		Host:       []byte(fmt.Sprintf("Host: %s", host)),
		UserAgent:  []byte(`User-Agent: curl/7.68.0`),
		Accept:     []byte(`Accept: */*`),
		Connection: []byte(`Connection: close`),
	}
	return HttpRequest{
		Request: []byte(fmt.Sprintf("GET %s HTTP/1.1", url)),
		Header:  header,
	}
}

// https://www.infraexpert.com/study/tcpip16.html
// HTTPリクエストをbyteにして返す
func (*HttpRequest) reqtoByteArr(request HttpRequest) []byte {
	var packet []byte
	var CRLF = []byte{0x0d, 0x0a}

	packet = append(packet, request.Request...)
	packet = append(packet, CRLF...)

	rv := reflect.ValueOf(request.Header)
	for i := 0; i < rv.NumField(); i++ {
		b := rv.Field(i).Interface().([]byte)
		packet = append(packet, b...)
		packet = append(packet, CRLF...)
	}
	// 空白行を入れて戻す
	packet = append(packet, CRLF...)
	return packet
}

オレオレハンドシェイク + オレオレHTTP、その結末はいかに

ここまで来たらオレオレハンドシェイクで接続を確立したら、HTTPデータをTCPデータとして送れば、HTTPレスポンスが返ってくるはずです。
以下のように実装します。

ハンドシェイクした後、HTTP GETリクエストを作成してsendNginxを呼んでいます。

func nginx() {
	localip := "127.0.0.1"
	var port uint16 = 8080

	syn := TCPIP{
		DestIP:   localip,
		DestPort: port,
		TcpFlag:  "SYN",
	}

	sendfd := NewTCPSocket()
	defer syscall.Close(sendfd)
	ack, err := startTCPConnection(sendfd, syn)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("TCP Connection is success!!\n")
	time.Sleep(10 * time.Millisecond)

	req := NewHttpGetRequest("/", "localhost:8080")
	pshack := TCPIP{
		DestIP:    localip,
		DestPort:  port,
		TcpFlag:   "PSHACK",
		SeqNumber: ack.SeqNumber,
		AckNumber: ack.AckNumber,
		Data:      req.reqtoByteArr(req),
	}
	sendNginx(sendfd, pshack)
}

sendNginxの中はこうなっています。
大事なところはシーケンス番号の処理ですかね、PSHACKでサーバからのHTTPレスポンスにACKを返すのですが、ちゃんとデータを受信したことを
証明するために受信したデータサイズをシーケンス番号に足した値をセットしてACKを返しています。

func sendNginx(sendfd int, tcpip TCPIP) {
	pshPacket := NewTCPIP(tcpip)
	destIp := iptobyte(tcpip.DestIP)
	destPort := uintTo2byte(tcpip.DestPort)

	addr := setSockAddrInet4(destIp, int(tcpip.DestPort))

	// httpリクエストを送る
	err := SendIPv4Socket(sendfd, pshPacket, addr)
	if err != nil {
		log.Fatalf("Send PSH packet err : %v\n", err)
	}
	var serverPshack TCPHeader
	var tolalLength uint32
	for {
		recvBuf := make([]byte, 1500)
		_, _, err := syscall.Recvfrom(sendfd, recvBuf, 0)
		if err != nil {
			log.Fatalf("read err : %v", err)
		}
		// IPヘッダのProtocolがTCPであることをチェック
		if recvBuf[9] == 0x06 && bytes.Equal(recvBuf[16:20], destIp) && bytes.Equal(recvBuf[20:22], destPort) {
			tolalLength = uint32(sumByteArr(recvBuf[2:4]))

			// IPヘッダを省いて20byte目からのTCPパケットをパースする
			serverPshack = parseTCP(recvBuf[20:])
			if serverPshack.ControlFlags[0] == PSHACK {
				fmt.Println("recv PSHACK from server")
				fmt.Printf("----- print HTTP Renponse -----\n")
				fmt.Printf("%s\n\n", string(serverPshack.TCPData))
				time.Sleep(100 * time.Millisecond)
				tcpLength := tolalLength - 20
				tcpLength = tcpLength - uint32(serverPshack.HeaderLength[0]>>4<<2)

				ack := TCPIP{
					DestIP:    tcpip.DestIP,
					DestPort:  tcpip.DestPort,
					TcpFlag:   "ACK",
					SeqNumber: serverPshack.AcknowlegeNumber,
					AckNumber: calcSequenceNumber(serverPshack.SequenceNumber, tcpLength),
				}
				ackPacket := NewTCPIP(ack)
				// HTTPを受信したことに対してACKを送る
				SendIPv4Socket(sendfd, ackPacket, addr)
				//time.Sleep(100 * time.Millisecond)
				fmt.Println("Send ACK to server")

			} else if serverPshack.ControlFlags[0] == FINACK { //FIN ACKであれば
				fmt.Println("recv FINACK from server")
				finack := TCPIP{
					DestIP:    tcpip.DestIP,
					DestPort:  tcpip.DestPort,
					TcpFlag:   "FINACK",
					SeqNumber: serverPshack.AcknowlegeNumber,
					AckNumber: calcSequenceNumber(serverPshack.SequenceNumber, 1),
				}
				send_finackPacket := NewTCPIP(finack)
				SendIPv4Socket(sendfd, send_finackPacket, addr)
				fmt.Println("Send FINACK to server")
				time.Sleep(100 * time.Millisecond)
				// FINACKを送ったら終了なのでbreakスルー
				break
			}
		}
	}
	syscall.Close(sendfd)
}

文章で書くと以下のようになりますかね。

HTTPリクエストをPSHACKで送る
サーバからACKが来る
サーバからPSHACKでHTTPレスポンスが来る
HTTPレスポンスに対するACKを返す
→この時HTTPレスポンスのAckNumberをSequenceNumberに、
 AckNumberにHTTPレスポンスのSequenceNumber+TCPデータのLengthを入れる
 TCPデータのLengthを出すには、IPヘッダのTotalLengthから-20byte-TCPヘッダのLength

レスポンスを受信したら即終了でいいので、HTTPリクエストのConnection: closeをセットしておいてサーバからの
FINACKにFINACKをrecvfrom内で返すようにしています。

ではこれを実行してみましょう。

$ sudo ./tcpip
Send SYN packet
TCP Connection is success!!
recv PSHACK from server
----- print HTTP Renponse -----
HTTP/1.1 200 OK
Server: nginx/1.21.6
Date: Mon, 21 Mar 2022 15:44:59 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 25 Jan 2022 15:03:52 GMT
Connection: close
ETag: "61f01158-267"
Accept-Ranges: bytes



Send ACK to server
recv PSHACK from server
----- print HTTP Renponse -----
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>


Send ACK to server
recv FINACK from server
Send FINACK to server

ちゃんとnginxからリクエストが返ってきています!!!!

Wiresharkでみてもハンドシェイクで接続確立後にHTTPのやり取りが行われて、最後に接続を閉じていますね。

終わりに

というわけでマスタリングTCPIPを片手にオレオレ実装してみたわけですが、ようやくほんのちょっとTCPIPの仕組みが理解出来たかなーと思いました。改めて基礎は大事ですね。

こうやっていろいろとプロトコルを実装してみると勉強になりますねー、次はどのプロトコルに手を出してみようか、BGPにDNSとかDHCPとかも面白そう。

というわけで皆さんもプロトコル作ってみてはいかがでしょうかー

以下感想

  • TCPIPとkernelを讃えよ
  • 今まで全く意味がわからなかったシフト演算子の使い方マジ理解した
  • Wiresharkとtcpdumpは神ツール、Wiresharkたんしゅき(*´Д`)ハァハァ

Discussion

ログインするとコメントできます