Goで作るモダン・ブートサーバー Day3 - 雑DHCPサーバー実装編

この記事は「Go で作るモダン・ブートサーバー」の 3 日目の記事です。 今回からはお待ちかねの、実装が始まります。 この回では Go で DHCP サーバーを実装します。

シリーズの記事は以下のリンクからどうぞ。

今回は DHCP サーバーを実装できればと思い記事を書きましたが、仕様の説明だけで長くなり(仕様も全部説明できなかった)、きちんとした DHCP サーバーの実装まで至りませんでした。 そのため DHCP サーバーの実装も数段階に分けて進めたいと思います。 この記事では DHCP の仕様を説明しつつ、固定 IP アドレスを割り当てるガバガバ DHCP サーバーを実装します。

今回作成するコードは以下のレポジトリで公開しています。

DHCP (Dynamic Host Configuration Protocol) について

DHCP (RFC 2131)は、クライアントが IP アドレスやネットマスクなどの設定をネットワーク経由で自動的に取得するプロトコルです。 DHCP は BOOTP (Bootstrap Protocol, RFC 951) を拡張したプロトコルで、IP アドレスのリース機能と追加の設定項目が利用可能になりました。 現在では DHCP はネットワーク接続時の自動設定という印象が強いですが、名前にあるとおり BOOTP 自体はマシンのブートストラップを想定したプロトコルです。

DHCP メッセージ

DHCP のメッセージにはいくつかの種類があり、RFC 2131 では 8 つの DHCP メッセージが定義されています。 このうち DHCP サーバーがクライアントに IP アドレスを割り当てる時に利用されるのは、DHCPDISCOVER、DHCPOFFER、DHCPREQUEST、DHCPACK の 4 つのメッセージです。 次の図は DHCP サーバーが IP アドレスをクライアントに割り当てるまでにやり取りするメッセージです。

IPアドレスを取得するときのDHCPメッセージ

  1. DHCPDISCOVER: クライアントが DHCP サーバーを探すために、L2 ネットワーク内にブロードキャストします。
  2. DHCPOFFER: DHCPDISCOVER を受け取った DHCP サーバーが、設定値をクライアントに返答します。
  3. DHCPREQUEST: DHCPOFFER メッセージを受け取ったクライアントが、DHCP サーバーに返答します。
  4. DHCPACK: DHCPREQUEST を受け取った DHCP サーバーが、クライアントに設定値を使ってよいという返答します。

パケット構造

DHCP は UDP のプロトコルです。 DHCP で送受信するパケット構造は、DHCP サーバーが送信するパケットと DHCP クライアントが送信するパケットで同じです。 それぞれのメッセージの種類ごとに、各フィールドに適切な値を埋めます。 以下が DHCP のパケット構造です。

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     op (1)    |   htype (1)   |   hlen (1)    |   hops (1)    |
+---------------+---------------+---------------+---------------+
|                            xid (4)                            |
+-------------------------------+-------------------------------+
|           secs (2)            |           flags (2)           |
+-------------------------------+-------------------------------+
|                          ciaddr  (4)                          |
+---------------------------------------------------------------+
|                          yiaddr  (4)                          |
+---------------------------------------------------------------+
|                          siaddr  (4)                          |
+---------------------------------------------------------------+
|                          giaddr  (4)                          |
+---------------------------------------------------------------+
|                                                               |
|                          chaddr  (16)                         |
|                                                               |
|                                                               |
+---------------------------------------------------------------+
|                                                               |
|                          sname   (64)                         |
+---------------------------------------------------------------+
|                                                               |
|                          file    (128)                        |
+---------------------------------------------------------------+
|                                                               |
|                          options (variable)                   |
+---------------------------------------------------------------+

DHCP サーバーからの返答メッセージのフィールド

DHCP サーバーがクライアントに送信する DHCPOFFER と DHCPACK メッセージでは、それぞれのフィールドの値は以下のとおりです。 DHCP サーバーはこの仕様に従い、それぞれのパケットを埋めます。

フィールド DHCPOFFER DHCPACK フィールドの説明
op BOOTREPLY (2) BOOTREPLY (2) DHCP サーバーへの要求が BOOTREQUEST(1)、DHCP からの応答が BOOTREPLY (2)
htype 1 1 ハードウェアのタイプ。イーサネットは 1
hlen 5 6 MAC アドレスの長さ。イーサネットは 1
hops 0 0 DHCP リレーエージェントのホップ数
xid DHCPDISCOVER の xid DHCPREQUEST の xid クライアントが生成する一貫した ID(ランダム)
secs 0 0 クライアントのリクエストが開始してからの経過秒数
ciaddr 0 DHCPREQUEST の ciaddr クライアントの IP アドレス
yiaddr 割り当てアドレス 割り当てアドレス クライアントに割り当てる IP アドレス
siaddr ブートサーバーのアドレス ブートサーバーのアドレス ブートサーバーのアドレス
flags DHCPDISCOVER の flags DHCPREQUEST の flags ブロードキャストかユニキャストかを表す
giaddr DHCPDISCOVER の giaddr DHCPREQUEST の giaddr リレーエージェントの IP アドレス
chaddr DHCPDISCOVER の chaddr DHCPREQUEST の chaddr クライアントの MAC アドレス
sname サーバー名 サーバー名 DHCP サーバーのサーバー名
file ブートファイル ブートファイル ブートに必要なブートサーバー上のファイル名 (TFTP のパス名や HTTP ブートの URL) です。
options options options オプション

options は予約された番号と対応する数値や文字列、IP アドレスを載せられるフィールドです。 例えば 54 はサーバー識別子を表す番号で、その値は DHCP サーバーの IP アドレスを載せられます。 この番号とその意味は RFC 2132 で定義されています。

DHCP サーバーの実装

さて、上記の仕様に基づいた、IP アドレスの割り当てを実装してみましょう。 DHCP サーバーが受け取るのは DHCPDISCOVER と DHCPREQUEST で、それぞれのメッセージで DHCPOFFER と DHCPACK を返します。

上記のとおりに UDP パケットを組み立てても良いのですが(Go なら簡単ですよね)、 もっと簡単に実装するために go.universe.tf/netboot パッケージを使います。 このパッケージは DHCP4 や DHCP6、そして TFTP のライブラリを含みます。

サーバーを建てる

それでは DHCPDISCOVER を受け取った時に DHCPOFFER を返し、DHCPREQUEST を受け取った時に DHCPACK を返す簡単なサーバーを作ります。 go.universe.tf/netboot の dhcp4 パッケージでは、op、htype、hlen、hops は自動で埋めてくれます。 また何も設定しなければゼロ値が使われるので、上記表の 0 の部分は何も代入しなくて良いです。

固定アドレを割り当てる DHCP サーバーの実装が以下のとおりです。 ほんの 50 行ほどでできました。

package main

import (
	"log"
	"net"

	"go.universe.tf/netboot/dhcp4"
)

func main() {
	listen := "0.0.0.0:67"
	conn, err := dhcp4.NewConn(listen)
	if err != nil {
		log.Fatalf("[FATAL] Unable to listen on %s: %v", listen, err)
	}
	defer conn.Close()

	log.Printf("[INFO] Starting DHCP server...")
	for {
		req, intf, err := conn.RecvDHCP() //    (1)
		if err != nil {
			log.Fatalf("[ERROR] Failed to receive DHCP package: %v", err)
		}

		log.Printf("[INFO] Received %s from %s", req.Type, req.HardwareAddr)
		resp := &dhcp4.Packet{    // (2)
			TransactionID: req.TransactionID,
			HardwareAddr:  req.HardwareAddr,
                        ClientAddr:    req.ClientAddr,
			YourAddr:      net.IPv4(172, 24, 32, 1),
			Options:       make(dhcp4.Options),
		}
		resp.Options[dhcp4.OptSubnetMask] = net.IPv4Mask(255, 255, 0, 0)

		switch req.Type {    // (3)
		case dhcp4.MsgDiscover:
			resp.Type = dhcp4.MsgOffer

		case dhcp4.MsgRequest:
			resp.Type = dhcp4.MsgAck

		default:
			log.Printf("[WARN] message type %s not supported", req.Type)
			continue
		}

		log.Printf("[INFO] Sending %s to %s", resp.Type, resp.HardwareAddr)
		err = conn.SendDHCP(resp, intf)   // (4)
		if err != nil {
			log.Printf("[ERROR] unable to send DHCP packet: %v", err)
		}
	}
}
  1. パケットを受け取るとこのメソッドは処理を返します。返り値にパケットと、パケットを受け取ったインターフェイスが返ります。
  2. それぞれのメッセージで共通するフィールドをあらかじめ設定します。 xid (TransactionID) 、chaddr (HardwareAddr)、ciaddr (ClientAddr) はリクエストの値を使います。 yiaddr (YourAddr) とサブネットマスクは、固定で 172.24.32.1255.255.0.0 を設定します。
  3. リクエストのメッセージタイプごとに、クライアントに返すメッセージタイプを設定します。
  4. 作成したパケットを返信します。

検証

それでは実装した DHCP サーバーが正常に動くか試してみましょう。 検証は前回の記事で作成した環境を使用します。 環境を作成はシェルスクリプトにまとめてあります。

まずは仮想ネットワークを作成します。

sudo ./bin/setup network --name br0 --address 172.24.0.1/16

そして上記の Go ファイルをビルドして起動します。 67 番ポートに LISTEN するには管理者権限が必要なので sudo で実行します。

go build . && sudo ./building-boot-server

そしてクライアントの VM を起動します。

sudo ./bin/setup node --network br0

すると、DHCP サーバーのログにパケットの受け渡しの様子が観測できます。

2020/03/18 21:55:22 [INFO] Starting DHCP server...
2020/03/18 21:56:37 [INFO] Received DHCPDISCOVER from 52:54:00:96:e6:c3
2020/03/18 21:56:37 [INFO] Sending DHCPOFFER to 52:54:00:96:e6:c3
2020/03/18 21:56:38 [INFO] Received DHCPDISCOVER from 52:54:00:96:e6:c3
2020/03/18 21:56:38 [INFO] Sending DHCPOFFER to 52:54:00:96:e6:c3
2020/03/18 21:56:40 [INFO] Received DHCPREQUEST from 52:54:00:96:e6:c3
2020/03/18 21:56:40 [INFO] Sending DHCPACK to 52:54:00:96:e6:c3

そしてクライアントの起動メッセージを見てみましょう。

VMの起動メッセージ

まず起動時に NIC の MAC アドレス(この場合 52:54:00:96:e6:c3)が表示されます。 この値が DHCP サーバーのログと一致することが確認できます。 そして DHCP サーバーが割り当てた(固定の)IP アドレス 172.24.32.1/16 が、クライアントに設定されていることが分かります。

無事 DHCP サーバーが動き、クライアントに IP アドレスを割り当てることができました!

おわりに

今回作成したコードは以下のレポジトリで公開しています。

DHCP といえばなんとなくネットワークの設定をするものだとイメージしがちですが、仕様を調べてみると面白いです。 今回実装した DHCP サーバーは固定 IP を割り当てるガバガバ実装ですが、この状態でブート情報を渡すときちんとブートストラップが開始します。

次回以降でもう少しマシな DHCP サーバーを実装していこうと思います。 それでは次回もお楽しみに!

参考文献


Profile picture

Shin'ya Ueoka

B2B向けSaaSを提供する会社の、元Webエンジニア。今はエンジニアリング組織のマネジメントをしている。