Initial commit: FunConnect project with server, relay, client and admin panel
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
115
client/src/commands/auth.rs
Normal file
115
client/src/commands/auth.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::state::{AppState, CurrentUser, Tokens};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResult {
|
||||
pub user: CurrentUser,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServerAuthResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
user: CurrentUser,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login(
|
||||
username: String,
|
||||
password: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AuthResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/auth/login", state.get_server_url());
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "username": username, "password": password }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(err["error"].as_str().unwrap_or("login failed").to_string());
|
||||
}
|
||||
|
||||
let body: ServerAuthResponse = resp.json().await.map_err(|e| e.to_string())?;
|
||||
let token = body.access_token.clone();
|
||||
state.set_auth(
|
||||
body.user.clone(),
|
||||
Tokens {
|
||||
access_token: body.access_token,
|
||||
refresh_token: body.refresh_token,
|
||||
},
|
||||
);
|
||||
Ok(AuthResult { user: body.user, token })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AuthResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/auth/register", state.get_server_url());
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(err["error"].as_str().unwrap_or("registration failed").to_string());
|
||||
}
|
||||
|
||||
let body: ServerAuthResponse = resp.json().await.map_err(|e| e.to_string())?;
|
||||
let token = body.access_token.clone();
|
||||
state.set_auth(
|
||||
body.user.clone(),
|
||||
Tokens {
|
||||
access_token: body.access_token,
|
||||
refresh_token: body.refresh_token,
|
||||
},
|
||||
);
|
||||
Ok(AuthResult { user: body.user, token })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn logout(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let refresh_token = {
|
||||
state.tokens.lock().unwrap().as_ref().map(|t| t.refresh_token.clone())
|
||||
};
|
||||
if let Some(rt) = refresh_token {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/auth/logout", state.get_server_url());
|
||||
let _ = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "refresh_token": rt }))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
state.clear_auth();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_current_user(state: State<'_, AppState>) -> Result<Option<AuthResult>, String> {
|
||||
let user = state.user.lock().unwrap().clone();
|
||||
let token = state.tokens.lock().unwrap().as_ref().map(|t| t.access_token.clone());
|
||||
|
||||
match (user, token) {
|
||||
(Some(u), Some(t)) => Ok(Some(AuthResult { user: u, token: t })),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
48
client/src/commands/config.rs
Normal file
48
client/src/commands/config.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::state::ServerConfig;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_server_url(url: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.set_server_url(url.clone());
|
||||
tracing::info!("Server URL set to: {}", url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_server_url(state: State<'_, AppState>) -> Result<String, String> {
|
||||
Ok(state.get_server_url())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_server_config(state: State<'_, AppState>) -> Result<ServerConfig, String> {
|
||||
let server_url = state.get_server_url();
|
||||
let url = format!("{}/api/v1/client-config", server_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("连接服务器失败: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("服务器返回错误: {}", response.status()));
|
||||
}
|
||||
|
||||
let config: ServerConfig = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析配置失败: {}", e))?;
|
||||
|
||||
*state.server_config.lock().unwrap() = Some(config.clone());
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_cached_server_config(state: State<'_, AppState>) -> Result<Option<ServerConfig>, String> {
|
||||
Ok(state.server_config.lock().unwrap().clone())
|
||||
}
|
||||
118
client/src/commands/friends.rs
Normal file
118
client/src/commands/friends.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FriendDto {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub avatar_seed: String,
|
||||
pub is_online: bool,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
fn auth_header(state: &AppState) -> Result<String, String> {
|
||||
state
|
||||
.get_access_token()
|
||||
.map(|t| format!("Bearer {}", t))
|
||||
.ok_or_else(|| "not authenticated".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_friends(state: State<'_, AppState>) -> Result<Vec<FriendDto>, String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/friends", state.get_server_url());
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json::<Vec<FriendDto>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FriendRequest {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub avatar_seed: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_requests(state: State<'_, AppState>) -> Result<Vec<FriendRequest>, String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/friends/requests", state.get_server_url());
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json::<Vec<FriendRequest>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_friend_request(
|
||||
username: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/friends/request", state.get_server_url());
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Authorization", token)
|
||||
.json(&serde_json::json!({ "username": username }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
let err: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(err["error"].as_str().unwrap_or("failed").to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn accept_friend_request(
|
||||
requester_id: Uuid,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/friends/{}/accept", state.get_server_url(), requester_id);
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.header("Authorization", token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err("failed to accept request".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_friend(friend_id: Uuid, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/friends/{}", state.get_server_url(), friend_id);
|
||||
client
|
||||
.delete(&url)
|
||||
.header("Authorization", token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
7
client/src/commands/mod.rs
Normal file
7
client/src/commands/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod friends;
|
||||
pub mod network;
|
||||
pub mod relay_nodes;
|
||||
pub mod rooms;
|
||||
pub mod signaling;
|
||||
323
client/src/commands/network.rs
Normal file
323
client/src/commands/network.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::network::{
|
||||
minecraft_proxy::{find_mc_port, start_client_proxy, start_host_proxy},
|
||||
p2p::{attempt_p2p, build_my_peer_info, find_quic_port},
|
||||
quic::open_endpoint,
|
||||
relay::connect_relay,
|
||||
relay_selector::fetch_best_node,
|
||||
session::{ConnectionStats, NetworkSession},
|
||||
lan_discovery::broadcast_lan_server,
|
||||
};
|
||||
use funmc_shared::protocol::PeerInfo;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkSessionDto {
|
||||
pub room_id: String,
|
||||
pub local_port: u16,
|
||||
pub session_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionInfo {
|
||||
pub room_id: String,
|
||||
pub local_port: u16,
|
||||
pub connect_addr: String,
|
||||
pub session_type: String,
|
||||
}
|
||||
|
||||
fn auth_token(state: &AppState) -> Result<String, String> {
|
||||
state.get_access_token().ok_or_else(|| "not authenticated".into())
|
||||
}
|
||||
|
||||
fn my_user_id(state: &AppState) -> Result<Uuid, String> {
|
||||
state
|
||||
.user
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|u| u.id)
|
||||
.ok_or_else(|| "not authenticated".into())
|
||||
}
|
||||
|
||||
/// Upload host peer info to server for P2P connection
|
||||
async fn upload_host_info(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
room_id: Uuid,
|
||||
peer_info: &PeerInfo,
|
||||
) {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/rooms/{}/host-info", server_url, room_id);
|
||||
|
||||
let nat_type_str = match peer_info.nat_type {
|
||||
funmc_shared::protocol::NatType::None => "none",
|
||||
funmc_shared::protocol::NatType::FullCone => "full_cone",
|
||||
funmc_shared::protocol::NatType::RestrictedCone => "restricted_cone",
|
||||
funmc_shared::protocol::NatType::PortRestrictedCone => "port_restricted",
|
||||
funmc_shared::protocol::NatType::Symmetric => "symmetric",
|
||||
funmc_shared::protocol::NatType::Unknown => "unknown",
|
||||
};
|
||||
|
||||
let _ = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({
|
||||
"public_addr": peer_info.public_addr,
|
||||
"local_addrs": peer_info.local_addrs,
|
||||
"nat_type": nat_type_str,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// House side: open a QUIC listener, accept connections from peers (direct or relayed),
|
||||
/// forward each to the local MC server (default 25565).
|
||||
#[tauri::command]
|
||||
pub async fn start_hosting(
|
||||
room_id: String,
|
||||
room_name: Option<String>,
|
||||
mc_port: Option<u16>,
|
||||
state: State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
) -> Result<NetworkSessionDto, String> {
|
||||
let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?;
|
||||
let room_name = room_name.unwrap_or_else(|| format!("Room {}", &room_id[..8]));
|
||||
let mc_server_port = mc_port.unwrap_or(25565);
|
||||
let mc_addr = format!("127.0.0.1:{}", mc_server_port).parse().unwrap();
|
||||
let token = auth_token(&state)?;
|
||||
let user_id = my_user_id(&state)?;
|
||||
|
||||
let quic_port = find_quic_port().await.map_err(|e| e.to_string())?;
|
||||
let bind_addr = format!("0.0.0.0:{}", quic_port).parse().unwrap();
|
||||
let endpoint = open_endpoint(bind_addr).map_err(|e| e.to_string())?;
|
||||
let endpoint = Arc::new(endpoint);
|
||||
|
||||
// Build and upload peer info for P2P connections
|
||||
let my_peer_info = build_my_peer_info(user_id, quic_port).await;
|
||||
tracing::info!("Host public addr: {}", my_peer_info.public_addr);
|
||||
upload_host_info(&state.get_server_url(), &token, room_uuid, &my_peer_info).await;
|
||||
|
||||
let session = NetworkSession::new(room_uuid, quic_port, true, "hosting");
|
||||
let cancel = session.cancel.clone();
|
||||
let stats = session.stats.clone();
|
||||
|
||||
// Store session
|
||||
*state.network_session.write().await = Some(session);
|
||||
|
||||
// Accept incoming QUIC connections in background
|
||||
let ep2 = endpoint.clone();
|
||||
let cancel2 = cancel.clone();
|
||||
let stats2 = stats.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(inc) = ep2.accept().await {
|
||||
let mc_addr2 = mc_addr;
|
||||
let cancel3 = cancel2.clone();
|
||||
let stats3 = stats2.clone();
|
||||
tokio::spawn(async move {
|
||||
match inc.await {
|
||||
Ok(conn) => {
|
||||
tracing::info!("Host: accepted QUIC connection from {}", conn.remote_address());
|
||||
{
|
||||
let mut s = stats3.lock().await;
|
||||
s.connected = true;
|
||||
s.session_type = "p2p".into();
|
||||
}
|
||||
let _ = start_host_proxy(mc_addr2, conn, cancel3).await;
|
||||
}
|
||||
Err(e) => tracing::warn!("Host: QUIC incoming error: {}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Broadcast LAN discovery so MC client can find the server
|
||||
let cancel_lan = cancel.clone();
|
||||
let room_name2 = room_name.clone();
|
||||
let local_port = quic_port;
|
||||
tokio::spawn(async move {
|
||||
let _ = broadcast_lan_server(&room_name2, local_port, cancel_lan).await;
|
||||
});
|
||||
|
||||
// Emit event
|
||||
let _ = app.emit("network:status_changed", serde_json::json!({
|
||||
"type": "hosting",
|
||||
"port": quic_port,
|
||||
}));
|
||||
|
||||
Ok(NetworkSessionDto {
|
||||
room_id,
|
||||
local_port: quic_port,
|
||||
session_type: "hosting".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch host's peer info from the server
|
||||
async fn fetch_host_peer_info(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
room_id: Uuid,
|
||||
) -> Option<PeerInfo> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/rooms/{}/host-info", server_url, room_id);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
resp.json::<PeerInfo>().await.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Client side: attempt P2P → relay fallback → start local MC proxy
|
||||
#[tauri::command]
|
||||
pub async fn join_room_network(
|
||||
room_id: String,
|
||||
host_user_id: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
) -> Result<ConnectionInfo, String> {
|
||||
let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?;
|
||||
let host_uuid = host_user_id
|
||||
.as_ref()
|
||||
.and_then(|id| Uuid::parse_str(id).ok());
|
||||
let token = auth_token(&state)?;
|
||||
let user_id = my_user_id(&state)?;
|
||||
|
||||
let mc_local_port = find_mc_port().await;
|
||||
let quic_port = find_quic_port().await.map_err(|e| e.to_string())?;
|
||||
let bind_addr = format!("0.0.0.0:{}", quic_port).parse().unwrap();
|
||||
let endpoint = open_endpoint(bind_addr).map_err(|e| e.to_string())?;
|
||||
let endpoint = Arc::new(endpoint);
|
||||
|
||||
// Build our own peer info (STUN discovery)
|
||||
let my_peer_info = build_my_peer_info(user_id, quic_port).await;
|
||||
tracing::info!("My public addr: {}", my_peer_info.public_addr);
|
||||
|
||||
// Emit status update
|
||||
let _ = app.emit("network:status_changed", serde_json::json!({
|
||||
"type": "connecting",
|
||||
"status": "尝试 P2P 直连...",
|
||||
}));
|
||||
|
||||
// Try P2P connection first if we have host info
|
||||
let mut p2p_conn: Option<quinn::Connection> = None;
|
||||
|
||||
if let Some(host_info) = fetch_host_peer_info(&state.get_server_url(), &token, room_uuid).await {
|
||||
tracing::info!("Got host peer info, attempting P2P to {}", host_info.public_addr);
|
||||
|
||||
match attempt_p2p(user_id, host_info, endpoint.clone(), quic_port).await {
|
||||
Ok(conn) => {
|
||||
tracing::info!("P2P connection established!");
|
||||
p2p_conn = Some(conn);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("P2P failed: {}, falling back to relay", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!("No host peer info available, using relay directly");
|
||||
}
|
||||
|
||||
// Use P2P connection or fall back to relay
|
||||
let (conn, session_type) = if let Some(p2p) = p2p_conn {
|
||||
(p2p, "p2p")
|
||||
} else {
|
||||
// Update status
|
||||
let _ = app.emit("network:status_changed", serde_json::json!({
|
||||
"type": "connecting",
|
||||
"status": "连接中继服务器...",
|
||||
}));
|
||||
|
||||
// Try to find the best relay node
|
||||
let relay_node = fetch_best_node(&state.get_server_url(), &token).await;
|
||||
|
||||
match relay_node {
|
||||
Ok(node) => {
|
||||
tracing::info!("Using relay node: {} ({})", node.name, node.url);
|
||||
match connect_relay(&node.url, room_uuid, &token).await {
|
||||
Ok(c) => (c, "relay"),
|
||||
Err(e) => {
|
||||
tracing::warn!("Relay failed: {}. No connection available.", e);
|
||||
return Err(format!("Could not connect: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
return Err("No relay nodes available".into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let session = NetworkSession::new(room_uuid, mc_local_port, false, session_type);
|
||||
let cancel = session.cancel.clone();
|
||||
let stats = session.stats.clone();
|
||||
|
||||
{
|
||||
let mut s = stats.lock().await;
|
||||
s.connected = true;
|
||||
s.session_type = session_type.to_string();
|
||||
}
|
||||
|
||||
*state.network_session.write().await = Some(session);
|
||||
|
||||
// Start MC proxy in background
|
||||
let conn2 = conn.clone();
|
||||
let cancel2 = cancel.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = start_client_proxy(mc_local_port, conn2, cancel2).await;
|
||||
});
|
||||
|
||||
let connect_addr = format!("127.0.0.1:{}", mc_local_port);
|
||||
|
||||
let _ = app.emit("network:status_changed", serde_json::json!({
|
||||
"type": session_type,
|
||||
"port": mc_local_port,
|
||||
"connect_addr": connect_addr,
|
||||
}));
|
||||
|
||||
Ok(ConnectionInfo {
|
||||
room_id,
|
||||
local_port: mc_local_port,
|
||||
connect_addr,
|
||||
session_type: session_type.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get live connection statistics
|
||||
#[tauri::command]
|
||||
pub async fn get_connection_stats(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ConnectionStats, String> {
|
||||
let guard = state.network_session.read().await;
|
||||
match guard.as_ref() {
|
||||
Some(s) => Ok(s.stats.lock().await.clone()),
|
||||
None => Ok(ConnectionStats {
|
||||
session_type: "none".into(),
|
||||
latency_ms: 0,
|
||||
bytes_sent: 0,
|
||||
bytes_received: 0,
|
||||
connected: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop all active network sessions
|
||||
#[tauri::command]
|
||||
pub async fn stop_network(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let mut guard = state.network_session.write().await;
|
||||
if let Some(s) = guard.take() {
|
||||
s.cancel.notify_waiters();
|
||||
tracing::info!("Network session stopped");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
111
client/src/commands/relay_nodes.rs
Normal file
111
client/src/commands/relay_nodes.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelayNodeDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub region: String,
|
||||
pub is_active: bool,
|
||||
pub priority: i32,
|
||||
pub last_ping_ms: Option<i32>,
|
||||
}
|
||||
|
||||
fn auth_header(state: &AppState) -> Result<String, String> {
|
||||
state
|
||||
.get_access_token()
|
||||
.map(|t| format!("Bearer {}", t))
|
||||
.ok_or_else(|| "not authenticated".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_relay_nodes(state: State<'_, AppState>) -> Result<Vec<RelayNodeDto>, String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/relay/nodes", state.get_server_url());
|
||||
let nodes = client
|
||||
.get(&url)
|
||||
.header("Authorization", token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json::<Vec<RelayNodeDto>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_relay_node(
|
||||
name: String,
|
||||
url: String,
|
||||
region: Option<String>,
|
||||
priority: Option<i32>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<RelayNodeDto, String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let api_url = format!("{}/api/v1/relay/nodes", state.get_server_url());
|
||||
let resp = client
|
||||
.post(&api_url)
|
||||
.header("Authorization", token)
|
||||
.json(&serde_json::json!({ "name": name, "url": url, "region": region, "priority": priority }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
let err: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(err["error"].as_str().unwrap_or("failed").to_string());
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
let node_id = body["id"].as_str().unwrap_or("").to_string();
|
||||
// Return a DTO with what we know
|
||||
Ok(RelayNodeDto {
|
||||
id: node_id,
|
||||
name,
|
||||
url,
|
||||
region: region.unwrap_or_else(|| "auto".into()),
|
||||
is_active: true,
|
||||
priority: priority.unwrap_or(0),
|
||||
last_ping_ms: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_relay_node(
|
||||
node_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/relay/nodes/{}", state.get_server_url(), node_id);
|
||||
client
|
||||
.delete(&url)
|
||||
.header("Authorization", token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn report_relay_ping(
|
||||
node_id: String,
|
||||
ping_ms: i32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/relay/nodes/{}/ping", state.get_server_url(), node_id);
|
||||
client
|
||||
.post(&url)
|
||||
.header("Authorization", token)
|
||||
.json(&serde_json::json!({ "ping_ms": ping_ms }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
187
client/src/commands/rooms.rs
Normal file
187
client/src/commands/rooms.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoomDto {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub owner_id: Uuid,
|
||||
pub owner_username: String,
|
||||
pub max_players: i32,
|
||||
pub current_players: i64,
|
||||
pub is_public: bool,
|
||||
pub has_password: bool,
|
||||
pub game_version: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
fn auth_header(state: &AppState) -> Result<String, String> {
|
||||
state
|
||||
.get_access_token()
|
||||
.map(|t| format!("Bearer {}", t))
|
||||
.ok_or_else(|| "not authenticated".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_rooms(state: State<'_, AppState>) -> Result<Vec<RoomDto>, String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/rooms", state.get_server_url());
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json::<Vec<RoomDto>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_room(
|
||||
name: String,
|
||||
max_players: Option<i32>,
|
||||
is_public: Option<bool>,
|
||||
password: Option<String>,
|
||||
game_version: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Uuid, String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/rooms", state.get_server_url());
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Authorization", token)
|
||||
.json(&serde_json::json!({
|
||||
"name": name,
|
||||
"max_players": max_players,
|
||||
"is_public": is_public,
|
||||
"password": password,
|
||||
"game_version": game_version,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
let err: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(err["error"].as_str().unwrap_or("failed to create room").to_string());
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
let id = body["id"]
|
||||
.as_str()
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
.ok_or("invalid room id")?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn join_room(
|
||||
room_id: Uuid,
|
||||
password: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/rooms/{}/join", state.get_server_url(), room_id);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Authorization", token)
|
||||
.json(&serde_json::json!({ "password": password }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
let err: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(err["error"].as_str().unwrap_or("failed to join").to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn leave_room(room_id: Uuid, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/rooms/{}/leave", state.get_server_url(), room_id);
|
||||
client
|
||||
.post(&url)
|
||||
.header("Authorization", token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoomMemberDto {
|
||||
pub user_id: Uuid,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub is_online: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_room_members(room_id: Uuid, state: State<'_, AppState>) -> Result<Vec<RoomMemberDto>, String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/rooms/{}/members", state.get_server_url(), room_id);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json::<Vec<RoomMemberDto>>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn kick_room_member(
|
||||
room_id: String,
|
||||
target_user_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/api/v1/rooms/{}/members/{}",
|
||||
state.get_server_url(),
|
||||
room_id,
|
||||
target_user_id
|
||||
);
|
||||
let resp = client
|
||||
.delete(&url)
|
||||
.header("Authorization", token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
let err: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(err["error"].as_str().unwrap_or("failed to kick member").to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_room(room_id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let token = auth_header(&state)?;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/rooms/{}", state.get_server_url(), room_id);
|
||||
let resp = client
|
||||
.delete(&url)
|
||||
.header("Authorization", token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
let err: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(err["error"].as_str().unwrap_or("failed to close room").to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
72
client/src/commands/signaling.rs
Normal file
72
client/src/commands/signaling.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::network::signaling::SignalingClient;
|
||||
use crate::state::AppState;
|
||||
|
||||
static mut SIGNALING_CLIENT: Option<SignalingClient> = None;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect_signaling(
|
||||
state: State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let token = state
|
||||
.get_access_token()
|
||||
.ok_or_else(|| "not authenticated".to_string())?;
|
||||
|
||||
let client = SignalingClient::connect(&state.get_server_url(), &token, app)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
unsafe {
|
||||
SIGNALING_CLIENT = Some(client);
|
||||
}
|
||||
|
||||
tracing::info!("Signaling WebSocket connected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect_signaling() -> Result<(), String> {
|
||||
unsafe {
|
||||
SIGNALING_CLIENT = None;
|
||||
}
|
||||
tracing::info!("Signaling WebSocket disconnected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_signaling_message(message: String) -> Result<(), String> {
|
||||
let msg: funmc_shared::protocol::SignalingMessage =
|
||||
serde_json::from_str(&message).map_err(|e| e.to_string())?;
|
||||
|
||||
unsafe {
|
||||
if let Some(client) = &SIGNALING_CLIENT {
|
||||
client.send(msg);
|
||||
Ok(())
|
||||
} else {
|
||||
Err("signaling not connected".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_chat_message(room_id: String, content: String) -> Result<(), String> {
|
||||
use uuid::Uuid;
|
||||
use funmc_shared::protocol::SignalingMessage;
|
||||
|
||||
let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?;
|
||||
let msg = SignalingMessage::SendChat {
|
||||
room_id: room_uuid,
|
||||
content,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
if let Some(client) = &SIGNALING_CLIENT {
|
||||
client.send(msg);
|
||||
Ok(())
|
||||
} else {
|
||||
Err("signaling not connected".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
21
client/src/config.rs
Normal file
21
client/src/config.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
/// FunMC 客户端配置常量
|
||||
|
||||
pub const APP_NAME: &str = "FunMC";
|
||||
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const APP_AUTHOR: &str = "魔幻方";
|
||||
|
||||
pub const DEFAULT_API_SERVER: &str = "https://funmc.com";
|
||||
pub const DEFAULT_RELAY_HOST: &str = "funmc.com";
|
||||
pub const DEFAULT_RELAY_PORT: u16 = 7900;
|
||||
pub const BACKUP_RELAY_PORT: u16 = 7901;
|
||||
|
||||
pub const MC_DEFAULT_PORT: u16 = 25565;
|
||||
pub const MC_PORT_RANGE: std::ops::Range<u16> = 25565..25576;
|
||||
|
||||
pub const QUIC_PORT_RANGE: std::ops::Range<u16> = 34000..34100;
|
||||
|
||||
pub const STUN_SERVER: &str = "stun.l.google.com:19302";
|
||||
|
||||
pub const P2P_TIMEOUT_SECS: u64 = 8;
|
||||
pub const RELAY_TIMEOUT_SECS: u64 = 10;
|
||||
pub const PING_TIMEOUT_SECS: u64 = 3;
|
||||
16
client/src/db.rs
Normal file
16
client/src/db.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
pub async fn init(app: &AppHandle) -> Result<()> {
|
||||
let app_dir = app.path().app_data_dir()?;
|
||||
std::fs::create_dir_all(&app_dir)?;
|
||||
let db_path = app_dir.join("funmc.db");
|
||||
let db_url = format!("sqlite://{}?mode=rwc", db_path.display());
|
||||
|
||||
let pool = SqlitePool::connect(&db_url).await?;
|
||||
sqlx::migrate!("./migrations/sqlite").run(&pool).await?;
|
||||
|
||||
app.manage(pool);
|
||||
Ok(())
|
||||
}
|
||||
73
client/src/lib.rs
Normal file
73
client/src/lib.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod network;
|
||||
pub mod state;
|
||||
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn run() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG")
|
||||
.unwrap_or_else(|_| "funmc_client_lib=debug".into()),
|
||||
),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let app_state = AppState::new();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.manage(app_state)
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = db::init(&handle).await {
|
||||
tracing::error!("DB init error: {}", e);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::auth::login,
|
||||
commands::auth::register,
|
||||
commands::auth::logout,
|
||||
commands::auth::get_current_user,
|
||||
commands::config::set_server_url,
|
||||
commands::config::get_server_url,
|
||||
commands::config::fetch_server_config,
|
||||
commands::config::get_cached_server_config,
|
||||
commands::friends::list_friends,
|
||||
commands::friends::list_requests,
|
||||
commands::friends::send_friend_request,
|
||||
commands::friends::accept_friend_request,
|
||||
commands::friends::remove_friend,
|
||||
commands::rooms::list_rooms,
|
||||
commands::rooms::create_room,
|
||||
commands::rooms::join_room,
|
||||
commands::rooms::leave_room,
|
||||
commands::rooms::get_room_members,
|
||||
commands::rooms::kick_room_member,
|
||||
commands::rooms::close_room,
|
||||
commands::network::start_hosting,
|
||||
commands::network::join_room_network,
|
||||
commands::network::get_connection_stats,
|
||||
commands::network::stop_network,
|
||||
commands::relay_nodes::list_relay_nodes,
|
||||
commands::relay_nodes::add_relay_node,
|
||||
commands::relay_nodes::remove_relay_node,
|
||||
commands::relay_nodes::report_relay_ping,
|
||||
commands::signaling::connect_signaling,
|
||||
commands::signaling::disconnect_signaling,
|
||||
commands::signaling::send_signaling_message,
|
||||
commands::signaling::send_chat_message,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
client/src/main.rs
Normal file
6
client/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Tauri entry point
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
funmc_client_lib::run();
|
||||
}
|
||||
88
client/src/network/lan_discovery.rs
Normal file
88
client/src/network/lan_discovery.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
/// Minecraft LAN server discovery spoofing
|
||||
///
|
||||
/// MC uses UDP multicast 224.0.2.60:4445 to advertise LAN servers.
|
||||
/// We listen on this address and also broadcast fake "LAN server" packets
|
||||
/// so that Minecraft clients see the FunMC room as a local server.
|
||||
///
|
||||
/// Broadcast format: "[MOTD]<name>[/MOTD][AD]<port>[/AD]"
|
||||
|
||||
use anyhow::Result;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const MC_MULTICAST_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 2, 60);
|
||||
const MC_MULTICAST_PORT: u16 = 4445;
|
||||
const BROADCAST_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Start broadcasting a fake LAN server announcement
|
||||
pub async fn broadcast_lan_server(
|
||||
server_name: &str,
|
||||
local_port: u16,
|
||||
cancel: Arc<Notify>,
|
||||
) -> Result<()> {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
sock.set_multicast_ttl_v4(1)?;
|
||||
|
||||
let motd = format!("[MOTD]{}[/MOTD][AD]{}[/AD]", server_name, local_port);
|
||||
let target = SocketAddr::new(IpAddr::V4(MC_MULTICAST_ADDR), MC_MULTICAST_PORT);
|
||||
|
||||
tracing::info!("Broadcasting MC LAN server: {} on port {}", server_name, local_port);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.notified() => {
|
||||
tracing::info!("LAN broadcast stopped");
|
||||
break;
|
||||
}
|
||||
_ = tokio::time::sleep(BROADCAST_INTERVAL) => {
|
||||
if let Err(e) = sock.send_to(motd.as_bytes(), target).await {
|
||||
tracing::warn!("LAN broadcast send error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Listen for MC LAN server announcements from the multicast group
|
||||
/// Returns parsed (name, port) pairs
|
||||
pub async fn listen_lan_servers(
|
||||
cancel: Arc<Notify>,
|
||||
tx: tokio::sync::mpsc::Sender<(String, u16)>,
|
||||
) -> Result<()> {
|
||||
let sock = UdpSocket::bind(("0.0.0.0", MC_MULTICAST_PORT)).await?;
|
||||
sock.join_multicast_v4(MC_MULTICAST_ADDR, Ipv4Addr::UNSPECIFIED)?;
|
||||
sock.set_multicast_loop_v4(false)?;
|
||||
|
||||
let mut buf = [0u8; 1024];
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.notified() => break,
|
||||
result = sock.recv_from(&mut buf) => {
|
||||
if let Ok((n, _)) = result {
|
||||
let text = String::from_utf8_lossy(&buf[..n]);
|
||||
if let Some(parsed) = parse_motd(&text) {
|
||||
let _ = tx.send(parsed).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_motd(text: &str) -> Option<(String, u16)> {
|
||||
let name = extract_between(text, "[MOTD]", "[/MOTD]")?;
|
||||
let port_str = extract_between(text, "[AD]", "[/AD]")?;
|
||||
let port = port_str.parse::<u16>().ok()?;
|
||||
Some((name.to_string(), port))
|
||||
}
|
||||
|
||||
fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
|
||||
let s = text.find(start)? + start.len();
|
||||
let e = text[s..].find(end)? + s;
|
||||
Some(&text[s..e])
|
||||
}
|
||||
144
client/src/network/minecraft_proxy.rs
Normal file
144
client/src/network/minecraft_proxy.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
/// Minecraft TCP → QUIC tunnel proxy
|
||||
|
||||
use anyhow::Result;
|
||||
use quinn::Connection;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::Notify;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn start_client_proxy(
|
||||
local_port: u16,
|
||||
conn: Connection,
|
||||
cancel: Arc<Notify>,
|
||||
) -> Result<()> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", local_port)).await?;
|
||||
tracing::info!("MC proxy listening on 127.0.0.1:{}", local_port);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.notified() => {
|
||||
tracing::info!("MC proxy shutting down");
|
||||
break;
|
||||
}
|
||||
accept = listener.accept() => {
|
||||
match accept {
|
||||
Ok((tcp_stream, _peer)) => {
|
||||
let conn2 = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = proxy_tcp_to_quic(tcp_stream, conn2).await {
|
||||
tracing::debug!("proxy_tcp_to_quic: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("MC proxy accept error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn proxy_tcp_to_quic(tcp: TcpStream, conn: Connection) -> Result<()> {
|
||||
let (mut qs, mut qr) = conn.open_bi().await?;
|
||||
let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp);
|
||||
|
||||
let t1 = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
let n = tcp_r.read(&mut buf).await?;
|
||||
if n == 0 { break; }
|
||||
qs.write_all(&buf[..n]).await?;
|
||||
}
|
||||
let _ = qs.finish();
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
|
||||
let t2 = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
let n = qr.read(&mut buf).await?.unwrap_or(0);
|
||||
if n == 0 { break; }
|
||||
tcp_w.write_all(&buf[..n]).await?;
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
|
||||
let _ = tokio::try_join!(t1, t2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start_host_proxy(
|
||||
mc_server_addr: SocketAddr,
|
||||
conn: Connection,
|
||||
cancel: Arc<Notify>,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Host proxy forwarding QUIC streams to MC server at {}", mc_server_addr);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.notified() => break,
|
||||
stream = conn.accept_bi() => {
|
||||
match stream {
|
||||
Ok((qs, qr)) => {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = proxy_quic_to_mc(qs, qr, mc_server_addr).await {
|
||||
tracing::debug!("proxy_quic_to_mc: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("host proxy stream accept error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn proxy_quic_to_mc(
|
||||
mut qs: quinn::SendStream,
|
||||
mut qr: quinn::RecvStream,
|
||||
mc_addr: SocketAddr,
|
||||
) -> Result<()> {
|
||||
let tcp = TcpStream::connect(mc_addr).await?;
|
||||
let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp);
|
||||
|
||||
let t1 = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
let n = qr.read(&mut buf).await?.unwrap_or(0);
|
||||
if n == 0 { break; }
|
||||
tcp_w.write_all(&buf[..n]).await?;
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
|
||||
let t2 = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
let n = tcp_r.read(&mut buf).await?;
|
||||
if n == 0 { break; }
|
||||
qs.write_all(&buf[..n]).await?;
|
||||
}
|
||||
let _ = qs.finish();
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
|
||||
let _ = tokio::try_join!(t1, t2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_mc_port() -> u16 {
|
||||
for port in 25565u16..=25575 {
|
||||
if TcpListener::bind(("127.0.0.1", port)).await.is_ok() {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
25565
|
||||
}
|
||||
9
client/src/network/mod.rs
Normal file
9
client/src/network/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod lan_discovery;
|
||||
pub mod minecraft_proxy;
|
||||
pub mod nat;
|
||||
pub mod p2p;
|
||||
pub mod quic;
|
||||
pub mod relay;
|
||||
pub mod relay_selector;
|
||||
pub mod session;
|
||||
pub mod signaling;
|
||||
149
client/src/network/nat.rs
Normal file
149
client/src/network/nat.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
/// NAT type detection via STUN (RFC 5389)
|
||||
/// Uses stun.l.google.com:19302 to discover public IP and port
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
const STUN_SERVER: &str = "stun.l.google.com:19302";
|
||||
const STUN_BINDING_REQUEST: u16 = 0x0001;
|
||||
const STUN_MAGIC_COOKIE: u32 = 0x2112A442;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
pub enum NatType {
|
||||
None, // Direct internet connection
|
||||
FullCone, // Any external host can send to our mapped addr
|
||||
RestrictedCone, // External must receive from us first (IP)
|
||||
PortRestricted, // External must receive from us first (IP+port)
|
||||
Symmetric, // Different mapping per destination (hardest to pierce)
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StunResult {
|
||||
pub public_addr: SocketAddr,
|
||||
pub nat_type: NatType,
|
||||
}
|
||||
|
||||
/// Build a minimal STUN Binding Request packet
|
||||
fn build_binding_request(transaction_id: &[u8; 12]) -> Vec<u8> {
|
||||
let mut pkt = Vec::with_capacity(20);
|
||||
pkt.extend_from_slice(&STUN_BINDING_REQUEST.to_be_bytes());
|
||||
pkt.extend_from_slice(&0u16.to_be_bytes()); // length
|
||||
pkt.extend_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
|
||||
pkt.extend_from_slice(transaction_id);
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Parse XOR-MAPPED-ADDRESS or MAPPED-ADDRESS from STUN response
|
||||
fn parse_mapped_address(data: &[u8]) -> Option<SocketAddr> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
let msg_len = u16::from_be_bytes([data[2], data[3]]) as usize;
|
||||
let mut pos = 20;
|
||||
while pos + 4 <= 20 + msg_len {
|
||||
let attr_type = u16::from_be_bytes([data[pos], data[pos + 1]]);
|
||||
let attr_len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize;
|
||||
pos += 4;
|
||||
if pos + attr_len > data.len() {
|
||||
break;
|
||||
}
|
||||
let attr_data = &data[pos..pos + attr_len];
|
||||
match attr_type {
|
||||
// XOR-MAPPED-ADDRESS (0x0020)
|
||||
0x0020 => {
|
||||
if attr_data.len() >= 8 && attr_data[1] == 0x01 {
|
||||
let xport = u16::from_be_bytes([attr_data[2], attr_data[3]])
|
||||
^ (STUN_MAGIC_COOKIE >> 16) as u16;
|
||||
let xip = u32::from_be_bytes([attr_data[4], attr_data[5], attr_data[6], attr_data[7]])
|
||||
^ STUN_MAGIC_COOKIE;
|
||||
let ip = std::net::Ipv4Addr::from(xip);
|
||||
return Some(SocketAddr::from((ip, xport)));
|
||||
}
|
||||
}
|
||||
// MAPPED-ADDRESS (0x0001)
|
||||
0x0001 => {
|
||||
if attr_data.len() >= 8 && attr_data[1] == 0x01 {
|
||||
let port = u16::from_be_bytes([attr_data[2], attr_data[3]]);
|
||||
let ip = std::net::Ipv4Addr::new(attr_data[4], attr_data[5], attr_data[6], attr_data[7]);
|
||||
return Some(SocketAddr::from((ip, port)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
pos += attr_len;
|
||||
// 4-byte alignment
|
||||
if attr_len % 4 != 0 {
|
||||
pos += 4 - (attr_len % 4);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Send one STUN binding request and return the mapped address
|
||||
pub async fn stun_query(sock: &UdpSocket, stun_addr: &str) -> Result<SocketAddr> {
|
||||
let tid: [u8; 12] = rand::random();
|
||||
let pkt = build_binding_request(&tid);
|
||||
|
||||
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(stun_addr).await?.collect();
|
||||
let server = addrs.into_iter().next().ok_or_else(|| anyhow!("STUN server DNS failed"))?;
|
||||
|
||||
sock.send_to(&pkt, server).await?;
|
||||
|
||||
let mut buf = [0u8; 512];
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(anyhow!("STUN timeout"));
|
||||
}
|
||||
match tokio::time::timeout(remaining, sock.recv_from(&mut buf)).await {
|
||||
Ok(Ok((n, _))) => {
|
||||
if let Some(addr) = parse_mapped_address(&buf[..n]) {
|
||||
return Ok(addr);
|
||||
}
|
||||
}
|
||||
_ => return Err(anyhow!("STUN failed")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform basic NAT detection: get public IP and classify NAT type
|
||||
pub async fn detect_nat() -> Result<StunResult> {
|
||||
// Bind a local UDP socket
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
|
||||
// Query primary STUN server
|
||||
let mapped1 = stun_query(&sock, STUN_SERVER).await?;
|
||||
|
||||
// Query again to same server to check if mapping is stable
|
||||
let mapped2 = stun_query(&sock, STUN_SERVER).await?;
|
||||
|
||||
let nat_type = if mapped1 == mapped2 {
|
||||
// Same port from same destination = Full Cone or Restricted (simplified)
|
||||
// Deep detection would require a second STUN server, omitted for now
|
||||
NatType::FullCone
|
||||
} else {
|
||||
NatType::Symmetric
|
||||
};
|
||||
|
||||
Ok(StunResult {
|
||||
public_addr: mapped1,
|
||||
nat_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get local LAN addresses
|
||||
pub fn get_local_addrs(port: u16) -> Vec<SocketAddr> {
|
||||
let mut addrs = Vec::new();
|
||||
if let Ok(ifaces) = local_ip_address::list_afinet_netifas() {
|
||||
for (_, ip) in ifaces {
|
||||
if !ip.is_loopback() {
|
||||
addrs.push(SocketAddr::new(ip, port));
|
||||
}
|
||||
}
|
||||
}
|
||||
addrs
|
||||
}
|
||||
129
client/src/network/p2p.rs
Normal file
129
client/src/network/p2p.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
/// P2P UDP hole punching using QUIC (quinn)
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Both peers bind a UDP socket and discover their public address via STUN
|
||||
/// 2. Exchange PeerInfo (public addr + local addrs) through the signaling server
|
||||
/// 3. Send simultaneous UDP probes to pierce NAT holes
|
||||
/// 4. Attempt QUIC connection to peer's public + local addresses
|
||||
/// 5. First successful connection wins
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use funmc_shared::protocol::PeerInfo;
|
||||
use quinn::{Connection, Endpoint};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::network::nat::{detect_nat, get_local_addrs, NatType};
|
||||
|
||||
pub const QUIC_P2P_PORT_RANGE: std::ops::Range<u16> = 34000..34100;
|
||||
|
||||
/// Find a free UDP port in our P2P range
|
||||
pub async fn find_quic_port() -> Result<u16> {
|
||||
for port in QUIC_P2P_PORT_RANGE {
|
||||
if tokio::net::UdpSocket::bind(("0.0.0.0", port)).await.is_ok() {
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
// Fall back to OS-assigned
|
||||
let sock = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
|
||||
Ok(sock.local_addr()?.port())
|
||||
}
|
||||
|
||||
/// Attempt P2P hole punch. Returns the QUIC Connection and endpoint on success.
|
||||
pub async fn attempt_p2p(
|
||||
my_user_id: Uuid,
|
||||
peer_info: PeerInfo,
|
||||
endpoint: Arc<Endpoint>,
|
||||
my_port: u16,
|
||||
) -> Result<Connection> {
|
||||
// Collect all candidate addresses to try
|
||||
let mut candidates: Vec<SocketAddr> = Vec::new();
|
||||
|
||||
// Parse peer's public address
|
||||
if let Ok(addr) = peer_info.public_addr.parse::<SocketAddr>() {
|
||||
candidates.push(addr);
|
||||
}
|
||||
for local_addr_str in &peer_info.local_addrs {
|
||||
if let Ok(addr) = local_addr_str.parse::<SocketAddr>() {
|
||||
candidates.push(addr);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("P2P: trying {} candidates for peer {}", candidates.len(), peer_info.user_id);
|
||||
|
||||
// Try all candidates concurrently with a 5 second total timeout
|
||||
let ep = endpoint.clone();
|
||||
let result = timeout(Duration::from_secs(8), async move {
|
||||
let mut handles = Vec::new();
|
||||
for addr in candidates {
|
||||
let ep2 = ep.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
// Send a few UDP probes first to open NAT hole
|
||||
for _ in 0..3 {
|
||||
let _ = ep2.connect(addr, "funmc");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
// Attempt real QUIC connection
|
||||
match ep2.connect(addr, "funmc") {
|
||||
Ok(connecting) => {
|
||||
match timeout(Duration::from_secs(3), connecting).await {
|
||||
Ok(Ok(conn)) => Some(conn),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
if let Ok(Some(conn)) = handle.await {
|
||||
return Some(conn);
|
||||
}
|
||||
}
|
||||
None
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(Some(conn)) => {
|
||||
tracing::info!("P2P connection established with {}", peer_info.user_id);
|
||||
Ok(conn)
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("P2P failed with {}, will use relay", peer_info.user_id);
|
||||
Err(anyhow!("P2P hole punch failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect our own PeerInfo for signaling exchange
|
||||
pub async fn build_my_peer_info(user_id: Uuid, port: u16) -> PeerInfo {
|
||||
let (public_addr, nat_type) = match detect_nat().await {
|
||||
Ok(r) => (r.public_addr.to_string(), r.nat_type),
|
||||
Err(_) => (format!("0.0.0.0:{}", port), crate::network::nat::NatType::Unknown),
|
||||
};
|
||||
|
||||
let nat_type_shared = match nat_type {
|
||||
NatType::None => funmc_shared::protocol::NatType::None,
|
||||
NatType::FullCone => funmc_shared::protocol::NatType::FullCone,
|
||||
NatType::RestrictedCone => funmc_shared::protocol::NatType::RestrictedCone,
|
||||
NatType::PortRestricted => funmc_shared::protocol::NatType::PortRestrictedCone,
|
||||
NatType::Symmetric => funmc_shared::protocol::NatType::Symmetric,
|
||||
NatType::Unknown => funmc_shared::protocol::NatType::Unknown,
|
||||
};
|
||||
|
||||
let local_addrs: Vec<String> = get_local_addrs(port)
|
||||
.into_iter()
|
||||
.map(|a| a.to_string())
|
||||
.collect();
|
||||
|
||||
PeerInfo {
|
||||
user_id,
|
||||
public_addr,
|
||||
local_addrs,
|
||||
nat_type: nat_type_shared,
|
||||
}
|
||||
}
|
||||
99
client/src/network/quic.rs
Normal file
99
client/src/network/quic.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
/// QUIC endpoint management using quinn
|
||||
/// Provides a shared QUIC endpoint for both P2P and relay connections
|
||||
|
||||
use anyhow::Result;
|
||||
use quinn::{ClientConfig, Endpoint, ServerConfig};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Build a self-signed TLS certificate for QUIC
|
||||
pub fn make_self_signed() -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["funmc".to_string()])?;
|
||||
let cert_der = CertificateDer::from(cert.cert);
|
||||
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()));
|
||||
Ok((vec![cert_der], key_der))
|
||||
}
|
||||
|
||||
/// Create a QUIC ServerConfig with a self-signed cert
|
||||
pub fn make_server_config() -> Result<ServerConfig> {
|
||||
let (certs, key) = make_self_signed()?;
|
||||
let mut tls = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?;
|
||||
tls.alpn_protocols = vec![b"funmc".to_vec()];
|
||||
let mut sc = ServerConfig::with_crypto(Arc::new(
|
||||
quinn::crypto::rustls::QuicServerConfig::try_from(tls)?,
|
||||
));
|
||||
// Tune transport params for game traffic
|
||||
let mut transport = quinn::TransportConfig::default();
|
||||
transport.max_idle_timeout(Some(std::time::Duration::from_secs(30).try_into()?));
|
||||
transport.keep_alive_interval(Some(std::time::Duration::from_secs(5)));
|
||||
sc.transport_config(Arc::new(transport));
|
||||
Ok(sc)
|
||||
}
|
||||
|
||||
/// Create a QUIC ClientConfig that accepts any self-signed cert (P2P peers)
|
||||
pub fn make_client_config_insecure() -> ClientConfig {
|
||||
let crypto = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(AcceptAnyCert))
|
||||
.with_no_client_auth();
|
||||
let mut cc = ClientConfig::new(Arc::new(
|
||||
quinn::crypto::rustls::QuicClientConfig::try_from(crypto).unwrap(),
|
||||
));
|
||||
let mut transport = quinn::TransportConfig::default();
|
||||
transport.max_idle_timeout(Some(std::time::Duration::from_secs(30).try_into().unwrap()));
|
||||
transport.keep_alive_interval(Some(std::time::Duration::from_secs(5)));
|
||||
cc.transport_config(Arc::new(transport));
|
||||
cc
|
||||
}
|
||||
|
||||
/// Open a QUIC endpoint bound to the given address
|
||||
pub fn open_endpoint(bind_addr: SocketAddr) -> Result<Endpoint> {
|
||||
let sc = make_server_config()?;
|
||||
let mut ep = Endpoint::server(sc, bind_addr)?;
|
||||
ep.set_default_client_config(make_client_config_insecure());
|
||||
Ok(ep)
|
||||
}
|
||||
|
||||
// ---- TLS cert verifier that accepts anything (P2P peers use self-signed) ----
|
||||
#[derive(Debug)]
|
||||
struct AcceptAnyCert;
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for AcceptAnyCert {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer,
|
||||
_intermediates: &[CertificateDer],
|
||||
_server_name: &rustls::pki_types::ServerName,
|
||||
_ocsp: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
68
client/src/network/relay.rs
Normal file
68
client/src/network/relay.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
/// QUIC relay client — used when P2P hole punch fails
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use quinn::Connection;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::network::quic::open_endpoint;
|
||||
use crate::network::relay_selector::DEFAULT_RELAY_PORT;
|
||||
|
||||
/// Connect to a relay server's QUIC endpoint
|
||||
pub async fn connect_relay(
|
||||
relay_url: &str,
|
||||
room_id: Uuid,
|
||||
access_token: &str,
|
||||
) -> Result<Connection> {
|
||||
let (host, port) = parse_relay_url(relay_url);
|
||||
|
||||
let relay_addr: SocketAddr = {
|
||||
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(format!("{}:{}", host, port))
|
||||
.await?
|
||||
.collect();
|
||||
addrs.into_iter().next().ok_or_else(|| anyhow!("relay DNS failed"))?
|
||||
};
|
||||
|
||||
let ep = open_endpoint("0.0.0.0:0".parse()?)?;
|
||||
|
||||
tracing::info!("Connecting to relay at {}", relay_addr);
|
||||
let connecting = ep.connect(relay_addr, &host)?;
|
||||
let conn = timeout(Duration::from_secs(10), connecting).await
|
||||
.map_err(|_| anyhow!("relay connection timed out"))?
|
||||
.map_err(|e| anyhow!("relay QUIC error: {}", e))?;
|
||||
|
||||
// Send auth handshake on a unidirectional stream using write_chunk
|
||||
let mut stream = conn.open_uni().await?;
|
||||
let handshake = serde_json::json!({
|
||||
"token": access_token,
|
||||
"room_id": room_id.to_string(),
|
||||
});
|
||||
let msg = serde_json::to_vec(&handshake)?;
|
||||
let len = (msg.len() as u32).to_be_bytes();
|
||||
stream.write_chunk(bytes::Bytes::copy_from_slice(&len)).await
|
||||
.map_err(|e| anyhow!("{}", e))?;
|
||||
stream.write_chunk(bytes::Bytes::from(msg)).await
|
||||
.map_err(|e| anyhow!("{}", e))?;
|
||||
let _ = stream.finish();
|
||||
let _ = stream.stopped().await;
|
||||
|
||||
tracing::info!("Relay handshake sent for room {}", room_id);
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn parse_relay_url(url: &str) -> (String, u16) {
|
||||
let cleaned = url
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.trim_start_matches("quic://");
|
||||
|
||||
if let Some((host, port_str)) = cleaned.rsplit_once(':') {
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
return (host.to_string(), port);
|
||||
}
|
||||
}
|
||||
|
||||
(cleaned.to_string(), DEFAULT_RELAY_PORT)
|
||||
}
|
||||
118
client/src/network/relay_selector.rs
Normal file
118
client/src/network/relay_selector.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
/// Relay node selection — client-side multi-node support
|
||||
/// Fetches relay nodes from server, measures latency, picks lowest RTT
|
||||
|
||||
use anyhow::Result;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const DEFAULT_RELAY_PORT: u16 = 7900;
|
||||
pub const BACKUP_RELAY_PORT: u16 = 7901;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RelayNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub region: String,
|
||||
pub priority: i32,
|
||||
pub last_ping_ms: Option<i32>,
|
||||
}
|
||||
|
||||
impl RelayNode {
|
||||
pub fn from_server_url(server_url: &str) -> Self {
|
||||
let relay_url = extract_relay_url(server_url);
|
||||
Self {
|
||||
id: "server".into(),
|
||||
name: "服务器节点".into(),
|
||||
url: relay_url,
|
||||
region: "auto".into(),
|
||||
priority: 100,
|
||||
last_ping_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_relay_url(relay_url: &str) -> Self {
|
||||
Self {
|
||||
id: "configured".into(),
|
||||
name: "配置节点".into(),
|
||||
url: relay_url.to_string(),
|
||||
region: "auto".into(),
|
||||
priority: 100,
|
||||
last_ping_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_addr(&self) -> Option<std::net::SocketAddr> {
|
||||
self.url.to_socket_addrs().ok()?.next()
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_relay_url(server_url: &str) -> String {
|
||||
let host = server_url
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or("localhost");
|
||||
format!("{}:{}", host, DEFAULT_RELAY_PORT)
|
||||
}
|
||||
|
||||
/// Fetch available relay nodes from server and return sorted by latency
|
||||
pub async fn fetch_best_node(server_url: &str, token: &str) -> Result<RelayNode> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/relay/nodes", server_url);
|
||||
|
||||
let default_node = RelayNode::from_server_url(server_url);
|
||||
|
||||
let nodes: Vec<RelayNode> = match client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.timeout(Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or_else(|_| vec![default_node.clone()]),
|
||||
Err(_) => vec![default_node.clone()],
|
||||
};
|
||||
|
||||
let nodes = if nodes.is_empty() { vec![default_node.clone()] } else { nodes };
|
||||
|
||||
let mut best: Option<(RelayNode, u128)> = None;
|
||||
let mut handles = vec![];
|
||||
|
||||
for node in nodes.clone() {
|
||||
handles.push(tokio::spawn(async move {
|
||||
let rtt = ping_relay_node(&node).await;
|
||||
(node, rtt)
|
||||
}));
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
if let Ok((node, Some(rtt))) = handle.await {
|
||||
if best.is_none() || rtt < best.as_ref().unwrap().1 {
|
||||
best = Some((node, rtt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(best
|
||||
.map(|(n, _)| n)
|
||||
.unwrap_or_else(|| nodes.into_iter().next().unwrap_or(default_node)))
|
||||
}
|
||||
|
||||
async fn ping_relay_node(node: &RelayNode) -> Option<u128> {
|
||||
let addr = node.parse_addr()?;
|
||||
let start = Instant::now();
|
||||
|
||||
let socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await.ok()?;
|
||||
socket.connect(addr).await.ok()?;
|
||||
|
||||
let ping_data = b"FUNMC_PING";
|
||||
socket.send(ping_data).await.ok()?;
|
||||
|
||||
let mut buf = [0u8; 32];
|
||||
match tokio::time::timeout(Duration::from_secs(3), socket.recv(&mut buf)).await {
|
||||
Ok(Ok(_)) => Some(start.elapsed().as_millis()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
42
client/src/network/session.rs
Normal file
42
client/src/network/session.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
// Network session state held in AppState
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ConnectionStats {
|
||||
pub session_type: String,
|
||||
pub latency_ms: u32,
|
||||
pub bytes_sent: u64,
|
||||
pub bytes_received: u64,
|
||||
pub connected: bool,
|
||||
}
|
||||
|
||||
pub struct NetworkSession {
|
||||
pub room_id: Uuid,
|
||||
pub local_port: u16,
|
||||
pub is_host: bool,
|
||||
pub session_type: String, // "p2p" | "relay"
|
||||
pub stats: Arc<Mutex<ConnectionStats>>,
|
||||
/// Cancel token: drop to stop all tasks
|
||||
pub cancel: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl NetworkSession {
|
||||
pub fn new(room_id: Uuid, local_port: u16, is_host: bool, session_type: &str) -> Self {
|
||||
Self {
|
||||
room_id,
|
||||
local_port,
|
||||
is_host,
|
||||
session_type: session_type.to_string(),
|
||||
stats: Arc::new(Mutex::new(ConnectionStats {
|
||||
session_type: session_type.to_string(),
|
||||
latency_ms: 0,
|
||||
bytes_sent: 0,
|
||||
bytes_received: 0,
|
||||
connected: false,
|
||||
})),
|
||||
cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
73
client/src/network/signaling.rs
Normal file
73
client/src/network/signaling.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
/// WebSocket signaling client
|
||||
/// Connects to the server's /api/v1/ws endpoint and handles incoming messages
|
||||
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use funmc_shared::protocol::SignalingMessage;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
|
||||
pub struct SignalingClient {
|
||||
pub tx: mpsc::UnboundedSender<SignalingMessage>,
|
||||
}
|
||||
|
||||
impl SignalingClient {
|
||||
/// Spawn signaling connection in background, forward incoming events to Tauri
|
||||
pub async fn connect(server_url: &str, token: &str, app: AppHandle) -> Result<Self> {
|
||||
let ws_url = server_url
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://");
|
||||
let ws_url = format!("{}/api/v1/ws?token={}", ws_url, token);
|
||||
|
||||
let (ws_stream, _) = connect_async(&ws_url).await?;
|
||||
let (mut ws_tx, mut ws_rx) = ws_stream.split();
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SignalingMessage>();
|
||||
|
||||
// Forward outgoing messages to WebSocket
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if let Ok(json) = serde_json::to_string(&msg) {
|
||||
if ws_tx.send(Message::Text(json)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Receive incoming messages and emit Tauri events
|
||||
tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
if let Ok(signal) = serde_json::from_str::<SignalingMessage>(&text) {
|
||||
let event_name = match &signal {
|
||||
SignalingMessage::FriendRequest { .. } => "signaling:friend_request",
|
||||
SignalingMessage::FriendAccepted { .. } => "signaling:friend_accepted",
|
||||
SignalingMessage::RoomInvite { .. } => "signaling:room_invite",
|
||||
SignalingMessage::MemberJoined { .. } => "signaling:member_joined",
|
||||
SignalingMessage::MemberLeft { .. } => "signaling:member_left",
|
||||
SignalingMessage::Kicked { .. } => "signaling:kicked",
|
||||
SignalingMessage::RoomClosed { .. } => "signaling:room_closed",
|
||||
SignalingMessage::UserOnline { .. } => "signaling:user_online",
|
||||
SignalingMessage::UserOffline { .. } => "signaling:user_offline",
|
||||
SignalingMessage::ChatMessage { .. } => "signaling:chat_message",
|
||||
SignalingMessage::Offer { .. }
|
||||
| SignalingMessage::Answer { .. }
|
||||
| SignalingMessage::IceCandidate { .. } => "signaling:network",
|
||||
_ => continue,
|
||||
};
|
||||
let payload = serde_json::to_value(&signal).unwrap_or_default();
|
||||
let _ = app.emit(event_name, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self { tx })
|
||||
}
|
||||
|
||||
pub fn send(&self, msg: SignalingMessage) {
|
||||
let _ = self.tx.send(msg);
|
||||
}
|
||||
}
|
||||
77
client/src/state.rs
Normal file
77
client/src/state.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::network::session::NetworkSession;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CurrentUser {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub avatar_seed: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tokens {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ServerConfig {
|
||||
pub server_name: String,
|
||||
pub server_url: String,
|
||||
pub relay_url: String,
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub server_url: Mutex<String>,
|
||||
pub server_config: Mutex<Option<ServerConfig>>,
|
||||
pub user: Mutex<Option<CurrentUser>>,
|
||||
pub tokens: Mutex<Option<Tokens>>,
|
||||
pub network_session: Arc<RwLock<Option<NetworkSession>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
let server_url = std::env::var("FUNMC_SERVER")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".into());
|
||||
Self {
|
||||
server_url: Mutex::new(server_url),
|
||||
server_config: Mutex::new(None),
|
||||
user: Mutex::new(None),
|
||||
tokens: Mutex::new(None),
|
||||
network_session: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_server_url(&self) -> String {
|
||||
self.server_url.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn set_server_url(&self, url: String) {
|
||||
*self.server_url.lock().unwrap() = url;
|
||||
}
|
||||
|
||||
pub fn get_access_token(&self) -> Option<String> {
|
||||
self.tokens.lock().ok()?.as_ref().map(|t| t.access_token.clone())
|
||||
}
|
||||
|
||||
pub fn set_auth(&self, user: CurrentUser, tokens: Tokens) {
|
||||
*self.user.lock().unwrap() = Some(user);
|
||||
*self.tokens.lock().unwrap() = Some(tokens);
|
||||
}
|
||||
|
||||
pub fn clear_auth(&self) {
|
||||
*self.user.lock().unwrap() = None;
|
||||
*self.tokens.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
pub fn get_user_id(&self) -> Option<Uuid> {
|
||||
self.user.lock().ok()?.as_ref().map(|u| u.id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user