Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ quote = '1.0.39'
rand = "0.9.1"
rayon = '1.10.0'
rkyv = '0.8.8'
rustls = { version = '0.23.31', default-features = false, features = ['logging', 'std', 'tls12'] }
rustls-pki-types = '1.12.0'
rustls-webpki = '0.103.4'
serde = '1.0.217'
serde_json = '1.0.140'
serial_test = '3.2.0'
Expand All @@ -112,6 +115,7 @@ thread_local = '1.1.8'
tikv-jemallocator = '0.6.0'
time = '0.3.41'
tokio = '1.45.0'
tokio-rustls = { version = '0.26.2', default-features = false, features = ['logging', 'tls12'] }
toml = '0.8.14'
tracing-appender = '0.2.3'
uuid = '1.16.0'
Expand Down
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,66 @@ sequenceDiagram

## Running

### Network topology

Hyperion uses one game server which runs all game-related code (e.g. physics, game events). One or more proxies can connect to the game server. Players connect to one of the proxies.

For development and testing purposes, it is okay to run one game server and one proxy on the same server. When generating keys, you will need to change the key and certificate file names used below to avoid file name conflicts.

On a production environment, the game server and each proxy should run on separate servers for performance.

### Generating keys and certificates

The connection between the game server and the proxies are encrypted through mTLS to ensure that the connection is secure and authenticate the proxies.

> [!WARNING]
> All private keys must be stored securely, and it is strongly recommended to generate the private keys on the server that will use them instead of transferring them over the Internet. Malicious proxies that have access to a private key can circumvent player authentication and can cause the game server to exhibit undefined behavior which can potentially lead to arbitrary code execution on the game server. If any private key has been compromised, redo this section to create new keys.

#### Create a private certificate authority (CA)

A server should be picked to store the certificate authority keys and will be referred to as the cetificate authority server. Since the game server and all proxies are considered to be trusted, any of these servers may be used for this purpose.

On the certificate authority server, generate a key and certificate by running:

```bash
openssl req -new -nodes -newkey rsa:4096 -keyout root_ca.pem -x509 -out root_ca.crt -days 365
```

OpenSSL will ask for information when running the command. All fields can be left empty.

The `-days` field specifies when the certificate will expire. It will expire in 365 days in the above command, but this can be modified as needed.

`root_ca.crt` is the root CA cert and should be copied to the game server and all proxy servers. When running the game server or the proxy, make sure to pass `--root-ca-cert root_ca.crt` as a command line flag.

#### Generate server keys and certificates

Follow these instructions for the game server and each proxy server. The server will be referred to as the target server.

On the target server, run:

```bash
openssl req -nodes -newkey rsa:4096 -keyout server_private_key.pem -out server.csr
```

OpenSSL will ask for information when running the command. All fields can be left empty.

Afterwards, transfer `server.csr` to the certificate authority server. On the certificate authority server, run:

```bash
openssl x509 -req -in server.csr -CA root_ca.crt -CAkey root_ca.pem -CAcreateserial -out server.crt -days 365 -sha256 -extfile <(printf "subjectAltName=DNS:example.com,IP:127.0.0.1")
```

Replace `example.com` with the target server's domain name and replace `127.0.0.1` with the IP address that will be used by other servers to connect to the target server.
If the IP or domain provided is incorrect, connections will fail with the error "invalid peer certificate: certificate not valid for name ...".

The `-days` field specifies when the certificate will expire. It will expire in 365 days in the above command, but this can be modified as needed.

Then, transfer `server.crt` to the target server.

`server.csr` and `server.crt` on the certificate authority server and `server.csr` on the target server are no longer needed and may be deleted.

`server.crt` is the target server's certificate and `server_private_key.pem` is the target server's private key. When running the game server or the proxy, make sure to pass `--cert server.crt --private-key server_private_key.pem` as a command line flag.

### Without cloning

```bash
Expand Down
15 changes: 12 additions & 3 deletions crates/hyperion-proxy-module/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::net::SocketAddr;
use std::{net::SocketAddr, path::Path};

use bevy::prelude::*;
use hyperion::runtime::AsyncRuntime;
Expand Down Expand Up @@ -36,12 +36,21 @@ fn update_proxy_address(trigger: Trigger<'_, SetProxyAddress>, runtime: Res<'_,
let listener = TcpListener::bind(&proxy).await.unwrap();
tracing::info!("Listening on {proxy}");

let server: SocketAddr = tokio::net::lookup_host(&server)
let addr: SocketAddr = tokio::net::lookup_host(&server)
.await
.unwrap()
.next()
.unwrap();

hyperion_proxy::run_proxy(listener, server).await.unwrap();
hyperion_proxy::run_proxy(
listener,
addr,
server.clone(),
Path::new("root_ca.crt"),
Path::new("proxy.crt"),
Path::new("proxy_private_key.pem"),
)
.await
.unwrap();
});
}
4 changes: 4 additions & 0 deletions crates/hyperion-proxy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ kanal = { workspace = true }
papaya = { workspace = true }
rkyv = { workspace = true }
rustc-hash = { workspace = true }
rustls = { workspace = true }
rustls-pki-types = { workspace = true }
rustls-webpki = { workspace = true }
tokio = { workspace = true, features = ["full", "tracing"] }
tokio-rustls = { workspace = true }
tokio-util = { workspace = true, features = ["full"] }
anyhow = { workspace = true }
bvh = { workspace = true }
Expand Down
72 changes: 60 additions & 12 deletions crates/hyperion-proxy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@
clippy::future_not_send
)]

use std::fmt::Debug;
use std::{fmt::Debug, path::Path, sync::Arc};

use anyhow::Context;
use colored::Colorize;
use hyperion_proto::ArchivedServerToProxyMessage;
use rustc_hash::FxBuildHasher;
use rustls::{RootCertStore, client::ClientConfig};
use rustls_pki_types::{CertificateDer, PrivateKeyDer, ServerName, pem::PemObject};
use tokio::{
io::{AsyncReadExt, BufReader},
io::{AsyncRead, AsyncReadExt, BufReader},
net::{TcpStream, ToSocketAddrs},
};
use tokio_rustls::TlsConnector;
use tokio_util::net::Listener;
use tracing::{Instrument, debug, error, info, info_span, instrument, trace, warn};

Expand Down Expand Up @@ -70,7 +73,43 @@ enum ShutdownType {
pub async fn run_proxy(
mut listener: impl HyperionListener,
server_addr: impl ToSocketAddrs + Debug + Clone,
mut server_name: String,
root_ca_cert_path: &Path,
proxy_cert_path: &Path,
proxy_private_key_path: &Path,
) -> anyhow::Result<()> {
// Remove port
let Some(port_index) = server_name.rfind(':') else {
anyhow::bail!("server name is missing port");
};
server_name.truncate(port_index);

let server_name = ServerName::try_from(server_name).context("failed to parse server name")?;

let root_ca_cert = CertificateDer::from_pem_file(root_ca_cert_path)
.context("failed to load root certificate authority certificate")?;
let proxy_cert = CertificateDer::from_pem_file(proxy_cert_path)
.context("failed to load proxy certificate")?;

let root_cert_store = Arc::new(RootCertStore {
roots: vec![
webpki::anchor_from_trusted_cert(&root_ca_cert)
.context("failed to create trust anchor")?
.to_owned(),
],
});

let cert_chain = vec![proxy_cert, root_ca_cert];
let key_der = PrivateKeyDer::from_pem_file(proxy_private_key_path)
.context("failed to load proxy private key")?;

let config = Arc::new(
ClientConfig::builder()
.with_root_certificates(root_cert_store)
.with_client_auth_cert(cert_chain, key_der)
.context("failed to create tls client config")?,
);

let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(None);

#[cfg(unix)]
Expand Down Expand Up @@ -121,7 +160,7 @@ pub async fn run_proxy(
let server_socket = connect(server_addr.clone()).await;
server_socket.set_nodelay(true).unwrap();

if let Err(e) = connect_to_server_and_run_proxy(&mut listener, server_socket, shutdown_rx.clone(), shutdown_tx.clone()).await {
if let Err(e) = connect_to_server_and_run_proxy(&mut listener, server_socket, server_name.clone(), config.clone(), shutdown_rx.clone(), shutdown_tx.clone()).await {
error!("Error connecting to server: {e:?}");
}

Expand All @@ -135,11 +174,20 @@ pub async fn run_proxy(
async fn connect_to_server_and_run_proxy(
listener: &mut impl HyperionListener,
server_socket: TcpStream,
server_name: ServerName<'static>,
config: Arc<ClientConfig>,
shutdown_rx: tokio::sync::watch::Receiver<Option<ShutdownType>>,
shutdown_tx: tokio::sync::watch::Sender<Option<ShutdownType>>,
) -> anyhow::Result<()> {
info!("🔗 Connected to server, accepting connections");
let (server_read, server_write) = server_socket.into_split();

let connector = TlsConnector::from(config);
let server_stream = connector
.connect(server_name, server_socket)
.await
.context("failed to connect to game server")?;

let (server_read, server_write) = tokio::io::split(server_stream);
let server_sender = launch_server_writer(server_write);

let player_registry = papaya::HashMap::default();
Expand Down Expand Up @@ -219,23 +267,23 @@ async fn connect_to_server_and_run_proxy(
}
}

struct IngressHandler {
server_read: BufReader<tokio::net::tcp::OwnedReadHalf>,
struct IngressHandler<R> {
server_read: BufReader<R>,
buffer: Vec<u8>,
egress: BufferedEgress,
}

impl Debug for IngressHandler {
impl<R> Debug for IngressHandler<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerReader").finish()
}
}

impl IngressHandler {
pub fn new(
server_read: BufReader<tokio::net::tcp::OwnedReadHalf>,
egress: BufferedEgress,
) -> Self {
impl<R> IngressHandler<R>
where
R: AsyncRead + Unpin,
{
pub fn new(server_read: BufReader<R>, egress: BufferedEgress) -> Self {
Self {
server_read,
egress,
Expand Down
34 changes: 32 additions & 2 deletions crates/hyperion-proxy/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ struct Params {
#[clap(short, long, default_value = "127.0.0.1:35565")]
#[serde(default = "default_server")]
server: String,

/// The file path to the root certificate authority certificate
#[clap(long)]
root_ca_cert: PathBuf,

/// The file path to the proxy certificate
#[clap(long)]
cert: PathBuf,

/// The file path to the proxy private key
#[clap(long)]
private_key: PathBuf,
}

fn default_proxy_addr() -> String {
Expand Down Expand Up @@ -128,14 +140,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
ProxyAddress::Tcp(addr) => {
let listener = TcpListener::bind(addr).await.unwrap();
let socket = NoDelayTcpListener { listener };
run_proxy(socket, server_addr).await.unwrap();
run_proxy(
socket,
server_addr,
params.server,
&params.root_ca_cert,
&params.cert,
&params.private_key,
)
.await
.unwrap();
}
#[cfg(unix)]
ProxyAddress::Unix(path) => {
// remove file if already exists
let _unused = tokio::fs::remove_file(path).await;
let listener = UnixListener::bind(path).unwrap();
run_proxy(listener, server_addr).await.unwrap();
run_proxy(
listener,
server_addr,
"localhost:0".to_string(),
&params.root_ca_cert,
&params.cert,
&params.private_key,
)
.await
.unwrap();
}
}
});
Expand Down
3 changes: 2 additions & 1 deletion crates/hyperion-proxy/src/server_sender.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::io::IoSlice;

use rkyv::util::AlignedVec;
use tokio::io::AsyncWrite;
use tracing::{Instrument, trace_span, warn};

use crate::util::AsyncWriteVectoredExt;
Expand All @@ -9,7 +10,7 @@ pub type ServerSender = kanal::AsyncSender<AlignedVec>;

// todo: probably makes sense for caller to encode bytes
#[must_use]
pub fn launch_server_writer(mut write: tokio::net::tcp::OwnedWriteHalf) -> ServerSender {
pub fn launch_server_writer(mut write: impl AsyncWrite + Unpin + Send + 'static) -> ServerSender {
let (tx, rx) = kanal::bounded_async::<AlignedVec>(32_768);

tokio::spawn(
Expand Down
Loading
Loading