golangで作るTCPIPプロトコル
はじめに
とりあえずIT業界に入ったら読んでおけという名著はいろいろありますが、その中の1冊がマスタリングTCP/IP入門編でしょう。
僕も買ってはいたものの読むのを途中で挫折していたので、今回しっかり読んでTCP/IPを再勉強してみたいと思います。
マスタリングTCP/IPを読みながらその他わからんことはググりつつ、golangでTCPIPプロトコルそのものを自作してみます。
方針は以下のようにします。
- ethernetから作る
- データのやり取りにnetパッケージは一切使わない
(訂正、PCのIPやMacアドレスを取るのにだけ使用しますた) - データのやり取りに使うのはsyscallのsendtoとrecvfromだけ
- socketはRAW_SOCKETを使う
golangやネットワークについても初心者の駆け出しですので間違えや実装ミス、変なコードがあるかもですが、生暖かい目でよろしゅうお願いします。🙇♂️🙇♂️🙇♂️
TCP/IP自体の解説はあまりせず作ったコードの解説が主なのでそのへんもご了承ください。コードは以下にあります。
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になります。
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の補数をチェックサムフィールドに入れます。」
最初読んだ時はマジで意味不明でした、こいつ何言ってるの?みたいなですね(恥ずかしい限りですが🤦♂️🤦♂️🤦♂️)
いろいろググって以下のページを見てようやっと計算方法がわかりました。
① 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のチェックサムを計算する時に使われるものです、いっしょに定義しておきます。
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のオプションもコードでは定義していますが、実際には使っていません。
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をするわけですね。
$ 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で見ると以下のようになります。
解説ページのようにちゃんとハンドシェイク出来てそうですね!
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