Logo

dev-resources.site

for different kinds of informations.

Binary Data in Gleam: Implementing The RCON Protocol

Published at
3/16/2024
Categories
gleam
erlang
elixir
gamedev
Author
bitcrshr
Categories
4 categories in total
gleam
open
erlang
open
elixir
open
gamedev
open
Author
8 person written this
bitcrshr
open
Binary Data in Gleam: Implementing The RCON Protocol

Update: This is now a library! Check it out here

With Gleam's 1.0 release hot off the presses and coverage by Twitch and Youtube giants ThePrimeagen and Theo, many folks are being drawn to the language as a gentle but powerful introduction to functional programming, and I'm no exception.

One of my go-to projects for learning a new language is implementing Valve's Source RCON Protocol and in this article we're gonna learn how to do it in Gleam!

We're going to base our implementation off of the excellent RCON package for Go known as gorcon.

Let's get started!

What the hell is Gleam?

Gleam is an impure functional programming language built off of Erlang's VM, BEAM with a focus on simplicity and developer experience. It has interoperability with other BEAM languages (e.g., Erlang and Elixir) and can even compile to Javascript!

For more information, I highly encourage you to check out the language tour.

What the hell is RCON?

RCON stands for Remote Connection, and is a binary protocol developed by Steam as part of Source. It is used by many popular game servers such as Rust, Minecraft, and PalWorld, and allows you to execute commands like ShowPlayers (PalWorld) from a remote system (such as the game client).

It has a simple wire format. In order, the fields are:

  • size - A 32-bit little-endian signed integer representing the total size of the packet (minus itself)
  • id - Another i32-LE that can be set to any positive value. This allows a response to be matched to a request.
  • type - Another i32-LE representing what this packet is meant to do. There are four types that are widely supported:
    • SERVERDATA_AUTH (3) - Sent by the client with the password as the body to authenticate the connection to the RCON server.
    • SERVERDATA_AUTH_RESPONSE (2) - Sent by the server to indicate the auth status following a SERVERDATA_AUTH request.
    • SERVERDATA_EXECCOMMAND (2) - Sent by the client to execute a command (such as ShowPlayers) on the server.
      • That's not a typo--both SERVERDATA_AUTH_RESPONSE and SERVERDATA_EXECCOMMAND are represented by a 2!
    • SERVERDATA_RESPONSE_VALUE (0) - Sent by the server to indicate the result of a SERVERDATA_EXECCOMMAND request.
  • body - An ASCII string that is terminated by two null bytes1

All of these fields together make a packet!

Implementing Packets

To begin, let's define some constants and helper functions that will help us later:

//// src/packet.gleam

/// How many bytes the padding (i.e., <<0x00, 0x00>>) takes up
const packet_padding_size_bytes: Int = 2

/// How many bytes the header (i.e., the id and type) takes up
const packet_header_size_bytes: Int = 8

/// Returns the byte size of the smallest possible packet, which is a packet
/// with an empty body.
fn min_packet_size_bytes() -> Int {
    packet_padding_size_bytes + packet_header_size_bytes
}

/// Returns the byte size of the largest possible packet, which is a packet
/// with a 4KB body. This is a limitation set by the protocol.
fn max_packet_size_bytes() -> Int {
    4096 + min_packet_size_bytes
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll define some types and helper functions that will make the public interface a bit nicer:

//// src/packet.gleam

// ...

/// Represents valid RCON packet types.
pub type PacketType {
    ServerDataAuth
    ServerDataAuthResponse
    ServerDataExecCommand
    ServerDataResponseValue
}

pub fn packet_type_to_int(pt: PacketType) -> Int {
  case pt {
    ServerDataAuth -> 3
    ServerDataAuthResponse | ServerDataExecCommand -> 2
    ServerDataResponseValue -> 0
  }
}

/// Represents an RCON packet.
pub type Packet {
    Packet(size: Int, id: Int, typ: Int, body: BitArray)
}

/// Constructs a new Packet.
pub fn new(
  packet_type: PacketType,
  packet_id: Int,
  body: String,
) -> Result(Packet, String) {
  let size =
    string.byte_size(body)
    + packet_header_size_bytes
    + packet_padding_size_bytes
  let max = max_packet_size_bytes()

  // note: it would be good to check if the body is ASCII here too!

  case size {
    _ if size > max -> Error("body is larger than 4096 bytes")
    _ -> {
      let bytes =
        body
        |> bytes_builder.from_string
        |> bytes_builder.to_bit_array

      Ok(Packet(size, packet_id, packet_type_to_int(packet_type), bytes))
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Great! Now we have a solid representation of a Packet in Gleam. As a bonus, as long as we use new(...), any Packet we construct is guaranteed to work with the protocol.

One cool bit of Gleam syntax we used is the pipe operator |>, and it would be an injustice to not go over it quickly:

let bytes = 
    body
    |> bytes_builder.from_string // parens optional when fn takes 1 arg
    |> bytes_builder.to_bit_array

// is equivalent to:
let bytes = bytes_builder.to_bit_array(
    bytes_builder.from_string(
        body    
    )
)
Enter fullscreen mode Exit fullscreen mode

This allows us to "chain" function calls in a way where that feels like a builder pattern but is really just syntactic sugar for nested function calls. Neat, right?

Now, let's get to the meat of it and implement the binary bits2! First, let's take advantage of the pipe operator again to convert a Packet into its binary format:

//// src/packet.gleam

// ...

pub fn to_bytes(packet: Packet) -> BitArray {
  bytes_builder.new()
  |> bytes_builder.append(<<packet.size:int-size(32)-little>>)
  |> bytes_builder.append(<<packet.id:int-size(32)-little>>)
  |> bytes_builder.append(<<packet.typ:int-size(32)-little>>)
  |> bytes_builder.append(packet.body)
  |> bytes_builder.append(<<0x00, 0x00>>)
  |> bytes_builder.to_bit_array()
}
Enter fullscreen mode Exit fullscreen mode

To again avoid grave injustices, let's examine the syntax for BitArray literals:

let size_bytes = <<packet.size:int-size(32)-little>>
Enter fullscreen mode Exit fullscreen mode

What we're saying here is:

  • packet.size - Write packet.size as binary to the BitArray
  • int - Represent it as an int
  • size(32) - With a size of 32 bits
  • little - As little-endian

Finally, let's use Gleam's excellent pattern matching to read a Packet from a BitArray. If you're familiar with Rust, you'll find this to be very similar to match :

//// src/packet.gleam

// ...

pub fn from_bytes(bytes: BitArray) -> Result(Packet, String) {
  let min_ps = min_packet_size_bytes()
  let max_ps = max_packet_size_bytes()

  // notice how pattern matching allows us to do this declaritively!
  // we're basically saying "if the data is this shape, do this"
  // instead of worrying about the details
  case bytes {
    // pull off an i32-LE called `size` and leave the `rest` as a BitArray.
    // we need to read the size first so we can know how many bytes the body 
    // should be!
    <<size:size(32)-little-int, rest:bits>> -> {
      case size {
        _ if size < min_ps -> {
          Error("size cannot be less than min_packet_size")
        }
        _ if size > max_ps ->
          Error("size cannot be greater than max_packet_size")
        _ -> {
          let body_size_bits =
            { size - packet_header_size_bytes - packet_padding_size_bytes } * 8

          case rest {
            // pull off the rest of the fields!
            // note that this is also ensuring there isn't any "extra"
            // data left over in the BitArray.
            <<
              id:int-size(32)-little,
              typ:int-size(32)-little,
              body:size(body_size_bits)-bits,
              padding:size(16)-bits,
            >> -> {
              case typ {
                3 | 2 | 0 -> {
                  case padding {
                    <<0x00, 0x00>> -> {
                      Ok(Packet(size, id, typ, body))
                    }

                    _ -> Error("padding must be <<0x00, 0x00>>")
                  }
                }

                _ -> Error("type must be 3, 2, or 0")
              }
            }

            _ -> {
              Error("invalid packet format")
            }
          }
        }
      }
    }

    _ -> Error("invalid packet format")
  }
}

Enter fullscreen mode Exit fullscreen mode

And that's all there is to it! Let me know in the comments if you'd like to see a part 2 where we can write the TCP logic to make a full-fledged RCON client in Gleam.

Better at Gleam than I am? Leave me a comment about how I could improve!

Who the hell are you anyway?

I'm Chandler, and my background is in Go, Rust, Zig, and begrudgingly, Typescript. I started out as a frontend engineer, but was quickly captivated by the realm of backend and systems programming. Professionally, I work as a core engineer at a healthcare startup. In my free time, I enjoy playing video games, learning new programming languages, and playing guitar.

You can catch me on đť•Ź Formerly Known As Twitter and check out my project graveyard on Github.


  1. Technically, it's a null-terminated string (ASCIIZ) followed by another null byte to terminate the packet ↩

  2. Pun was absolutely intended. You're welcome. ↩

erlang Article's
30 articles in total
Favicon
Elixir em Foco em 2024
Favicon
Parsing Grid-Based STDIN Input in Gleam
Favicon
Bridging the Gap: Simplifying Live Component Invocation in Phoenix LiveView
Favicon
RabbitMQ with Web MQTT Plugin vs. Node.js : Performance and Memory Usage Comparison
Favicon
Navigating the EEF Stipend Process
Favicon
👉 What is gleam language used for ❓
Favicon
Implementing OpenID Connect on the BEAM
Favicon
OpenID Connect—An Introduction
Favicon
Por que o Elixir Ă© melhor que Node.js para Processamento AssĂ­ncrono?
Favicon
How To Deploy RabbitMQ On Public IP?
Favicon
Weather API: A GenServer and LiveView Implementation Part I
Favicon
Berkenalan Dengan Bahasa Pemrograman Fungsional Elixir (Bagian 1)
Favicon
(Amazing!) How to manage and install multiple versions of Erlang/OTP and Elixir via vfox in Windows
Favicon
Hands-On with Gleam: Building and Improving a Binary Search Tree
Favicon
Historia de la BeamVM
Favicon
Elixir Days - Evento presencial em SĂŁo Paulo
Favicon
BEAM VM The good, the bad and the ugly
Favicon
ConferĂŞncias do Ecossistema de Erlang (e Elixir)
Favicon
Deep Diving Into the Erlang Scheduler
Favicon
Install mutiple Erlang and Elixir with vfox
Favicon
Installing Erlang With vfox
Favicon
Erlang Workshop 2024 - Call for Papers
Favicon
Empresas brasileiras que usam, ou usaram, Elixir ou Erlang
Favicon
Binary Data in Gleam: Implementing The RCON Protocol
Favicon
Using the Keyword module for options
Favicon
How to take leverage from on_mount to reduce code
Favicon
What you should know about the live_session macro
Favicon
Build Phoenix Docker Compose development environment easily
Favicon
When to use the handle_params callback
Favicon
The “let it crash” error handling strategy of Erlang, by Joe Armstrong

Featured ones: