RCONをGoで自前実装してMinecraftサーバーに"/say Hello!"を送るまで

## はじめに

この記事は[KLab Engineer Advent Calendar 2023](https://qiita.com/advent-calendar/2023/klab) 20日目の記事です。

RCONを実装したライブラリは既存のものがすでにたくさんありますが、今回はあえて一からプロトコルを実装してみる記事となっています。

## RCONとは？

TCP/IPベースの通信を介してゲームサーバーにコマンドを送信するプロトコルです。

Minecraftのサーバーでは[Source RCON Protocol](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol)をベースにしたRCONを利用してリモートでコマンドを実行できます。

なお、Minecraftに限らず、[Ark: Survival Evolved](https://store.steampowered.com/app/346110/ARK_Survival_Evolved/?l=japanese)や[Rust](https://store.steampowered.com/agecheck/app/252490/?l=japanese)など、他のゲームでも同じような仕組みが利用できます。

## RCONを普通に使いたい場合

本記事では簡単なコマンドが送れるようになるまでしか実装しないので、単純にRCONを利用したい場合は、実用に耐えうる以下のようなパッケージを利用してください。

- [gorcon/rcon](https://github.com/gorcon/rcon)
- [Kelwing/mc-rcon](https://github.com/Kelwing/mc-rcon)

また、上記のGo用パッケージ以外にも色々な言語で実装されているので、自分の環境にあったものを利用してください。

## Goで実装してみる

では実際にGoでRCONプロトコルを実装してみます。

MinecraftのRCONは詳細な仕様などが公開されていないため、[Source RCON Protocol](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol)を参考にしながら実装します。

### パケットの構造

パケットは以下のように構成されています。

|フィールド|型|サイズ|説明|
| :-- | :-- | :-- | :-- |
|Size|int32 (リトルエンディアン)|4バイト|パケットのサイズ。(Size自体のサイズは含まない)|
|ID|int32 (リトルエンディアン)|4バイト|レスポンスの際に使うID。今回はリクエストしか送らないので0で良い。|
|Type|int32 (リトルエンディアン)|4バイト|パケットの種類。詳しい説明は[Packet Type](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol#Packet_Type)を参照。|
|Body|nullで終了するASCII文字列|10バイト~4086バイト + 1バイト|本文。コマンドなどはここに入る。|
|Empty String|nullで終了するASCII文字列|1バイト|空文字|

![rcon.drawio (4).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2958683/b44e451a-e76f-4fd7-d866-8bb08942e3d7.png)

### パケットを作る

パケットの構造がわかったのでGoでコードを書いてみます。
まずは、[パケットの構造](#パケットの構造) 通りのパケットを`[]byte`型で作るところまでやってみます。

#### パケット用の構造体を作成

```go
type PacketType int32

const (
	ResponseValue             PacketType = 0
	ExecCommandOrAuthResponse            = 2
	Auth                                 = 3
)

type Packet struct {
	Size int32
	ID   int32
	Type PacketType
	Body []byte
}
```

#### 構造体を初期化する関数

```go
func newPacket(id int32, packetType PacketType, body []byte) Packet {
	return Packet{
        // bodyの長さ + 10でSizeを除く全体の長さが求められる
        // 10の内訳 -> 10 = ID(int32なので4) + Type(int32なので4) + null(1) + null(1)
		Size: int32(len(body) + 10),  
		ID:   id,
		Type: packetType,
		Body: body,
	}
}
```

#### 構造体をbufferに書き込んで`[]byte`にする関数

```go
func (p *Packet) marshal() ([]byte, error) {
    // p.Sizeにp.Size自身の長さを足すとp.Size込の長さになる
	buf := bytes.NewBuffer(make([]byte, 0, p.Size+4))

	binary.Write(buf, binary.LittleEndian, p.Size)
	binary.Write(buf, binary.LittleEndian, p.ID)
	binary.Write(buf, binary.LittleEndian, p.Type)
	binary.Write(buf, binary.LittleEndian, p.Body)
    binary.Write(buf, binary.LittleEndian, [2]byte{})  // null

	return buf.Bytes(), nil
}
```

### パケットの`[]byte`を構造体に戻す

レスポンス等のパケットを読むためにパケットを構造体に戻せるようにしてみます。

```go
func unmashalPacket(r io.Reader) (Packet, error) {
	packet := Packet{}

	err := binary.Read(r, binary.LittleEndian, &packet.Size)
	if err != nil {
		return packet, err
	}

	err = binary.Read(r, binary.LittleEndian, &packet.ID)
	if err != nil {
		return packet, err
	}

	err = binary.Read(r, binary.LittleEndian, &packet.Type)
	if err != nil {
		return packet, err
	}

	// IDとType分のバイト数を引く
	packet.Body = make([]byte, packet.Size-8)
	_, err = io.ReadFull(r, packet.Body)
	if err != nil {
		return packet, err
	}

	// 末尾のnullを取り除く
	packet.Body = packet.Body[:len(packet.Body)-2]

	return packet, nil
}
```

### パケットを送信する

パケット周りの処理が完成したので、次は実際にパケットを送信する処理を作ります。

#### TCP用のクライアント

TCP用のクライアントを用意します。


```go
type Conn struct {
	conn net.Conn
}

func New(addr string, password string) (Conn, error) {
	conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
	if err != nil {
		return Conn{}, err
	}

	client := Conn{conn}
    // 後で実装
	err = client.authenticate(password)
	if err != nil {
		return Conn{}, err
	}

	return client, nil
}

func (c Conn) Close() {
	c.conn.Close()
}
```
client.authenticateは後で実装します。

#### 送信処理

```go
func (c Conn) send(packetType PacketType, bodyStr string) (Packet, error) {
	p := newPacket(0, packetType, []byte(bodyStr))
	b, err := p.marshal()
	if err != nil {
		return Packet{}, err
	}

	_, err = c.conn.Write(b)
	if err != nil {
		return Packet{}, err
	}

	resp, err := unmashalPacket(c.conn)
	if err != nil {
		return Packet{}, err
	}

	return resp, nil
}

func (c Conn) Exec(command string) error {
	p, err := c.send(ExecCommandOrAuthResponse, command)
	if err != nil {
		return err
	}

	fmt.Println("Response: " + string(p.Body))

	return nil
}
```

#### 認証処理

RCONでコマンドを送るためには、はじめに認証処理を行う必要があります。
認証方法は単純で、サーバー側に設定されたパスワードをbodyに入れて送信するだけです。
ただし、PacketTypeを`3`にする必要がある点に注意してください。（コマンド実行は`2`）
```go
func (c Conn) authenticate(password string) error {
	p, err := c.send(Auth, password)
	if err != nil {
		return err
	}

	// 認証に失敗したらIDに-1が入る
	if p.ID == -1 {
		return errors.New("failed auth")
	}

	return err
}
```

## Minecraft側の準備

RCON用の処理が完成したので、Minecraft側の準備を行っていきます。

### サーバーを立てる

まずは、Minecraftのサーバーを立てるために必要なjarファイルを[公式サイト](https://www.minecraft.net/ja-jp/download/server)からダウンロードします。
また、同じページに

```
# jarファイルの名前は適宜変更する
java -Xmx1024M -Xms1024M -jar minecraft_server.1.20.4.jar nogui
```

このような起動用のコマンドの例が書いてあるので、このコマンドをjarファイルと同じ階層で実行します。（ファイル等が生成されるのでjarファイルを適切なディレクトリに移動させてから実行したほうがいいです）

実行後eula.txtが生成されるので、リンク先の内容をよく読み問題なければ`eula=false`を`eula=true`に書き換えます。
その後もう一度コマンドを実行するとサーバーが立ち上がります。

### サーバーの設定を変える

設定を変更してRCONを有効にします。

jarファイルと同じ階層に設定ファイル（`server.properties`）が生成されているので、以下の項目を変更します。

```
enable-rcon=true
rcon.password=任意のパスワード
rcon.port=25575  # 必要があれば変える
```

変更後一度サーバーを起動してみて以下のようなログが出ていれば設定できています。

```
[Server thread/INFO]: RCON running on 0.0.0.0:25575
```

## "/say Hello!"を送信してみる

準備が整ったのでサーバーにRCONを使ってコマンドを送信してみます。

先程の実装を最低限CLIで利用できるようにした[サンプル](https://github.com/wgdp/rcon-sample)を使って作業を行います。

サーバーを起動した状態で以下を実行します。

```bash
# 引数は適宜変更
go run main.go -h localhost:25575 -p パスワード -c "/say Hello!"
```

実行後、サーバーのログに表示されていたら成功しています。

```
[RCON Listener #1/INFO]: Thread RCON Client /0:0:0:0:0:0:0:1 started
[Server thread/INFO]: [Not Secure] [Rcon] Hello!
[RCON Client /0:0:0:0:0:0:0:1 #4/INFO]: Thread RCON Client /0:0:0:0:0:0:0:1 shutting down
```

#### クライアント側でも確認

![2023-12-14_22.12.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2958683/6ed9fdd6-5aa5-4ec3-f0ba-371a365af53f.png)

うまく送れています。

他のコマンドも試しに送ってみました。

```bash
go run main.go -h localhost:25575 -p hogehoge -c "/execute at @a run fill ~ ~ ~ ^10 ^50 ^10 tnt"
```

![2023-12-14_22.26.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2958683/532ac877-3f51-2c03-9438-c8f5c3e127db.png)

問題なさそうですね。

## 小ネタ

実装の際、参考にした[Source RCON Protocol](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol)では、コマンド(Body)はASCIIのみとなっており、日本語などは送信できない仕様になっています。

ですが、Minecraftで実装されているRCONではUTF-8が送信できます。

![2023-12-14_22.33.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2958683/7aac541f-b226-ad11-1e8f-3735a953dc13.png)

そのため、このように日本語が含まれたコマンドが送信できます。

## まとめ

今回は自分の勉強も兼ねてプロトコルを実装してみましたが、`encoding/binary`や`net`などのパッケージ周りの理解が深まった気がします。

Minecraft以外のゲームでも、サーバーのコマンド実行用にRCONが実装されているゲームがあるので、自分でサーバーを立てたりするときに活用できると幸せになれると思います。