the silly
This commit is contained in:
commit
82782182e4
7 changed files with 3048 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
2570
Cargo.lock
generated
Normal file
2570
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "sun_server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7.5", features = ["macros", "ws"] }
|
||||
eyre = "0.6.12"
|
||||
futures-util = { version = "0.3.30", features = ["sink"] }
|
||||
mongodb = "3.1.0"
|
||||
parking_lot = "0.12.3"
|
||||
reqwest = { version = "0.12.7", features = ["json"] }
|
||||
serde = "1.0.204"
|
||||
serde_json = "1.0.128"
|
||||
thiserror = "1.0.63"
|
||||
tokio = { version = "1.39.2", features = ["full"] }
|
||||
url = { version = "2.5.2", features = ["serde"] }
|
||||
uuid = { version = "1.10.0", features = ["serde"] }
|
||||
weak-table = "0.3.2"
|
82
src/auth/mod.rs
Normal file
82
src/auth/mod.rs
Normal file
|
@ -0,0 +1,82 @@
|
|||
use axum::extract::ws::WebSocket;
|
||||
use axum::http::StatusCode;
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::REQWEST_CLIENT;
|
||||
use packet::{
|
||||
ClientboundPacket, HandshakeRequest, HandshakeResponse, PacketIo, PacketIoError,
|
||||
ServerboundPacket, WritePacket,
|
||||
};
|
||||
|
||||
pub mod packet;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GameProfileProperty {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GameProfile {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub properties: Vec<GameProfileProperty>,
|
||||
}
|
||||
|
||||
pub async fn authenticate_socket(
|
||||
socket: &mut WebSocket,
|
||||
) -> Result<GameProfile, AuthenticateSocketError> {
|
||||
let join_id = Uuid::new_v4();
|
||||
|
||||
socket
|
||||
.write_packet(&ClientboundPacket::HandshakeRequest(HandshakeRequest {
|
||||
join_id,
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let HandshakeResponse { player_name } = match socket
|
||||
.read_packet()
|
||||
.await?
|
||||
.ok_or(AuthenticateSocketError::ClientDisconnected)?
|
||||
{
|
||||
ServerboundPacket::HandshakeResponse(response) => response,
|
||||
#[allow(unreachable_patterns)]
|
||||
packet => return Err(AuthenticateSocketError::InvalidPacket(packet)),
|
||||
};
|
||||
|
||||
let url = Url::parse_with_params(
|
||||
"https://sessionserver.mojang.com/session/minecraft/hasJoined",
|
||||
&[
|
||||
("username", player_name.clone()),
|
||||
("serverId", format!("ssi-{}", join_id)),
|
||||
],
|
||||
)?;
|
||||
|
||||
let response = REQWEST_CLIENT.get(url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AuthenticateSocketError::Non200Response(response.status()));
|
||||
}
|
||||
|
||||
response.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthenticateSocketError {
|
||||
#[error("non-200 status code: {0}")]
|
||||
Non200Response(StatusCode),
|
||||
#[error("invalid packet: {0:?}")]
|
||||
InvalidPacket(ServerboundPacket),
|
||||
#[error("client disconnected")]
|
||||
ClientDisconnected,
|
||||
#[error(transparent)]
|
||||
PacketIo(#[from] PacketIoError),
|
||||
#[error(transparent)]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
#[error(transparent)]
|
||||
UrlParse(#[from] url::ParseError),
|
||||
}
|
35
src/auth/packet/mod.rs
Normal file
35
src/auth/packet/mod.rs
Normal file
|
@ -0,0 +1,35 @@
|
|||
mod traits;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use self::traits::*;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "op", content = "d", rename_all = "snake_case")]
|
||||
pub enum ClientboundPacket {
|
||||
// Authentication
|
||||
HandshakeRequest(HandshakeRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "op", content = "d", rename_all = "snake_case")]
|
||||
pub enum ServerboundPacket {
|
||||
// Authentication
|
||||
HandshakeResponse(HandshakeResponse),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct HandshakeRequest {
|
||||
pub join_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct HandshakeResponse {
|
||||
pub player_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct PlayerJoin {
|
||||
pub player_name: String,
|
||||
}
|
67
src/auth/packet/traits.rs
Normal file
67
src/auth/packet/traits.rs
Normal file
|
@ -0,0 +1,67 @@
|
|||
use axum::extract::ws::{Message, WebSocket};
|
||||
use futures_util::{
|
||||
stream::{SplitSink, SplitStream},
|
||||
SinkExt, TryStreamExt,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::{ClientboundPacket, ServerboundPacket};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PacketIoError {
|
||||
#[error("the client has disconnected")]
|
||||
Disconnectetd,
|
||||
#[error("the socket is closing")]
|
||||
SocketClosing,
|
||||
#[error("the client sent an invalid websocket message: {0:?}")]
|
||||
InvalidMessage(Message),
|
||||
#[error(transparent)]
|
||||
Axum(#[from] axum::Error),
|
||||
#[error(transparent)]
|
||||
SerdeJson(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub trait PacketIo {
|
||||
async fn read_packet(&mut self) -> Result<Option<ServerboundPacket>, PacketIoError>;
|
||||
}
|
||||
|
||||
impl PacketIo for WebSocket {
|
||||
async fn read_packet(&mut self) -> Result<Option<ServerboundPacket>, PacketIoError> {
|
||||
process_packet(self.try_next().await?.ok_or(PacketIoError::Disconnectetd)?)
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketIo for SplitStream<WebSocket> {
|
||||
async fn read_packet(&mut self) -> Result<Option<ServerboundPacket>, PacketIoError> {
|
||||
process_packet(self.try_next().await?.ok_or(PacketIoError::Disconnectetd)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn process_packet(message: Message) -> Result<Option<ServerboundPacket>, PacketIoError> {
|
||||
match message {
|
||||
Message::Text(text) => Ok(Some(serde_json::from_str(&text)?)),
|
||||
Message::Ping(_) | Message::Pong(_) => Ok(None),
|
||||
Message::Close(_) => Err(PacketIoError::SocketClosing),
|
||||
msg => Err(PacketIoError::InvalidMessage(msg)),
|
||||
}
|
||||
}
|
||||
|
||||
pub trait WritePacket {
|
||||
async fn write_packet(&mut self, packet: &ClientboundPacket) -> Result<(), PacketIoError>;
|
||||
}
|
||||
|
||||
impl WritePacket for WebSocket {
|
||||
async fn write_packet(&mut self, packet: &ClientboundPacket) -> Result<(), PacketIoError> {
|
||||
let serialized = serde_json::to_string(packet)?;
|
||||
self.send(Message::Text(serialized)).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl WritePacket for SplitSink<WebSocket, Message> {
|
||||
async fn write_packet(&mut self, packet: &ClientboundPacket) -> Result<(), PacketIoError> {
|
||||
let serialized = serde_json::to_string(packet)?;
|
||||
self.send(Message::Text(serialized)).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
274
src/main.rs
Normal file
274
src/main.rs
Normal file
|
@ -0,0 +1,274 @@
|
|||
use std::{
|
||||
collections::HashSet,
|
||||
net::SocketAddr,
|
||||
sync::{Arc, LazyLock, Weak},
|
||||
};
|
||||
|
||||
use auth::authenticate_socket;
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
Path, State, WebSocketUpgrade,
|
||||
},
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use futures_util::{stream::SplitSink, SinkExt, StreamExt, TryStreamExt};
|
||||
use mongodb::{
|
||||
bson::{oid::ObjectId, Bson, Document},
|
||||
Client, Collection, Database,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
use weak_table::PtrWeakHashSet;
|
||||
|
||||
mod auth;
|
||||
|
||||
static CONNECTED_MAP: LazyLock<RwLock<HashSet<(MinecraftServer, Uuid)>>> =
|
||||
LazyLock::new(|| RwLock::new(HashSet::new()));
|
||||
|
||||
static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
reqwest::Client::builder()
|
||||
.user_agent("Samsung Smart Fridge")
|
||||
.build()
|
||||
.expect("Failed to initialize Reqwest")
|
||||
});
|
||||
|
||||
static CONNECTIONS: LazyLock<
|
||||
RwLock<
|
||||
PtrWeakHashSet<Weak<tokio::sync::Mutex<SplitSink<WebSocket, axum::extract::ws::Message>>>>,
|
||||
>,
|
||||
> = LazyLock::new(|| RwLock::new(PtrWeakHashSet::new()));
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> eyre::Result<()> {
|
||||
let mongo = Client::with_uri_str("mongodb://localhost:27017")
|
||||
.await
|
||||
.expect("Unable to connect to mongodb database");
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/cape/:uuid", get(cape_lookup_endpoint))
|
||||
.route("/api/status/:uuid", get(status_lookup_endpoint))
|
||||
.route("/api/cape_status/:uuid", get(cape_status_lookup_endpoint))
|
||||
.route("/api/ws", get(socket_endpoint));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:8585")
|
||||
.await
|
||||
.unwrap();
|
||||
axum::serve(listener, app.with_state(mongo.database("sun-server")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Clone)]
|
||||
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
|
||||
enum MinecraftServer {
|
||||
NetworkServer(String),
|
||||
SocketAddr(SocketAddr),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Cape {
|
||||
_id: ObjectId,
|
||||
uuid: String,
|
||||
cape: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct User {
|
||||
_id: ObjectId,
|
||||
uuid: String,
|
||||
status: Status,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum Status {
|
||||
Connected,
|
||||
ConnectedSpecial,
|
||||
ConnectedAdmin,
|
||||
Disconnected,
|
||||
NonSun,
|
||||
}
|
||||
|
||||
async fn cape_lookup(uuid: &Uuid, mongo: &Database) -> Option<Cape> {
|
||||
let capes: Collection<Cape> = mongo.collection("capes");
|
||||
capes
|
||||
.find_one(Document::from_iter([(
|
||||
"uuid".into(),
|
||||
Bson::String(uuid.to_string()),
|
||||
)]))
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn status_lookup(server: &MinecraftServer, uuid: &Uuid, mongo: &Database) -> Option<Status> {
|
||||
let users: Collection<User> = mongo.collection("users");
|
||||
let user = users
|
||||
.find_one(Document::from_iter([(
|
||||
"uuid".into(),
|
||||
Bson::String(uuid.to_string()),
|
||||
)]))
|
||||
.await
|
||||
.unwrap();
|
||||
let Some(user) = user else {
|
||||
return None;
|
||||
};
|
||||
let is_connected = CONNECTED_MAP
|
||||
.read()
|
||||
.contains(&(server.clone(), uuid.to_owned()));
|
||||
Some(if is_connected {
|
||||
user.status
|
||||
} else {
|
||||
Status::Disconnected
|
||||
})
|
||||
}
|
||||
|
||||
async fn cape_lookup_endpoint(
|
||||
Path(uuid): Path<Uuid>,
|
||||
State(mongo): State<Database>,
|
||||
) -> impl IntoResponse {
|
||||
let cape_str = cape_lookup(&uuid, &mongo)
|
||||
.await
|
||||
.map(|c| c.cape)
|
||||
.unwrap_or(String::new());
|
||||
(
|
||||
[(header::CONTENT_TYPE, "application/json")],
|
||||
json!({"cape": cape_str}).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn status_lookup_endpoint(
|
||||
Path((server, uuid)): Path<(MinecraftServer, Uuid)>,
|
||||
State(mongo): State<Database>,
|
||||
) -> impl IntoResponse {
|
||||
let status = status_lookup(&server, &uuid, &mongo)
|
||||
.await
|
||||
.unwrap_or(Status::NonSun);
|
||||
(
|
||||
[(header::CONTENT_TYPE, "application/json")],
|
||||
json!({"status": status}).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn cape_status_lookup_endpoint(
|
||||
Path((server, uuid)): Path<(MinecraftServer, Uuid)>,
|
||||
State(mongo): State<Database>,
|
||||
) -> impl IntoResponse {
|
||||
let cape_str = cape_lookup(&uuid, &mongo)
|
||||
.await
|
||||
.map(|c| c.cape)
|
||||
.unwrap_or(String::new());
|
||||
let status = status_lookup(&server, &uuid, &mongo)
|
||||
.await
|
||||
.unwrap_or(Status::NonSun);
|
||||
(
|
||||
[(header::CONTENT_TYPE, "application/json")],
|
||||
json!({"cape": cape_str, "status": status}).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn socket_endpoint(ws: WebSocketUpgrade) -> impl IntoResponse {
|
||||
ws.on_upgrade(|socket| handle_socket(socket))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum StatusType {
|
||||
Connect,
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct StatusPacket {
|
||||
server: MinecraftServer,
|
||||
status_type: StatusType,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct ChatPacket {
|
||||
from: String,
|
||||
contents: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(tag = "op", content = "d", rename_all = "snake_case")]
|
||||
enum Packet {
|
||||
Status(StatusPacket),
|
||||
Chat(ChatPacket),
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket) {
|
||||
let player_info = match authenticate_socket(&mut socket).await {
|
||||
Ok(player_info) => player_info,
|
||||
Err(err) => {
|
||||
eprintln!("failed to authenticate socket {err:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (socket_w, mut socket_r) = socket.split();
|
||||
let socket_w = Arc::new(tokio::sync::Mutex::new(socket_w));
|
||||
CONNECTIONS.write().insert(socket_w.clone().into());
|
||||
|
||||
while let Some(message) = socket_r.try_next().await.ok().flatten() {
|
||||
let Ok(message) = message.into_text() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(message) = serde_json::from_str::<Packet>(&message) else {
|
||||
continue;
|
||||
};
|
||||
match message {
|
||||
Packet::Status(message) => {
|
||||
match message.status_type {
|
||||
StatusType::Connect => CONNECTED_MAP
|
||||
.write()
|
||||
.insert((message.server, player_info.id)),
|
||||
StatusType::Disconnect => CONNECTED_MAP
|
||||
.write()
|
||||
.remove(&(message.server, player_info.id)),
|
||||
};
|
||||
}
|
||||
Packet::Chat(message) => {
|
||||
if message.from.to_lowercase() != player_info.name.to_lowercase() {
|
||||
return;
|
||||
}
|
||||
let Ok(message) = serde_json::to_string(&Packet::Chat(message)) else {
|
||||
return;
|
||||
};
|
||||
let connections = CONNECTIONS.read().iter().collect::<Vec<_>>();
|
||||
for connection in connections {
|
||||
let _ = connection
|
||||
.lock()
|
||||
.await
|
||||
.send(Message::Text(message.clone()))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let to_remove = CONNECTED_MAP
|
||||
.read()
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if c.1 == player_info.id {
|
||||
Some(c.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut connected_map_lock = CONNECTED_MAP.write();
|
||||
for r in to_remove {
|
||||
connected_map_lock.remove(&r);
|
||||
}
|
||||
// expensive but I don't care atm
|
||||
CONNECTIONS.write().remove_expired();
|
||||
}
|
Loading…
Reference in a new issue