steezeburger.com

Rathole Tunnel: Securely and Cheaply Expose Local Palworld Server

Why?

The biggest reason I needed to run a reverse tunnel was to get around NAT issues, but there are several benefits:

How?

  • local Palworld server
  • Rathole tunnel
    • Rathole is a reverse tunneling tool that allows you to easily expose local services to the internet. It’s written in Rust and very fast. It’s similar to localtunnel or ngrok, but it’s open source and you run your own server.
    • Rathole server running on a remote VPS
      • I went with the cheapest DigitalOcean droplet, ~$4/month
      • My goal was to get a publicly accesible box up quickly. You could probably run Rathole on a Raspberry Pi, and this could be a good solution if you already have an rpi running at home and exposed publicly to the internet.
    • Rathole client running on my local machine

Final Solution

Rathole Server

  • Create new droplet on Digital Ocean (DO)

    I am using Digital Ocean because I am familiar with it, but any cheap VPS should work. We’re only running a tunnel on this box, not the game server itself.

    • Create a new project if you do not already have a DO project
    • Create a new droplet
      • Choose a region that is a decent middle ground between you and your friends or expected server members.
      • Leave the default datacenter (unless you have other plans for this droplet and need specific features that are only available in certain datacenters)
      • I always choose the most recent LTS (long term support) version of Ubuntu, “22.04 (LTS) x64” at time of writing.
      • Droplet type should be “shared cpu”
      • CPU options > Regular, Disk Type: SSD
      • Then choose the cheapest, $4/month option
      • No need to add a volume
      • Add your ssh key
      • Can change hostname to whatever you like, I went with rathole
      • Can use any tags you want, e.g. rathole, reverse-tunnel, palworld, etc
  • Once the droplet has finished provisioning, ssh into it. The droplet IP will be shown on the droplets dashboard on the DO website
    • ssh root@<droplet-ip> - you’ll be authenticated via the ssh key you added to the droplet
    • generally a good idea to upgrade the system after first login
      • sudo apt update && sudo apt upgrade -y
      • probably want to reboot after sudo reboot (note: you’ll lose connection and need to ssh back in!)
  • Install Docker
    • Some linux distributions come with unofficial docker packages, but it’s recommended to install from the official docker repository
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      # add Docker's official GPG key:
      sudo apt-get update
      sudo apt-get install ca-certificates curl
      sudo install -m 0755 -d /etc/apt/keyrings
      sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
      sudo chmod a+r /etc/apt/keyrings/docker.asc

      # Add the repository to Apt sources:
      echo \
      "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
      $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
      sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
      sudo apt-get update

      # Install docker and related packages
      sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
  • Create a project directory and necessary files
    • Create directory and files
      1
      2
      3
      4
      5
      mkdir ~/rathole-palworld
      # docker-compose configuration
      touch ~/rathole-palworld/docker-compose.yaml
      # rathole server configuration
      touch ~/rathole-palworld/server.toml
    • Populate docker-compose.yaml
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      # docker-compose.yaml
      services:
      palworld-rathole-server:
      restart: unless-stopped
      container_name: palworld-rathole-server
      image: rapiz1/rathole
      command: ["--server", "/app/server.toml"]
      ports:
      - 2333:2333 # for rathole communication
      - 8211:8211/udp # for palworld communication
      - 27015:27015/udp # for steam client communication
      volumes:
      - ./server.toml:/app/server.toml
    • Populate server.toml
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      # server.toml
      [server]
      bind_addr = "0.0.0.0:2333" # `2333` specifies the port that rathole listens for clients
      default_token = "use_a_secret_that_only_you_know"

      [server.services.palworld]
      type = "udp"
      bind_addr = "0.0.0.0:8211"
      nodelay = true

      [server.services.palworld2]
      type = "udp"
      bind_addr = "0.0.0.0:27015"
      nodelay = true
  • Run Rathole server via docker compose
    1
    2
    # starts the rathole server in the foreground
    docker compose up
    • Helpful docker-compose commands
      • docker compose up -d - start the server in the background
      • docker compose down - stop the server
      • docker compose logs -f - view the server logs
      • docker compose logs -f palworld-rathole-server - view the server logs by container name

Rathole Client

NOTE: We are now going to be running a client on the same machine as the game server! So you’ll be running commands in a WSL/Ubuntu terminal session that’s running on your local machine now.

  • Create directory and files for Rathole client
    1
    2
    3
    4
    5
     mkdir ~/rathole-palworld
    # docker-compose configuration
    touch ~/rathole-palworld/docker-compose.yaml
    # rathole server configuration
    touch ~/rathole-palworld/client.toml
  • Populate docker-compose.yaml
    1
    2
    3
    4
    5
    6
    7
    8
    9
    services:
    palworld-rathole-client:
    restart: unless-stopped
    container_name: palworld-rathole-client
    image: rapiz1/rathole
    command: ["--client", "/app/client.toml"]
    network_mode: host
    volumes:
    - ./client.toml:/app/client.toml
  • Populate client.toml - make sure to replace your.digital.ocean.ip with the IP of your droplet! And the default_token needs to match the default_token in your Rathole server’s server.toml

    NOTE: 2024/2/16 - Added client.transport and client.transport.tcp sections. This helped A LOT with some users’ connection issues. There is some issue with the udp packets not being received correctly and causing the tunnel to terminate, but this setting mostly fixed the issue.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    # client.toml
    [client]
    remote_addr = "your.digital.ocean.ip:2333" # The address of the server. The port must be the same with the port in `server.bind_addr`
    default_token = "use_a_secret_that_only_you_know"
    retry_interval = 1

    [client.transport] # The whole block is optional. Specify which transport to use
    type = "tcp" # Optional. Possible values: ["tcp", "tls", "noise"]. Default: "tcp"

    [client.transport.tcp]
    keepalive_secs = 5 # Optional. Specify `tcp_keepalive_time` in `tcp(7)`, if applicable. Default: 20 seconds
    keepalive_interval = 2 # Optional. Specify `tcp_keepalive_intvl` in `tcp(7)`, if applicable. Default: 8 seconds

    [client.services.palworld]
    local_addr = "127.0.0.1:8211"
    type = "udp"
    nodelay = true

    [client.services.palworld2]
    local_addr = "127.0.0.1:27015"
    type = "udp"
    nodelay = true
  • Run Rathole client via docker compose
    1
    2
    # starts the rathole client in the foreground
    docker compose up
  • Test connection
    • You should now be able to connect to your Palworld server using the IP of your DigitalOcean droplet. In the Palworld mulitplayer server page, you will input something that looks like 123.123.123.90:8211, where the left side of the colon is the IP address of your DigitalOcean droplet.

Final Notes

This solution works well to get around NAT issues and expose a local game server to the internet without exposing anything else. It’s more secure than port forwarding or DMZ, and it’s still quite cheap. It’s been working well for my friends and I, and I hope it works well for you. I created a Github repository to help you get started with this solution. You can find it here.

You can leave an issue on the repo, or you can find out how to contact me on my Github profile if you need any help or have any questions! You can also leave a comment on this blog post if you are logged into Github.

Thanks for taking the time to read this post, and I hope it helps you out!


Palworld things I’m working on:

Rust Hierarchical Configurations from Files, Environment Variables, and CLI Args with Figment and Clap.

Why?

I’ve been working with Rust lately and I recently needed to implement a program where the configuration could be set through a file, environment variables, and cli args, with the latter overriding the former.

This is called a hierarchical configuration, and it is the recommended way of handling configuration in your applications.

There are several benefits to using hierarchical configuration. A few of them are:

  • Easier testing - you can easily test your app with different configurations and in different environments. e.g. cli w/ cli args, ide w/ file, cloud environments w/ environment variables, etc.

  • Improved portability - you can easily override single configuration values for deployment to any environment. 12 Factor App recommends setting configuration values with environment variables. This is no problem with the setup I recommend.

  • Flexibility - you can provide different levels of configuration customization for different users for different use cases, e.g. you can provide default configuration for most users but let power users override these values with environment variables or command line arguments.

Figment has some suggestions for how to use it with Clap, but their example did not fit my use case, and I had trouble finding examples of how to implement this properly. That is why I decided to write this blog post!

How?

There are a few Rust crates that support hierarchical configurations. I chose Figment because it was the only layered config crate that could handle values directly from the Serialize type. This was necessary to support command line arguments. We also need Clap for command line argument parsing and Serde for serialization of these command line arguments so that the argument values will be deserialized into the correct Rust types.

Figment actually has a nice little section in the documentation that explains how to use Figment with Clap, but there are a few differences with how they set up their configurations. They chose to load the configurations in a different order than is recommended for hierarchical configurations.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use clap::Parser;
use figment::{Figment, providers::{Serialized, Toml, Env, Format}};
use serde::{Serialize, Deserialize};

#[derive(Parser, Debug)]
struct Config {
/// Name of the person to greet.
#[clap(short, long, value_parser)]
name: String,

/// Number of times to greet
#[clap(short, long, value_parser, default_value_t = 1)]
count: u8,
}

// Parse CLI arguments. Override CLI config values with those in
// `Config.toml` and `APP_`-prefixed environment variables.
let config: Config = Figment::new()
.merge(Serialized::defaults(Config::parse()))
.merge(Toml::file("Config.toml"))
.merge(Env::prefixed("APP_"))
.extract()?;

Their ordering is cli args < file < environment variables, but we want file < environment variables < cli args. We could simply reorder the merge calls. Let’s try that.

1
2
3
4
5
6
7
// Parse CLI arguments. Override CLI config values with those in
// `Config.toml` and `APP_`-prefixed environment variables.
let config: Config = Figment::new()
.merge(Toml::file("Config.toml"))
.merge(Env::prefixed("APP_"))
.merge(Serialized::defaults(Config::parse()))
.extract()?;

However, this causes an issue. This will force you to use command line arguments, because if you don’t, the values would be None, but your Config struct’s values are not Option<T>, so they can’t be None. Your code will panic! Okay, so what if we refactored Config to use Option<T>s?

1
2
3
4
5
6
7
8
9
10
#[derive(Parser, Debug)]
struct Config {
/// Name of the person to greet.
#[clap(short, long, value_parser)]
name: Option<String>,

/// Number of times to greet
#[clap(short, long, value_parser, default_value_t = 1)]
count: Option<u8>,
}

We now run into another issue. If we called the program without command line arguments specified, the Config‘s values would be None. This would override the previous configuration values set by the file or environment variables with None! This means we are still basically forced to use command line arguments.

Final Solution

The final solution required adding a separate struct to handle the command line arguments, along with a helper from Serde that skips serialization of values if they are None.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// cli.rs
use clap::Parser;
use serde::Serialize;

#[derive(Debug, Parser, Serialize)]
pub(crate) struct Cli {
/// The name
#[arg(long = "name")]
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
pub(crate) name: Option<String>,

/// The count
#[arg(long = "count")]
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
pub(crate) count: Option<u8>,
}

Notice the #[serde(skip_serializing_if = "::std::option::Option::is_none")]! This means the value will not be included in the serialized struct if it was not set on the command line.

1
2
3
4
5
6
7
8
9
10
11
12
13
// config.rs
use serde::{Deserialize, Serialize};

/// The global configuration for the driver and its components.
#[derive(Serialize, Deserialize)]
pub(crate) struct Config {
/// The name
pub(crate) name: String,

/// The count
pub(crate) count: u8,
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use clap::Parser;
use color_eyre::eyre::Result;
use figment::{
Figment,
providers::{Env, Format, Serialized, Toml},
};

// main.rs
use crate::cli::Cli;
use crate::config::Config;

pub(crate) mod cli;
pub(crate) mod config;

pub async fn run() -> Result<()> {
// hierarchical config. cli args override envars which override toml config values
let conf: Config = Figment::new()
.merge(Toml::file("Config.toml"))
.merge(Env::prefixed("APP_"))
.merge(Serialized::defaults(Cli::parse()))
.extract()?;

Now we can elegantly set configuration values with Toml files, environment variables, and command line arguments in a sane way!

You can see a full example in action in this repo I am currently working on at my new company, Astria.

https://github.com/astriaorg/astria-conductor

Feel free to follow me on Github or shoot me an e-mail! My e-mail address can be found on my Github profile.

katas

A kata is something borrowed from Japanese martial arts and is something I have seen a lot as a suggestion when learning software development.

Kata is a Japanese word that means “form”. The idea is to practice the same movements often enough that they become second nature. In software development, a developer may use katas to practice things like creating a new router in an app, or maybe writing an algorithm to find all primes up to a specified number. Just short practice items. This practice can lead to more time and mental capacity to think about more important problems that you are trying to solve in your work. It helps keep you sharp and is a fun way to learn new languages and practice algorithms at the same time as well.

I’ve been learning Blender recently, and I didn’t really see any short practice lists or challenges to complete when learning digital modeling and CAD.

So, if you find this page and are also learning a new modeling program, I challenge you to model the following items:

  • ink pen
  • mug
  • usb flash drive
  • three legged stool

well hello there. welcome to my blog.