mido

thinking so ur dog doesnt have to

I like making little games with friends :] Mostly shit posting and vibing.


a weary man stumbles through the alleys of unity and rust FFI bindings

It's a work night so I avoided starting any refactors or big-brain tasks. I chose to treat myself by diving into messing with generating a C# friendly DLL and it's wrapping .cs file.

Mission: Say Hello

Tonight's goal was just to make a Unity test scene that uses my networking dll to initiate and ideally promote a GameConnection to 'active' via all the same handshakes i've talked about the in the last posts.

In weeks prior I had idly taken a tour of some crates to help with FFI stuff and came across this promising crate: Interoptopus 🦑. I set up a new library crate and was on my way.

Our goal is just to use the client_traffic crate to initiate the same join handshake, this was easy enough:

use client_traffic::initiate_connection;
use interoptopus::{ffi_function, function, Inventory, InventoryBuilder};

#[ffi_function]
#[no_mangle]
pub extern "C" fn arzach_connect() {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();

    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();

    let conn = runtime.block_on(async move {
        initiate_connection("http://127.0.0.1:8080/join".parse().unwrap(), tx)
            .await
            .unwrap()
    });
}

#[ffi_function]
#[no_mangle]
pub extern "C" fn say_hi() -> u8 {
    println!("Hello world");
    return 200;
}

pub fn ffi_inventory() -> Inventory {
    InventoryBuilder::new()
        .register(function!(arzach_connect))
        .register(function!(say_hi))
        .inventory()
}

#[cfg(test)]
mod tests {
    use interoptopus::{util::NamespaceMappings, Error, Interop};

    use super::*;

    #[test]
    fn gen_cs() -> Result<(), Error> {
        use interoptopus_backend_csharp::overloads::{DotNet, Unity};
        use interoptopus_backend_csharp::{Config, Generator};

        let config = Config {
            dll_name: "csharp".to_string(),
            namespace_mappings: NamespaceMappings::new("Goose.Zone"),
            ..Config::default()
        };

        Generator::new(config, ffi_inventory())
            .add_overload_writer(DotNet::new())
            //.add_overload_writer(Unity::new())
            .write_file("bindings/csharp/Interop.cs")
            .unwrap();

        Ok(())
    }
}

My first attempt was ambitious and used the arzach_connect() connect mostly as it was, which immediately caused a panic and Unity completely shut down.

screenshot of unity crashing

The rust library can't log to the Unity editor without some elbow grease, but thankfully stdout DOES go to Unity's Editor.log:

Attempting connection to: http://127.0.0.1:8080/join
thread '<unnamed>' panicked at 'A Tokio 1.x context was found, but IO is disabled. Call `enable_io` on the runtime builder to enable IO.', C:\Users\midov\.cargo\registry\src\github.com-1ecc6299db9ec823\tokio-1.25.0\src\net\tcp\stream.rs:161:18
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The important bit is nicely explanatory:

'A Tokio 1.x context was found, but IO is disabled. Call enable_io on the runtime builder to enable IO.'

I went ahead and added .enable_all() as you can see in the earlier code snippet.

This did the trick! Here we can see the server happily doing whatever it needs to:

     Running `target\debug\test_server.exe`
UDP Socket listening on 0.0.0.0:27015
Starting ingress...
Built new pending connection!
Query incoming
Promoting connection Ready for Some(127.0.0.1:55735)
New player added! id: 1 from: 127.0.0.1:55735
Adopted active connection for 127.0.0.1:55735

The Rust arzach_connect() call completes immediately after successfully promoting and quietly unwinds its stack and returns to Unity. Currently there is no special Drop() behavior for connections shutting down, or even any formal shutdown semantics yet. The server also does not detect dead connections yet.

What did we learn?

Many moons from now when I'm ready to formalize the C# FFI interactions for this library, I have a roadmap already! Hooray!

When that time comes, my goal is to make it a much more purpose built API for client connections in the context of Unity.

Some other thoughts (other libraries, amplification attacks, security tradeoffs etc)

This evening I also considered diving into Godot 4 to see if it's as easy as it is in Unity to drop a .dll + wrapper .cs file in and get cracking. I didn't have the energy to learn Godot 4 tonight but I did end up reading about how they implemented ENet.

ENet is a free open source networking library like any other or like mine is aspiring to be: with a toolbox of dealing with UPNP (asking your firewall nicely to port forward), reliable sending/receiving with channel constructs, unreliable channels, etc. It's really fantastic stuff and their license is very chill too.

It looks like Godot wrapped ENet in v3, but not before Fabio Alessandrelli, sponsored by Mozilla, bolted DTLS onto it. He writes a little bit about his journey in this 2020 article here.

I'd have to dig more but it looks like they've used the Mbed TLS C library to get it going. Mbed came up a lot in my research months ago, and as tempting as it was, I really wanted to keep things as pure-rust as possible. Another consideration I have, that may prove to be me overengineering, is that I don't love the notion of pre-distributed asymmetric cert that is common for all clients, or even dealing with x509 certs in general (that shit sucks we can all agree, though I respect it the same way I respect an ancient looking confused bear who wandered onto my camp site).

With the asymmetric approach in UDP, ie: a mtls x509 cert of some kind, when your public cert is used by all potentially valid peers, you'll have to continue to use extra handshakes and data in the plaintext to discriminate. You'll also need to avoid amplification techniques, ie: a listening server sees a 5 byte "hello" message and sends back 1400 bytes of join data -- UDP is connectionless so an attacker could spoof the source address of raw packets and force the server to just spam 1400 bytes for every 5 at the address they're spoofing in their from address.

My approach thankfully avoids this: we will only be able to communicate with peers who have talked to us via our HTTPS POST /join endpoint, a unique key for that session is generated and both peers generate the same cipher, and the very first message we'll receive from a potential peer is a 1024-byte challenge. In my approach, it holds true so far, that any traffic we can't decrypt will be fully discarded and ignored, the UDP Receive socket is not responsible simultaneously "priming" a secure session as well as routing active game traffic the way that the dTLS approach might require. Once the server receives the first message from a potential new client peer, with the 1024-byte challenge, it immediately stores the 'from' address+port for the rest of that connection's life, the usage of that cert is now bound to that IP+PORT remote peer.

So in summary we've eliminated two concern categories:

  • avoiding amplification attacks
  • avoiding spoofing attacks

Note: there is 100% nothing wrong with ENet, Godot 4's approach to dTLS, C libraries, I'm just being a little hobbyist who is enjoying myself and learning as I go, chasing hunches while trying to be honest with myself if they turn out to be bad ideas.

What's next maybe, in no particular order

Our remaining concerns/todos include stuff like:

  • rotating keys (chacha20 suggests ~2GiB of data ideally, our poly1305 hmac keeps us randomized in the cipher space and generating new keys is really really easy and cheap)
  • connection heartbeats (keepalive + culling)
  • connection shutdown/teardown
  • reliable stream constructs (sequence #s + ack mask can give us a reliable moving window where we only emit ordered data, and hold onto out of order packets until we backfill the missing bits, and maybe send periodic ACKs if we're still waiting on sparse data)
  • avoiding replay attacks (solved for free once we get windowing/reliability mechanisms going, as we will be tracking incoming packets over a window of time more)

You must log in to comment.

in reply to @mido's post:

Damn, I was hooked on reading about rust-based networking code but C# binding generation is a really interesting topic as well. Neat to see how (relatively) straightfoward the cross-language connection was.