はじめに
この記事はKLab Engineer Advent Calendar 2023 20日目の記事です。
RCONを実装したライブラリは既存のものがすでにたくさんありますが、今回はあえて一からプロトコルを実装してみる記事となっています。
RCONとは?
TCP/IPベースの通信を介してゲームサーバーにコマンドを送信するプロトコルです。
MinecraftのサーバーではSource RCON ProtocolをベースにしたRCONを利用してリモートでコマンドを実行できます。
なお、Minecraftに限らず、Ark: Survival EvolvedやRustなど、他のゲームでも同じような仕組みが利用できます。
RCONを普通に使いたい場合
本記事では簡単なコマンドが送れるようになるまでしか実装しないので、単純にRCONを利用したい場合は、実用に耐えうる以下のようなパッケージを利用してください。
また、上記のGo用パッケージ以外にも色々な言語で実装されているので、自分の環境にあったものを利用してください。
Goで実装してみる
では実際にGoでRCONプロトコルを実装してみます。
MinecraftのRCONは詳細な仕様などが公開されていないため、Source RCON Protocolを参考にしながら実装します。
パケットの構造
パケットは以下のように構成されています。
フィールド | 型 | サイズ | 説明 |
---|---|---|---|
Size | int32 (リトルエンディアン) | 4バイト | パケットのサイズ。(Size自体のサイズは含まない) |
ID | int32 (リトルエンディアン) | 4バイト | レスポンスの際に使うID。今回はリクエストしか送らないので0で良い。 |
Type | int32 (リトルエンディアン) | 4バイト | パケットの種類。詳しい説明はPacket Typeを参照。 |
Body | nullで終了するASCII文字列 | 10バイト~4086バイト + 1バイト | 本文。コマンドなどはここに入る。 |
Empty String | nullで終了するASCII文字列 | 1バイト | 空文字 |
パケットを作る
パケットの構造がわかったのでGoでコードを書いてみます。
まずは、パケットの構造 通りのパケットを[]byte
型で作るところまでやってみます。
パケット用の構造体を作成
type PacketType int32
const (
ResponseValue PacketType = 0
ExecCommandOrAuthResponse = 2
Auth = 3
)
type Packet struct {
Size int32
ID int32
Type PacketType
Body []byte
}
構造体を初期化する関数
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
にする関数
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
を構造体に戻す
レスポンス等のパケットを読むためにパケットを構造体に戻せるようにしてみます。
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用のクライアントを用意します。
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は後で実装します。
送信処理
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
)
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ファイルを公式サイトからダウンロードします。
また、同じページに
# 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で利用できるようにしたサンプルを使って作業を行います。
サーバーを起動した状態で以下を実行します。
# 引数は適宜変更
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
クライアント側でも確認
うまく送れています。
他のコマンドも試しに送ってみました。
go run main.go -h localhost:25575 -p hogehoge -c "/execute at @a run fill ~ ~ ~ ^10 ^50 ^10 tnt"
問題なさそうですね。
小ネタ
実装の際、参考にしたSource RCON Protocolでは、コマンド(Body)はASCIIのみとなっており、日本語などは送信できない仕様になっています。
ですが、Minecraftで実装されているRCONではUTF-8が送信できます。
そのため、このように日本語が含まれたコマンドが送信できます。
まとめ
今回は自分の勉強も兼ねてプロトコルを実装してみましたが、encoding/binary
やnet
などのパッケージ周りの理解が深まった気がします。
Minecraft以外のゲームでも、サーバーのコマンド実行用にRCONが実装されているゲームがあるので、自分でサーバーを立てたりするときに活用できると幸せになれると思います。