Initial commit: FunConnect project with server, relay, client and admin panel

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-24 20:56:36 +08:00
parent eb6e901440
commit b6891483ae
167 changed files with 16147 additions and 106 deletions

View 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])
}

View 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
}

View 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
View 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
View 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,
}
}

View 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()
}
}

View 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)
}

View 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,
}
}

View 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()),
}
}
}

View 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);
}
}