Files
Starlight_Lancher/packages/app-lib/src/api/hongshi.rs

1398 lines
43 KiB
Rust

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<Mutex<HongshiState>> =
LazyLock::new(|| Mutex::new(HongshiState::default()));
static HONGSHI_RUNTIME: LazyLock<Mutex<HongshiRuntime>> =
LazyLock::new(|| Mutex::new(HongshiRuntime::default()));
static HONGSHI_OPERATION: LazyLock<Mutex<()>> =
LazyLock::new(|| Mutex::new(()));
static HONGSHI_DOWNLOAD_URL: LazyLock<Mutex<Option<CachedDownloadUrl>>> =
LazyLock::new(|| Mutex::new(None));
static DETECTED_PORTS: LazyLock<Mutex<HashMap<String, DetectedLanPort>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static LAN_PORT_PATTERNS: LazyLock<Vec<Regex>> = 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<u64>,
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<u16>,
pub node: Option<HongshiNode>,
pub public_address: Option<String>,
pub created_at: Option<String>,
pub last_exit_code: Option<i32>,
pub error_type: Option<HongshiErrorType>,
pub error_message: Option<String>,
pub bound_instance_id: Option<String>,
pub port_changed: bool,
pub binary_installed: bool,
pub download_progress: Option<u8>,
}
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<Arc<Mutex<Child>>>,
job: Option<JobGuard>,
monitor: Option<JoinHandle<()>>,
status_file: Option<PathBuf>,
stopping: bool,
}
#[derive(Debug)]
struct TunnelStatusFile {
status: String,
server: String,
port: i32,
created: Option<String>,
}
#[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<JobGuard> {
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::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() 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<JobGuard> {
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<String, String>),
List(Vec<HongshiNodeEntry>),
}
#[derive(Debug, Deserialize)]
struct HongshiNodeEntry {
host: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
region: Option<String>,
}
fn download_endpoint_for(os: &str, architecture: &str) -> eyre::Result<String> {
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<String> {
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::<u64>().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::<HongshiApiError>(&body)
.map(|error| error.detail)
.unwrap_or_else(|_| {
if body.trim().is_empty() {
status.to_string()
} else {
body
}
})
}
async fn download_client() -> eyre::Result<reqwest::Client> {
crate::util::fetch::configured_client()
.await
.map_err(|error| eyre::eyre!(error.to_string()))
}
async fn request_download_url(endpoint: &str) -> eyre::Result<reqwest::Url> {
{
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::<HongshiDownloadResponse>()
.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::<Vec<_>>();
let destination = destination
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
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<PathBuf> {
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<String> {
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::<IpAddr>() {
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<BTreeMap<String, String>> {
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<String, String>,
) -> 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<String, String>, 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<Vec<HongshiNode>> {
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<DetectedLanPort> {
let mut ports = DETECTED_PORTS
.lock()
.await
.values()
.cloned()
.collect::<Vec<_>>();
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<TunnelStatusFile> {
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::<i32>()
.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<Option<TunnelStatusFile>> {
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<Mutex<Child>>,
) -> eyre::Result<Option<i32>> {
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<i32>,
) {
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<Mutex<Child>>, 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<Mutex<Child>>,
status_file: &Path,
started_at: SystemTime,
) -> eyre::Result<Result<TunnelStatusFile, i32>> {
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<Mutex<Child>>,
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<String>,
instance_id: Option<String>,
) -> 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::<u16>().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));
}
}