use eyre::{Context, bail}; use futures::future::join_all; use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant, SystemTime}; use tokio::net::{TcpStream, lookup_host}; use tokio::process::{Child, Command}; use tokio::sync::Mutex; use tokio::task::JoinHandle; use tracing::{info, warn}; use super::multiplayer::{self, MultiplayerProvider}; const API_BASE: &str = "https://hongshi.site"; const NODE_ENDPOINT: &str = "https://hongshi.site/newserver.json"; const CONTROL_PORT: u16 = 7000; const NODE_PROBE_TIMEOUT: Duration = Duration::from_millis(1500); const START_TIMEOUT: Duration = Duration::from_secs(20); const MAX_BINARY_SIZE: u64 = 256 * 1024 * 1024; const DOWNLOAD_URL_CACHE_TTL: Duration = Duration::from_secs(28 * 60); const DOWNLOAD_RATE_LIMIT_RETRY_DELAY: Duration = Duration::from_secs(61); static HONGSHI_STATE: LazyLock> = LazyLock::new(|| Mutex::new(HongshiState::default())); static HONGSHI_RUNTIME: LazyLock> = LazyLock::new(|| Mutex::new(HongshiRuntime::default())); static HONGSHI_OPERATION: LazyLock> = LazyLock::new(|| Mutex::new(())); static HONGSHI_DOWNLOAD_URL: LazyLock>> = LazyLock::new(|| Mutex::new(None)); static DETECTED_PORTS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static LAN_PORT_PATTERNS: LazyLock> = LazyLock::new(|| { [ r"(?i)local game hosted on port\s+(\d{1,5})", r"(?i)started serving on(?:\s+port)?\s+(\d{1,5})", r"(?i)successfully opened port\s+(\d{1,5})", ] .into_iter() .map(|pattern| Regex::new(pattern).expect("LAN port regex should compile")) .collect() }); #[derive( Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, )] #[serde(rename_all = "snake_case")] pub enum HongshiStatus { Unsupported, #[default] Idle, WaitingForPort, Downloading, SelectingNode, Starting, Open, Closed, Error, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum HongshiErrorType { Unsupported, NodeList, NodeUnavailable, InvalidPort, Install, KernelStart, KernelExit, StatusFile, Unknown, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct HongshiNode { pub name: String, pub address: String, pub latency_ms: Option, pub reachable: bool, pub cached: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct DetectedLanPort { pub instance_id: String, pub instance_name: String, pub process_id: String, pub port: u16, pub detected_at: String, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct HongshiState { pub supported: bool, pub status: HongshiStatus, pub local_port: Option, pub node: Option, pub public_address: Option, pub created_at: Option, pub last_exit_code: Option, pub error_type: Option, pub error_message: Option, pub bound_instance_id: Option, pub port_changed: bool, pub binary_installed: bool, pub download_progress: Option, } impl Default for HongshiState { fn default() -> Self { let supported = is_supported(); Self { supported, status: if supported { HongshiStatus::Idle } else { HongshiStatus::Unsupported }, local_port: None, node: None, public_address: None, created_at: None, last_exit_code: None, error_type: None, error_message: None, bound_instance_id: None, port_changed: false, binary_installed: binary_installed(), download_progress: None, } } } #[derive(Default)] struct HongshiRuntime { child: Option>>, job: Option, monitor: Option>, status_file: Option, stopping: bool, } #[derive(Debug)] struct TunnelStatusFile { status: String, server: String, port: i32, created: Option, } #[cfg(target_os = "windows")] #[derive(Debug)] struct JobGuard(windows::Win32::Foundation::HANDLE); #[cfg(target_os = "windows")] unsafe impl Send for JobGuard {} #[cfg(target_os = "windows")] impl Drop for JobGuard { fn drop(&mut self) { unsafe { let _ = windows::Win32::Foundation::CloseHandle(self.0); } } } #[cfg(not(target_os = "windows"))] #[derive(Debug)] struct JobGuard; #[cfg(target_os = "windows")] fn attach_kill_on_close_job(process_id: u32) -> eyre::Result { use windows::Win32::System::JobObjects::{ AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject, }; use windows::Win32::System::Threading::{ OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE, }; unsafe { let job = CreateJobObjectW(None, windows::core::PCWSTR::null()) .wrap_err("failed to create RedStone job object")?; let guard = JobGuard(job); let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; SetInformationJobObject( job, JobObjectExtendedLimitInformation, std::ptr::from_ref(&information).cast(), std::mem::size_of::() as u32, ) .wrap_err("failed to configure RedStone job object")?; let process = OpenProcess( PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, process_id, ) .wrap_err("failed to open RedStone process for job assignment")?; let assignment = AssignProcessToJobObject(job, process); let _ = windows::Win32::Foundation::CloseHandle(process); assignment .wrap_err("failed to assign RedStone process to job object")?; Ok(guard) } } #[cfg(not(target_os = "windows"))] fn attach_kill_on_close_job(_process_id: u32) -> eyre::Result { Ok(JobGuard) } pub fn is_supported() -> bool { cfg!(any( all(target_os = "windows", target_arch = "x86_64"), all( target_os = "macos", any(target_arch = "x86_64", target_arch = "aarch64") ), all( target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64") ), )) } fn hongshi_root() -> PathBuf { let base_dir = crate::state::DirectoryInfo::global_handle_if_ready() .map(|directories| directories.config_dir.clone()) .or_else(|| { crate::state::DirectoryInfo::initial_settings_dir_path( crate::brand::BUNDLE_IDENTIFIER, ) }) .unwrap_or_else(|| PathBuf::from(".")); base_dir.join("hongshi") } pub fn logs_dir() -> PathBuf { hongshi_root().join("logs") } fn binary_path() -> PathBuf { hongshi_root().join(binary_name()) } fn binary_installed() -> bool { std::fs::read(binary_path()).is_ok_and(|bytes| valid_binary(&bytes)) } fn binary_name() -> &'static str { if cfg!(target_os = "windows") { "hongshi.exe" } else { "hongshi" } } fn status_file_path() -> PathBuf { std::env::temp_dir() .join("axolotl-hongshi") .join(format!("tunnel-{}.ini", std::process::id())) } fn node_cache_path() -> PathBuf { hongshi_root().join("nodes.json") } fn valid_pe(bytes: &[u8]) -> bool { if bytes.len() < 0x40 || bytes[0..2] != [b'M', b'Z'] { return false; } let pe_offset = u32::from_le_bytes(bytes[0x3c..0x40].try_into().unwrap()) as usize; pe_offset.checked_add(4).is_some_and(|end| { end <= bytes.len() && bytes[pe_offset..end] == *b"PE\0\0" }) } fn valid_binary(bytes: &[u8]) -> bool { if cfg!(target_os = "windows") { valid_pe(bytes) } else if cfg!(target_os = "linux") { bytes.starts_with(b"\x7fELF") } else if cfg!(target_os = "macos") { matches!( bytes.get(..4), Some([0xfe, 0xed, 0xfa, 0xce]) | Some([0xce, 0xfa, 0xed, 0xfe]) | Some([0xfe, 0xed, 0xfa, 0xcf]) | Some([0xcf, 0xfa, 0xed, 0xfe]) | Some([0xca, 0xfe, 0xba, 0xbe]) | Some([0xbe, 0xba, 0xfe, 0xca]) ) } else { false } } #[derive(Debug, Deserialize)] struct HongshiDownloadResponse { url: String, } #[derive(Debug, Deserialize)] struct HongshiApiError { detail: String, } #[derive(Debug)] struct CachedDownloadUrl { url: reqwest::Url, expires_at: Instant, } #[derive(Debug, Deserialize)] #[serde(untagged)] enum HongshiNodeResponse { Map(BTreeMap), List(Vec), } #[derive(Debug, Deserialize)] struct HongshiNodeEntry { host: String, #[serde(default)] name: Option, #[serde(default)] region: Option, } fn download_endpoint_for(os: &str, architecture: &str) -> eyre::Result { let platform = match os { "windows" => "windows", "macos" => "darwin", "linux" => "linux", other => bail!("RedStone is not available on {other}"), }; let arch = match architecture { "x86_64" => "amd64", "aarch64" => "arm64", other => bail!("RedStone is not available on {other} architecture"), }; Ok(if platform == "windows" { format!("{API_BASE}/api/download/windows") } else { format!("{API_BASE}/api/download/{platform}?arch={arch}") }) } fn download_endpoint() -> eyre::Result { download_endpoint_for(std::env::consts::OS, std::env::consts::ARCH) } fn is_daily_download_limit(detail: &str) -> bool { let detail = detail.to_ascii_lowercase(); detail.contains("daily") || detail.contains("today") || detail.contains("每日") || detail.contains("今日") || detail.contains("当天") } fn retry_after_delay(response: &reqwest::Response) -> Duration { response .headers() .get(reqwest::header::RETRY_AFTER) .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::().ok()) .map(Duration::from_secs) .unwrap_or(DOWNLOAD_RATE_LIMIT_RETRY_DELAY) } async fn parse_api_error(response: reqwest::Response) -> String { let status = response.status(); let body = response.text().await.unwrap_or_default(); serde_json::from_str::(&body) .map(|error| error.detail) .unwrap_or_else(|_| { if body.trim().is_empty() { status.to_string() } else { body } }) } async fn download_client() -> eyre::Result { crate::util::fetch::configured_client() .await .map_err(|error| eyre::eyre!(error.to_string())) } async fn request_download_url(endpoint: &str) -> eyre::Result { { let mut cached = HONGSHI_DOWNLOAD_URL.lock().await; if let Some(entry) = cached.as_ref() && entry.expires_at > Instant::now() { return Ok(entry.url.clone()); } *cached = None; } let client = download_client().await?; let mut response = client .get(endpoint) .send() .await .wrap_err("failed to request RedStone download URL")?; if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { let delay = retry_after_delay(&response); let detail = parse_api_error(response).await; if is_daily_download_limit(&detail) { bail!("RedStone daily download limit reached: {detail}") } warn!( retry_after_seconds = delay.as_secs(), "RedStone download URL request was rate limited; retrying once" ); tokio::time::sleep(delay).await; response = client .get(endpoint) .send() .await .wrap_err("failed to retry RedStone download URL request")?; if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { let detail = parse_api_error(response).await; bail!( "RedStone download API is still rate limited after waiting; the daily limit may have been reached: {detail}" ) } } let response = response .error_for_status() .wrap_err("RedStone download URL request failed")? .json::() .await .wrap_err("invalid RedStone download response")?; let download_url = reqwest::Url::parse(&response.url) .wrap_err("invalid RedStone kernel download URL")?; if download_url.scheme() != "https" { bail!("RedStone kernel download URL must use HTTPS") } *HONGSHI_DOWNLOAD_URL.lock().await = Some(CachedDownloadUrl { url: download_url.clone(), expires_at: Instant::now() + DOWNLOAD_URL_CACHE_TTL, }); Ok(download_url) } async fn download_inner() -> eyre::Result<()> { let _operation = HONGSHI_OPERATION.lock().await; if !is_supported() { bail!("RedStone is not supported on this platform") } if HONGSHI_RUNTIME.lock().await.child.is_some() { bail!( "cannot replace RedStone while the multiplayer service is running" ) } let endpoint = download_endpoint()?; { let mut state = HONGSHI_STATE.lock().await; state.status = HongshiStatus::Downloading; state.download_progress = Some(0); state.error_type = None; state.error_message = None; } let download_url = request_download_url(&endpoint).await?; let client = download_client().await?; let response = client .get(download_url) .send() .await .wrap_err("failed to download RedStone kernel")? .error_for_status() .wrap_err("RedStone kernel download failed")?; let total = response.content_length().unwrap_or(0); if total > MAX_BINARY_SIZE { bail!("RedStone kernel download is too large") } let mut downloaded = 0_u64; let mut data = Vec::with_capacity(total.min(MAX_BINARY_SIZE) as usize); let mut stream = response.bytes_stream(); while let Some(chunk) = futures::StreamExt::next(&mut stream).await { let chunk = chunk.wrap_err("failed to read RedStone kernel")?; downloaded += chunk.len() as u64; if downloaded > MAX_BINARY_SIZE { bail!("RedStone kernel download is too large") } data.extend_from_slice(&chunk); if total > 0 { HONGSHI_STATE.lock().await.download_progress = Some(((downloaded * 100 / total).min(100)) as u8); } } if data.len() as u64 > MAX_BINARY_SIZE || !valid_binary(&data) { bail!("downloaded RedStone kernel failed executable validation") } let path = binary_path(); let parent = path .parent() .ok_or_else(|| eyre::eyre!("invalid RedStone kernel path"))?; tokio::fs::create_dir_all(parent) .await .wrap_err("failed to create RedStone directory")?; let temporary = path.with_extension("download"); tokio::fs::write(&temporary, &data) .await .wrap_err("failed to stage RedStone kernel")?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut permissions = std::fs::metadata(&temporary)?.permissions(); permissions.set_mode(0o755); std::fs::set_permissions(&temporary, permissions)?; } atomic_replace(&temporary, &path) .await .wrap_err("failed to install RedStone kernel")?; let mut state = HONGSHI_STATE.lock().await; state.binary_installed = true; state.status = HongshiStatus::Idle; state.download_progress = None; Ok(()) } pub async fn download() -> eyre::Result<()> { let result = download_inner().await; if let Err(error) = &result { let mut state = HONGSHI_STATE.lock().await; state.status = HongshiStatus::Error; state.download_progress = None; state.error_type = Some(HongshiErrorType::Install); state.error_message = Some(format!("{error:#}")); } result } #[cfg(target_os = "windows")] async fn atomic_replace(source: &Path, destination: &Path) -> eyre::Result<()> { use std::os::windows::ffi::OsStrExt; use windows::Win32::Storage::FileSystem::{ MOVE_FILE_FLAGS, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, }; use windows::core::PCWSTR; let source = source .as_os_str() .encode_wide() .chain(std::iter::once(0)) .collect::>(); let destination = destination .as_os_str() .encode_wide() .chain(std::iter::once(0)) .collect::>(); unsafe { MoveFileExW( PCWSTR(source.as_ptr()), PCWSTR(destination.as_ptr()), MOVE_FILE_FLAGS( MOVEFILE_REPLACE_EXISTING.0 | MOVEFILE_WRITE_THROUGH.0, ), ) .wrap_err("failed to atomically replace RedStone file")?; } Ok(()) } #[cfg(not(target_os = "windows"))] async fn atomic_replace(source: &Path, destination: &Path) -> eyre::Result<()> { tokio::fs::rename(source, destination).await?; Ok(()) } async fn ensure_binary() -> eyre::Result { let path = binary_path(); if let Ok(existing) = tokio::fs::read(&path).await && valid_binary(&existing) { return Ok(path); } bail!("RedStone kernel is not installed; download it first") } fn validate_host(host: &str) -> eyre::Result { let host = host.trim(); if host.is_empty() || host.len() > 253 || host.contains(['/', '\\', ':', '?', '#', '@']) || host.chars().any(char::is_whitespace) { bail!("invalid RedStone node address: {host}"); } if let Ok(ip) = host.parse::() { if unsafe_node_ip(ip) { bail!("unsafe RedStone node address: {host}"); } } else if !host.split('.').all(|label| { !label.is_empty() && label.len() <= 63 && !label.starts_with('-') && !label.ends_with('-') && label.chars().all(|character| { character.is_ascii_alphanumeric() || character == '-' }) }) { bail!("invalid RedStone node hostname: {host}"); } Ok(host.to_string()) } fn unsafe_node_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(ip) => { ip.is_loopback() || ip.is_link_local() || ip.is_unspecified() } IpAddr::V6(ip) => { ip.is_loopback() || ip.is_unicast_link_local() || ip.is_unspecified() } } } fn parse_node_map(bytes: &[u8]) -> eyre::Result> { let raw: HongshiNodeResponse = serde_json::from_slice(bytes) .wrap_err("failed to parse RedStone node list")?; let raw = match raw { HongshiNodeResponse::Map(nodes) => nodes, HongshiNodeResponse::List(nodes) => nodes .into_iter() .enumerate() .map(|(index, node)| { let name = node .name .or(node.region) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| format!("Node {}", index + 1)); (name, node.host) }) .collect(), }; if raw.is_empty() { bail!("RedStone node list is empty"); } let mut nodes = BTreeMap::new(); for (name, address) in raw { let name = name.trim(); if name.is_empty() || name.len() > 64 { bail!("invalid RedStone node name"); } nodes.insert(name.to_string(), validate_host(&address)?); } Ok(nodes) } async fn write_node_cache( nodes: &BTreeMap, ) -> eyre::Result<()> { let path = node_cache_path(); let parent = path .parent() .ok_or_else(|| eyre::eyre!("invalid RedStone node cache path"))?; tokio::fs::create_dir_all(parent).await?; let temporary = path.with_extension("json.new"); tokio::fs::write(&temporary, serde_json::to_vec(nodes)?).await?; atomic_replace(&temporary, &path).await?; Ok(()) } async fn load_node_map( _force_refresh: bool, ) -> eyre::Result<(BTreeMap, bool)> { let client = download_client().await?; match client.get(NODE_ENDPOINT).send().await { Ok(response) => match response.error_for_status() { Ok(response) => { let bytes = response.bytes().await?; let nodes = parse_node_map(&bytes)?; if let Err(error) = write_node_cache(&nodes).await { warn!("failed to cache RedStone node list: {error:#}"); } return Ok((nodes, false)); } Err(error) => { warn!("RedStone node endpoint returned an error: {error}") } }, Err(error) => warn!("failed to fetch RedStone nodes: {error}"), } let cached = tokio::fs::read(node_cache_path()) .await .wrap_err("failed to fetch RedStone nodes and no cache is available")?; Ok((parse_node_map(&cached)?, true)) } async fn probe_node( name: String, address: String, cached: bool, ) -> HongshiNode { let started = Instant::now(); let socket = lookup_host((address.as_str(), CONTROL_PORT)) .await .ok() .and_then(|mut addresses| { addresses.find(|socket| !unsafe_node_ip(socket.ip())) }); let reachable = if let Some(socket) = socket { tokio::time::timeout(NODE_PROBE_TIMEOUT, TcpStream::connect(socket)) .await .is_ok_and(|result| result.is_ok()) } else { false }; HongshiNode { name, address, latency_ms: reachable.then(|| started.elapsed().as_millis() as u64), reachable, cached, } } pub async fn get_nodes(force_refresh: bool) -> eyre::Result> { let (nodes, cached) = load_node_map(force_refresh).await?; let mut nodes = join_all( nodes .into_iter() .map(|(name, address)| probe_node(name, address, cached)), ) .await; sort_nodes(&mut nodes); Ok(nodes) } fn sort_nodes(nodes: &mut [HongshiNode]) { nodes.sort_by_key(|node| { ( !node.reachable, node.latency_ms.unwrap_or(u64::MAX), node.name.clone(), ) }); } pub async fn get_detected_ports() -> Vec { let mut ports = DETECTED_PORTS .lock() .await .values() .cloned() .collect::>(); ports.sort_by(|left, right| left.instance_id.cmp(&right.instance_id)); ports } pub async fn get_state() -> HongshiState { let mut state = HONGSHI_STATE.lock().await; if !matches!( state.status, HongshiStatus::Downloading | HongshiStatus::SelectingNode | HongshiStatus::Starting | HongshiStatus::Open ) { state.binary_installed = binary_installed(); } state.clone() } fn parse_tunnel_status(contents: &str) -> eyre::Result { let mut in_tunnel = false; let mut values = HashMap::new(); for raw_line in contents.lines() { let line = raw_line.trim(); if line.is_empty() || line.starts_with([';', '#']) { continue; } if line.starts_with('[') && line.ends_with(']') { in_tunnel = &line[1..line.len() - 1] == "tunnel"; continue; } if in_tunnel && let Some((key, value)) = line.split_once('=') { values.insert( key.trim().to_string(), value.split(';').next().unwrap_or("").trim().to_string(), ); } } let status = values.remove("status").unwrap_or_default(); if status != "open" && status != "closed" { bail!("invalid RedStone tunnel status"); } let server = validate_host(&values.remove("server").unwrap_or_default())?; let port = values .remove("port") .unwrap_or_default() .parse::() .wrap_err("invalid RedStone tunnel port")?; if (status == "open" && !(1..=65535).contains(&port)) || (status == "closed" && port != -1) { bail!("invalid RedStone tunnel port for status {status}"); } Ok(TunnelStatusFile { status, server, port, created: values.remove("created"), }) } async fn read_fresh_status( path: &Path, started_at: SystemTime, ) -> eyre::Result> { let metadata = match tokio::fs::metadata(path).await { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(None); } Err(error) => return Err(error.into()), }; if metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH) < started_at { return Ok(None); } let contents = tokio::fs::read_to_string(path).await?; parse_tunnel_status(&contents).map(Some) } async fn process_exit_code( child: &Arc>, ) -> eyre::Result> { let mut child = child.lock().await; Ok(child.try_wait()?.map(|status| status.code().unwrap_or(-1))) } async fn set_start_error( error_type: HongshiErrorType, message: String, exit_code: Option, ) { let mut state = HONGSHI_STATE.lock().await; state.status = HongshiStatus::Error; state.public_address = None; state.error_type = Some(error_type); state.error_message = Some(message); state.last_exit_code = exit_code; } fn classify_start_error(message: &str) -> HongshiErrorType { if message.contains("install RedStone kernel") { HongshiErrorType::Install } else if message.contains("node") || message.contains("fetch RedStone nodes") { HongshiErrorType::NodeList } else if message.contains("status") || message.contains("tunnel creation") { HongshiErrorType::StatusFile } else { HongshiErrorType::KernelStart } } fn error_type_for_exit(exit_code: i32) -> HongshiErrorType { match exit_code { 1 => HongshiErrorType::NodeUnavailable, 2 => HongshiErrorType::KernelStart, _ => HongshiErrorType::KernelExit, } } fn should_try_next_node(automatic: bool, exit_code: i32) -> bool { automatic && exit_code == 1 } async fn spawn_kernel( binary: &Path, node: &HongshiNode, local_port: u16, status_file: &Path, ) -> eyre::Result<(Arc>, JobGuard, SystemTime)> { let root = hongshi_root(); tokio::fs::create_dir_all(&root).await?; if let Some(parent) = status_file.parent() { tokio::fs::create_dir_all(parent).await?; } let _ = tokio::fs::remove_file(status_file).await; let started_at = SystemTime::now(); let mut child = Command::new(binary) .arg("-server") .arg(&node.address) .arg("-port") .arg(local_port.to_string()) .arg("-status-file") .arg(status_file) .current_dir(&root) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .kill_on_drop(true) .spawn() .wrap_err("failed to start RedStone kernel")?; let process_id = child .id() .ok_or_else(|| eyre::eyre!("RedStone process has no process id"))?; let job = match attach_kill_on_close_job(process_id) { Ok(job) => job, Err(error) => { let _ = child.start_kill(); return Err(error); } }; info!("started RedStone kernel with pid {process_id}"); Ok((Arc::new(Mutex::new(child)), job, started_at)) } async fn wait_until_open( child: &Arc>, status_file: &Path, started_at: SystemTime, ) -> eyre::Result> { let deadline = Instant::now() + START_TIMEOUT; loop { if let Some(exit_code) = process_exit_code(child).await? { return Ok(Err(exit_code)); } if let Some(status) = read_fresh_status(status_file, started_at).await? { if status.status == "open" { if let Some(exit_code) = process_exit_code(child).await? { return Ok(Err(exit_code)); } return Ok(Ok(status)); } return Ok(Err(0)); } if Instant::now() >= deadline { let mut child = child.lock().await; let _ = child.start_kill(); bail!("timed out waiting for RedStone tunnel creation"); } tokio::time::sleep(Duration::from_millis(250)).await; } } async fn monitor_kernel( child: Arc>, status_file: PathBuf, started_at: SystemTime, ) { loop { match process_exit_code(&child).await { Ok(Some(exit_code)) => { let stopping = HONGSHI_RUNTIME.lock().await.stopping; if stopping { let mut state = HONGSHI_STATE.lock().await; *state = HongshiState::default(); } else if exit_code == 0 { let mut state = HONGSHI_STATE.lock().await; state.status = HongshiStatus::Closed; state.public_address = None; state.last_exit_code = Some(exit_code); } else { set_start_error( HongshiErrorType::KernelExit, format!("RedStone kernel exited with code {exit_code}"), Some(exit_code), ) .await; } break; } Ok(None) => {} Err(error) => { set_start_error( HongshiErrorType::KernelExit, error.to_string(), None, ) .await; break; } } match read_fresh_status(&status_file, started_at).await { Ok(Some(status)) if status.status == "closed" => { let mut state = HONGSHI_STATE.lock().await; state.status = HongshiStatus::Closed; state.public_address = None; state.created_at = status.created; } Ok(_) => {} Err(error) => { warn!("failed to read RedStone status file: {error:#}") } } tokio::time::sleep(Duration::from_millis(250)).await; } { let mut runtime = HONGSHI_RUNTIME.lock().await; runtime.child = None; runtime.job = None; runtime.status_file = None; runtime.stopping = false; } multiplayer::release_provider(MultiplayerProvider::Hongshi).await; } pub async fn start( local_port: u16, node_name: Option, instance_id: Option, ) -> eyre::Result<()> { let _operation = HONGSHI_OPERATION.lock().await; if !is_supported() { set_start_error( HongshiErrorType::Unsupported, "RedStone is not supported on this platform".to_string(), None, ) .await; bail!("RedStone is not supported on this platform"); } if local_port == 0 { set_start_error( HongshiErrorType::InvalidPort, "invalid local port".to_string(), None, ) .await; bail!("invalid local port"); } if HONGSHI_RUNTIME.lock().await.child.is_some() { bail!("RedStone is already running"); } if let Some(instance_id) = instance_id.as_deref() { let ports = DETECTED_PORTS.lock().await; let detected = ports.get(instance_id).ok_or_else(|| { eyre::eyre!("selected Minecraft instance is no longer running") })?; if detected.port != local_port { bail!("selected Minecraft instance opened a different LAN port"); } } multiplayer::claim_provider(MultiplayerProvider::Hongshi).await?; let result = async { { let mut state = HONGSHI_STATE.lock().await; state.status = HongshiStatus::SelectingNode; state.local_port = Some(local_port); state.node = None; state.public_address = None; state.created_at = None; state.last_exit_code = None; state.error_type = None; state.error_message = None; state.bound_instance_id = instance_id.clone(); state.port_changed = false; } let binary = ensure_binary().await?; let mut nodes = get_nodes(false).await?; if let Some(name) = node_name.as_deref() { nodes.retain(|node| node.name == name); if nodes.is_empty() { bail!("selected RedStone node no longer exists"); } } else { nodes.retain(|node| node.reachable); } if nodes.is_empty() { bail!("no reachable RedStone node is available"); } let status_file = status_file_path(); let automatic = node_name.is_none(); let mut last_error = None; for node in nodes { { let mut state = HONGSHI_STATE.lock().await; state.status = HongshiStatus::Starting; state.node = Some(node.clone()); } let (child, job, started_at) = spawn_kernel(&binary, &node, local_port, &status_file).await?; match wait_until_open(&child, &status_file, started_at).await? { Ok(tunnel) => { if tunnel.server != node.address { let mut child = child.lock().await; let _ = child.start_kill(); bail!("RedStone status file returned an unexpected server"); } let public_address = format!("{}:{}", tunnel.server, tunnel.port); { let mut state = HONGSHI_STATE.lock().await; state.status = HongshiStatus::Open; state.public_address = Some(public_address); state.created_at = tunnel.created; state.last_exit_code = None; } let monitor = tokio::spawn(monitor_kernel( child.clone(), status_file.clone(), started_at, )); let mut runtime = HONGSHI_RUNTIME.lock().await; runtime.child = Some(child); runtime.job = Some(job); runtime.monitor = Some(monitor); runtime.status_file = Some(status_file); return Ok(()); } Err(exit_code) => { last_error = Some(exit_code); if !should_try_next_node(automatic, exit_code) { break; } } } } let exit_code = last_error.unwrap_or(-1); let error_type = error_type_for_exit(exit_code); let message = format!("RedStone failed to create a tunnel (exit code {exit_code}, type {error_type:?})"); set_start_error(error_type, message.clone(), Some(exit_code)).await; bail!(message) } .await; if let Err(error) = result { if HONGSHI_STATE.lock().await.status != HongshiStatus::Error { let message = error.to_string(); set_start_error(classify_start_error(&message), message, None) .await; } multiplayer::release_provider(MultiplayerProvider::Hongshi).await; return Err(error); } Ok(()) } pub async fn stop() -> eyre::Result<()> { let _operation = HONGSHI_OPERATION.lock().await; let (child, job, monitor, status_file) = { let mut runtime = HONGSHI_RUNTIME.lock().await; runtime.stopping = true; ( runtime.child.take(), runtime.job.take(), runtime.monitor.take(), runtime.status_file.take(), ) }; if let Some(child) = child { let mut child = child.lock().await; let _ = child.start_kill(); let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await; } if let Some(monitor) = monitor { monitor.abort(); } drop(job); if let Some(path) = status_file { let _ = tokio::fs::remove_file(path).await; } { let mut runtime = HONGSHI_RUNTIME.lock().await; runtime.stopping = false; } *HONGSHI_STATE.lock().await = HongshiState::default(); multiplayer::release_provider(MultiplayerProvider::Hongshi).await; Ok(()) } pub async fn observe_minecraft_log( instance_id: &str, instance_name: &str, process_id: &str, message: &str, ) { let port = LAN_PORT_PATTERNS.iter().find_map(|pattern| { pattern .captures(message) .and_then(|captures| captures.get(1)) .and_then(|value| value.as_str().parse::().ok()) .filter(|port| *port > 0) }); let Some(port) = port else { return; }; DETECTED_PORTS.lock().await.insert( instance_id.to_string(), DetectedLanPort { instance_id: instance_id.to_string(), instance_name: instance_name.to_string(), process_id: process_id.to_string(), port, detected_at: chrono::Local::now() .format("%Y-%m-%d %H:%M:%S") .to_string(), }, ); let mut state = HONGSHI_STATE.lock().await; if state.bound_instance_id.as_deref() == Some(instance_id) && state.local_port.is_some_and(|current| current != port) { state.port_changed = true; } } pub async fn minecraft_process_finished(instance_id: &str) { DETECTED_PORTS.lock().await.remove(instance_id); let should_stop = { let state = HONGSHI_STATE.lock().await; state.bound_instance_id.as_deref() == Some(instance_id) && matches!( state.status, HongshiStatus::Starting | HongshiStatus::Open ) }; if should_stop && let Err(error) = stop().await { warn!("failed to stop RedStone after Minecraft exited: {error:#}"); } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_node_map_and_rejects_unsafe_hosts() { let nodes = parse_node_map(r#"{"南京":"119.45.52.17"}"#.as_bytes()).unwrap(); assert_eq!(nodes.get("南京").map(String::as_str), Some("119.45.52.17")); assert!(parse_node_map(br#"{"local":"127.0.0.1"}"#).is_err()); assert!(parse_node_map(br#"{"bad":"https://example.com"}"#).is_err()); let nodes = parse_node_map( br#"[{"host":"relay.example.com","region":"cn-east"}]"#, ) .unwrap(); assert_eq!( nodes.get("cn-east").map(String::as_str), Some("relay.example.com") ); } #[test] fn validates_supported_executable_formats() { if cfg!(target_os = "windows") { let mut binary = vec![0; 0x44]; binary[0..2].copy_from_slice(b"MZ"); binary[0x3c..0x40].copy_from_slice(&0x40_u32.to_le_bytes()); binary[0x40..0x44].copy_from_slice(b"PE\0\0"); assert!(valid_binary(&binary)); } else if cfg!(target_os = "linux") { assert!(valid_binary(b"\x7fELFtest")); } else if cfg!(target_os = "macos") { assert!(valid_binary(&[0xcf, 0xfa, 0xed, 0xfe])); } assert!(!valid_binary(b"not an executable")); } #[test] fn maps_supported_platforms_to_download_endpoints() { assert_eq!( download_endpoint_for("windows", "x86_64").unwrap(), "https://hongshi.site/api/download/windows" ); assert_eq!( download_endpoint_for("macos", "aarch64").unwrap(), "https://hongshi.site/api/download/darwin?arch=arm64" ); assert_eq!( download_endpoint_for("linux", "x86_64").unwrap(), "https://hongshi.site/api/download/linux?arch=amd64" ); assert!(download_endpoint_for("linux", "riscv64").is_err()); } #[test] fn distinguishes_daily_download_limits_from_short_rate_limits() { assert!(is_daily_download_limit("今日下载次数已达上限")); assert!(is_daily_download_limit("Daily download limit reached")); assert!(!is_daily_download_limit("60 秒内请勿重复请求")); assert!(!is_daily_download_limit("Too many requests")); } #[test] fn parses_open_and_closed_status_files() { let open = parse_tunnel_status( "[tunnel]\nstatus=open\nserver=1.2.3.4\nport=41862\ncreated=2026-08-09 22:42:40\n", ) .unwrap(); assert_eq!(open.port, 41862); assert_eq!(open.server, "1.2.3.4"); assert!( parse_tunnel_status( "[tunnel]\nstatus=closed\nserver=1.2.3.4\nport=-1\n" ) .is_ok() ); assert!( parse_tunnel_status( "[tunnel]\nstatus=open\nserver=1.2.3.4\nport=-1\n" ) .is_err() ); } #[tokio::test] async fn detects_minecraft_lan_ports() { observe_minecraft_log( "instance-a", "Test Instance", "process-a", "Local game hosted on port 54321", ) .await; let ports = get_detected_ports().await; assert!(ports.iter().any(|entry| { entry.instance_id == "instance-a" && entry.instance_name == "Test Instance" && entry.process_id == "process-a" && entry.port == 54321 })); minecraft_process_finished("instance-a").await; } #[test] fn sorts_reachable_nodes_by_latency() { let mut nodes = vec![ HongshiNode { name: "slow".to_string(), address: "203.0.113.1".to_string(), latency_ms: Some(80), reachable: true, cached: false, }, HongshiNode { name: "offline".to_string(), address: "203.0.113.2".to_string(), latency_ms: None, reachable: false, cached: false, }, HongshiNode { name: "fast".to_string(), address: "203.0.113.3".to_string(), latency_ms: Some(20), reachable: true, cached: false, }, ]; sort_nodes(&mut nodes); assert_eq!(nodes[0].name, "fast"); assert_eq!(nodes[1].name, "slow"); assert_eq!(nodes[2].name, "offline"); } #[test] fn maps_exit_codes_and_only_fails_over_automatic_nodes() { assert_eq!(error_type_for_exit(1), HongshiErrorType::NodeUnavailable); assert_eq!(error_type_for_exit(2), HongshiErrorType::KernelStart); assert_eq!(error_type_for_exit(3), HongshiErrorType::KernelExit); assert!(should_try_next_node(true, 1)); assert!(!should_try_next_node(false, 1)); assert!(!should_try_next_node(true, 2)); } }