Binary Data in Gleam: Implementing The RCON Protocol

bitcrshr

Chandler Davis

Posted on March 16, 2024

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. 

💖 💪 🙅 🚩
bitcrshr
Chandler Davis

Posted on March 16, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related