Files
FlClash/services/helper/src/service/hub.rs
T
chen08209 94a25f4827 fix(desktop): fix the system proxy, tray and Helper on Windows and Linux
Changelog: Windows and Linux desktop fixes
2026-10-11 12:28:52 +08:00

1535 lines
48 KiB
Rust

use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::VecDeque;
use std::convert::Infallible;
use std::fs::{File, OpenOptions};
#[cfg(all(
not(all(feature = "windows-service", target_os = "windows")),
not(target_os = "linux")
))]
use std::future::pending;
#[cfg(not(any(
all(feature = "windows-service", target_os = "windows"),
target_os = "linux"
)))]
use std::future::Future;
use std::io::{BufRead, Error, Read};
#[cfg(windows)]
use std::os::windows::fs::OpenOptionsExt;
#[cfg(windows)]
use std::os::windows::io::AsRawHandle;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
use std::{io, thread};
use warp::http::StatusCode;
use warp::{Filter, Rejection, Reply};
#[cfg(windows)]
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
#[cfg(windows)]
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ;
#[cfg(windows)]
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
#[cfg(not(any(
all(feature = "windows-service", target_os = "windows"),
target_os = "linux"
)))]
const LISTEN_PORT: u16 = 47890;
#[cfg(not(target_os = "linux"))]
const CORE_PIPE_PREFIX: &str = r"\\.\pipe\FlClashCore_";
#[cfg(target_os = "linux")]
const CORE_SOCKET_PREFIX: &str = "/tmp/FlClashSocket_";
#[cfg(target_os = "linux")]
const CORE_SOCKET_SUFFIX: &str = ".sock";
const PROTOCOL_VERSION_HEADER: &str = "x-flclash-helper-protocol";
#[cfg(windows)]
const PROTOCOL_VERSION: &str = "8";
#[cfg(not(windows))]
const PROTOCOL_VERSION: &str = "6";
const EXPECTED_CORE_SHA256: &str = env!("CORE_SHA256");
const LOG_CAPACITY: usize = 100;
const MAX_REQUEST_BODY_BYTES: u64 = 4096;
const CORE_EXIT_TIMEOUT: Duration = Duration::from_millis(1500);
const CORE_GRACEFUL_EXIT_TIMEOUT: Duration = Duration::from_millis(3000);
const CORE_EXIT_POLL_INTERVAL: Duration = Duration::from_millis(20);
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct StartParams {
pub address: String,
#[serde(rename = "sessionId")]
pub session_id: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct StopParams {
#[serde(rename = "sessionId")]
pub session_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PingParams {
#[serde(rename = "coreSha256")]
core_sha256: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StartResponse {
session_id: String,
pid: u32,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StopResponse {
session_id: String,
stopped: bool,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<&'static str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ErrorDetails {
os_error: i32,
}
#[derive(Serialize)]
struct ErrorResponse {
code: &'static str,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<ErrorDetails>,
}
struct ManagedCore {
session_id: String,
child: Child,
#[cfg(windows)]
_job: CoreJob,
}
impl ManagedCore {
fn adopt(session_id: String, child: Child) -> Result<Self, Error> {
#[cfg(windows)]
let job = CoreJob::bind(&child)?;
Ok(Self {
session_id,
child,
#[cfg(windows)]
_job: job,
})
}
fn terminate(&mut self) -> Result<(), Error> {
if self.request_exit() && self.wait_for_exit(CORE_GRACEFUL_EXIT_TIMEOUT)? {
return Ok(());
}
let _ = self.child.kill();
if self.wait_for_exit(CORE_EXIT_TIMEOUT)? {
return Ok(());
}
Err(Error::other("Core did not exit after termination"))
}
fn wait_for_exit(&mut self, timeout: Duration) -> Result<bool, Error> {
let deadline = Instant::now() + timeout;
loop {
if self.child.try_wait()?.is_some() {
return Ok(true);
}
if Instant::now() >= deadline {
return Ok(false);
}
thread::sleep(CORE_EXIT_POLL_INTERVAL);
}
}
/// Only the TUN device dies with the process; SIGTERM lets the Core remove
/// the policy routes sing-tun installed for it.
#[cfg(target_os = "linux")]
fn request_exit(&mut self) -> bool {
// SAFETY: the child is unreaped, so its pid cannot have been recycled.
unsafe { libc::kill(self.child.id() as libc::pid_t, libc::SIGTERM) == 0 }
}
#[cfg(not(target_os = "linux"))]
fn request_exit(&mut self) -> bool {
false
}
}
/// Windows has no cgroup to take the Core down with a crashed Helper, so the
/// Core lives in a job that the kernel kills when the Helper's handle closes.
#[cfg(windows)]
struct CoreJob(HANDLE);
#[cfg(windows)]
impl CoreJob {
fn bind(child: &Child) -> Result<Self, Error> {
// SAFETY: plain kernel32 calls on handles this process owns; the job
// handle is closed by Drop and the process handle stays with `child`.
unsafe {
let job = CreateJobObjectW(std::ptr::null(), std::ptr::null());
if job == 0 {
return Err(Error::last_os_error());
}
let job = Self(job);
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if SetInformationJobObject(
job.0,
JobObjectExtendedLimitInformation,
&limits as *const _ as *const _,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
) == 0
{
return Err(Error::last_os_error());
}
if AssignProcessToJobObject(job.0, child.as_raw_handle() as HANDLE) == 0 {
return Err(Error::last_os_error());
}
Ok(job)
}
}
}
#[cfg(windows)]
impl Drop for CoreJob {
fn drop(&mut self) {
// SAFETY: the handle came from CreateJobObjectW and is closed once.
unsafe {
CloseHandle(self.0);
}
}
}
struct VerifiedCore {
path: PathBuf,
directory: PathBuf,
_handle: File,
}
impl VerifiedCore {
fn open() -> Result<Self, Error> {
let path = core_path()?;
let directory = path
.parent()
.ok_or_else(|| Error::other("Core executable has no parent directory"))?
.to_path_buf();
let handle = open_verified_core(&path, EXPECTED_CORE_SHA256)?;
Ok(Self {
path,
directory,
_handle: handle,
})
}
fn spawn(&self, address: &str) -> Result<Child, Error> {
let mut command = Command::new(&self.path);
command
.current_dir(&self.directory)
.stderr(Stdio::piped())
.arg(address);
#[cfg(target_os = "linux")]
adopt_core_owner(&mut command)?;
command.spawn()
}
}
/// The Core hands files back to its real UID only when that differs from its
/// effective one, so the child needs a setuid Core's split credentials.
#[cfg(target_os = "linux")]
fn adopt_core_owner(command: &mut Command) -> Result<(), Error> {
use std::os::unix::process::CommandExt;
let (uid, gid) = super::linux::core_owner()?;
// SAFETY: async-signal-safe calls touching only this child, before exec.
unsafe {
command.pre_exec(move || {
if libc::setresgid(gid, 0, 0) != 0 || libc::setresuid(uid, 0, 0) != 0 {
return Err(Error::last_os_error());
}
Ok(())
});
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
enum StopDecision {
NotRunning,
Stop,
SessionMismatch,
}
fn core_path() -> Result<PathBuf, Error> {
let helper_path = std::env::current_exe()?;
let directory = helper_path
.parent()
.ok_or_else(|| Error::other("helper executable has no parent directory"))?;
Ok(directory.join(env!("CORE_NAME")))
}
fn open_core(path: &Path) -> Result<File, Error> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(windows)]
options.share_mode(FILE_SHARE_READ);
options.open(path)
}
fn sha256_file(file: &mut File) -> Result<String, Error> {
let mut hasher = Sha256::new();
let mut buffer = [0; 4096];
loop {
let bytes_read = file.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn open_verified_core(path: &Path, expected_sha256: &str) -> Result<File, Error> {
if expected_sha256.is_empty() {
return Err(Error::other("expected Core SHA256 is empty"));
}
let mut core_file = open_core(path)?;
if sha256_file(&mut core_file)? != expected_sha256 {
return Err(Error::other("Core executable SHA256 mismatch"));
}
Ok(core_file)
}
#[cfg(not(target_os = "linux"))]
fn is_allowed_core_address(address: &str) -> bool {
let Some(suffix) = address.strip_prefix(CORE_PIPE_PREFIX) else {
return false;
};
is_valid_session_id(suffix)
}
#[cfg(target_os = "linux")]
fn is_allowed_core_address(address: &str) -> bool {
let Some(suffix) = address.strip_prefix(CORE_SOCKET_PREFIX) else {
return false;
};
let Some(digits) = suffix.strip_suffix(CORE_SOCKET_SUFFIX) else {
return false;
};
!digits.is_empty() && digits.len() <= 10 && digits.bytes().all(|byte| byte.is_ascii_digit())
}
fn is_valid_session_id(value: &str) -> bool {
value.len() == 32
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn stop_decision(current: Option<&str>, requested: &str) -> StopDecision {
match current {
None => StopDecision::NotRunning,
Some(session_id) if session_id == requested => StopDecision::Stop,
Some(_) => StopDecision::SessionMismatch,
}
}
static LOGS: Lazy<Mutex<VecDeque<String>>> =
Lazy::new(|| Mutex::new(VecDeque::with_capacity(LOG_CAPACITY)));
static MANAGED_CORE: Lazy<Mutex<Option<ManagedCore>>> = Lazy::new(|| Mutex::new(None));
fn lock_surviving_poison<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// A Core whose app went away exits on its own, and stays a zombie until it is
/// waited on; without this nothing waits before the next start or stop.
fn reap_exited_core(session_id: &str) {
let deadline = Instant::now() + CORE_EXIT_TIMEOUT;
loop {
{
let mut managed = lock_surviving_poison(&MANAGED_CORE);
let Some(core) = managed
.as_mut()
.filter(|core| core.session_id == session_id)
else {
return;
};
match core.child.try_wait() {
Ok(Some(status)) => {
log_message(format!("Core exited on its own: {status}"));
*managed = None;
return;
}
Ok(None) => {}
Err(_) => return,
}
}
if Instant::now() >= deadline {
return;
}
thread::sleep(CORE_EXIT_POLL_INTERVAL);
}
}
fn release_managed_core(managed: &mut Option<ManagedCore>) -> Result<(), Error> {
let Some(core) = managed.as_mut() else {
return Ok(());
};
core.terminate()?;
*managed = None;
Ok(())
}
fn json_response<T: Serialize>(value: &T, status: StatusCode) -> warp::reply::Response {
warp::reply::with_status(warp::reply::json(value), status).into_response()
}
fn error_response(
code: &'static str,
message: impl Into<String>,
status: StatusCode,
) -> warp::reply::Response {
json_response(
&ErrorResponse {
code,
message: message.into(),
details: None,
},
status,
)
}
/// The OS error code is what lets the app tell a Core that Windows refused on
/// policy grounds (Smart App Control, AppLocker) from one that is broken.
fn launch_failure_response(error: &Error) -> warp::reply::Response {
json_response(
&ErrorResponse {
code: "processLaunchFailed",
message: error.to_string(),
details: error
.raw_os_error()
.map(|os_error| ErrorDetails { os_error }),
},
StatusCode::INTERNAL_SERVER_ERROR,
)
}
fn start(start_params: StartParams) -> warp::reply::Response {
if !is_allowed_core_address(&start_params.address) {
return error_response(
"invalidRequest",
"invalid Core address",
StatusCode::BAD_REQUEST,
);
}
if !is_valid_session_id(&start_params.session_id) {
return error_response(
"invalidRequest",
"invalid Core session ID",
StatusCode::BAD_REQUEST,
);
}
#[cfg(target_os = "linux")]
if let Err(error) = super::linux::ensure_owner_socket(&start_params.address) {
return error_response("invalidRequest", error.to_string(), StatusCode::BAD_REQUEST);
}
launch(start_params)
}
/// Releases the managed Core before anything else, so a request reaching this
/// point must already be accepted: a rejected one has to leave it running.
fn launch(start_params: StartParams) -> warp::reply::Response {
let mut managed = lock_surviving_poison(&MANAGED_CORE);
if let Err(error) = release_managed_core(&mut managed) {
log_message(format!(
"Helper could not release the managed Core: {error}"
));
return error_response(
"coreStopFailed",
error.to_string(),
StatusCode::INTERNAL_SERVER_ERROR,
);
}
let core = match VerifiedCore::open() {
Ok(core) => core,
Err(error) => {
return error_response(
"coreVerificationFailed",
error.to_string(),
StatusCode::CONFLICT,
)
}
};
match core.spawn(&start_params.address) {
Ok(mut child) => {
let process_id = child.id();
if let Some(stderr) = child.stderr.take() {
let reader = io::BufReader::new(stderr);
let session_id = start_params.session_id.clone();
thread::spawn(move || {
for line in reader.lines() {
match line {
Ok(output) => {
log_message(output);
}
Err(_) => {
break;
}
}
}
reap_exited_core(&session_id);
});
}
*managed = match ManagedCore::adopt(start_params.session_id.clone(), child) {
Ok(core) => Some(core),
Err(error) => {
log_message(format!("Helper could not confine the Core: {error}"));
return error_response(
"internalError",
format!("Core confinement failed: {error}"),
StatusCode::INTERNAL_SERVER_ERROR,
);
}
};
json_response(
&StartResponse {
session_id: start_params.session_id,
pid: process_id,
},
StatusCode::OK,
)
}
Err(e) => {
log_message(e.to_string());
launch_failure_response(&e)
}
}
}
fn stop_core(stop_params: StopParams) -> warp::reply::Response {
if !is_valid_session_id(&stop_params.session_id) {
return error_response(
"invalidRequest",
"invalid Core session ID",
StatusCode::BAD_REQUEST,
);
}
let mut managed = lock_surviving_poison(&MANAGED_CORE);
match stop_decision(
managed.as_ref().map(|core| core.session_id.as_str()),
&stop_params.session_id,
) {
StopDecision::NotRunning => json_response(
&StopResponse {
session_id: stop_params.session_id,
stopped: false,
reason: Some("notRunning"),
},
StatusCode::OK,
),
StopDecision::SessionMismatch => json_response(
&StopResponse {
session_id: stop_params.session_id,
stopped: false,
reason: Some("sessionMismatch"),
},
StatusCode::CONFLICT,
),
StopDecision::Stop => match release_managed_core(&mut managed) {
Ok(()) => json_response(
&StopResponse {
session_id: stop_params.session_id,
stopped: true,
reason: None,
},
StatusCode::OK,
),
Err(error) => {
log_message(format!("Helper could not stop the managed Core: {error}"));
error_response(
"coreStopFailed",
error.to_string(),
StatusCode::INTERNAL_SERVER_ERROR,
)
}
},
}
}
pub(super) fn log_message(message: String) {
let mut log_buffer = lock_surviving_poison(&LOGS);
while log_buffer.len() >= LOG_CAPACITY {
log_buffer.pop_front();
}
log_buffer.push_back(message);
}
fn get_logs() -> impl Reply {
let log_buffer = lock_surviving_poison(&LOGS);
let value = log_buffer
.iter()
.cloned()
.collect::<Vec<String>>()
.join("\n");
warp::reply::with_header(
warp::reply::with_header(value, "Content-Type", "text/plain; charset=utf-8"),
"Cache-Control",
"no-store",
)
}
fn ping_response(result: Result<PathBuf, Error>) -> warp::reply::Response {
let (value, status) = match result {
Ok(path) => (path.to_string_lossy().into_owned(), StatusCode::OK),
Err(error) => (error.to_string(), StatusCode::CONFLICT),
};
warp::reply::with_header(
warp::reply::with_status(value, status),
PROTOCOL_VERSION_HEADER,
PROTOCOL_VERSION,
)
.into_response()
}
fn ping_core_sha256_mismatch() -> warp::reply::Response {
warp::reply::with_header(
error_response(
"coreSha256Mismatch",
"Core SHA256 mismatch",
StatusCode::CONFLICT,
),
PROTOCOL_VERSION_HEADER,
PROTOCOL_VERSION,
)
.into_response()
}
fn probe_helper_path() -> Result<PathBuf, Error> {
open_core(&core_path()?)?;
std::env::current_exe()
}
fn ping(ping_params: PingParams) -> warp::reply::Response {
if ping_params.core_sha256 != EXPECTED_CORE_SHA256 {
log_message("Helper ping rejected a Core SHA256 mismatch".to_string());
return ping_core_sha256_mismatch();
}
let result = probe_helper_path();
if let Err(error) = &result {
log_message(format!("Helper ping failed: {error}"));
}
ping_response(result)
}
async fn start_request(start_params: StartParams) -> Result<warp::reply::Response, Infallible> {
Ok(
match tokio::task::spawn_blocking(move || start(start_params)).await {
Ok(response) => response,
Err(error) => error_response(
"internalError",
format!("Core start task failed: {error}"),
StatusCode::INTERNAL_SERVER_ERROR,
),
},
)
}
async fn stop_request(stop_params: StopParams) -> Result<warp::reply::Response, Infallible> {
Ok(
match tokio::task::spawn_blocking(move || stop_core(stop_params)).await {
Ok(response) => response,
Err(error) => error_response(
"internalError",
format!("Core stop task failed: {error}"),
StatusCode::INTERNAL_SERVER_ERROR,
),
},
)
}
async fn ping_request(ping_params: PingParams) -> Result<warp::reply::Response, Infallible> {
Ok(tokio::task::spawn_blocking(move || ping(ping_params))
.await
.unwrap_or_else(|error| {
ping_response(Err(Error::other(format!(
"Helper ping task failed: {error}"
))))
}))
}
async fn handle_rejection(rejection: Rejection) -> Result<warp::reply::Response, Infallible> {
if rejection.find::<ForbiddenClient>().is_some() {
return Ok(warp::reply::with_header(
error_response(
"invalidRequest",
"browser origins and non-local hosts are not allowed",
StatusCode::FORBIDDEN,
),
"connection",
"close",
)
.into_response());
}
if rejection.find::<warp::reject::InvalidQuery>().is_some() {
return Ok(warp::reply::with_header(
error_response(
"invalidRequest",
"invalid ping query",
StatusCode::BAD_REQUEST,
),
PROTOCOL_VERSION_HEADER,
PROTOCOL_VERSION,
)
.into_response());
}
if rejection
.find::<warp::filters::body::BodyDeserializeError>()
.is_some()
{
return Ok(error_response(
"invalidRequest",
"invalid JSON request body",
StatusCode::BAD_REQUEST,
));
}
if rejection.find::<warp::reject::PayloadTooLarge>().is_some() {
return Ok(warp::reply::with_header(
error_response(
"invalidRequest",
"request body exceeds 4096 bytes",
StatusCode::PAYLOAD_TOO_LARGE,
),
"connection",
"close",
)
.into_response());
}
if rejection.find::<warp::reject::LengthRequired>().is_some() {
return Ok(error_response(
"invalidRequest",
"request body length is required",
StatusCode::LENGTH_REQUIRED,
));
}
if rejection.is_not_found() {
return Ok(error_response(
"notFound",
"Helper endpoint not found",
StatusCode::NOT_FOUND,
));
}
if rejection.find::<warp::reject::InvalidHeader>().is_some() {
return Ok(error_response(
"invalidRequest",
"invalid request header",
StatusCode::BAD_REQUEST,
));
}
if rejection.find::<warp::reject::MethodNotAllowed>().is_some() {
return Ok(error_response(
"invalidRequest",
"Helper endpoint does not accept this method",
StatusCode::METHOD_NOT_ALLOWED,
));
}
Ok(error_response(
"internalError",
"unhandled Helper request rejection",
StatusCode::INTERNAL_SERVER_ERROR,
))
}
#[derive(Debug)]
struct ForbiddenClient;
impl warp::reject::Reject for ForbiddenClient {}
pub(super) fn routes() -> impl Filter<Extract = (impl Reply,), Error = Infallible> + Clone {
let native_client = warp::header::optional::<String>("origin")
.and(warp::header::optional::<String>("host"))
.and_then(|origin: Option<String>, host: Option<String>| async move {
let local_host = host.is_none_or(|host| {
host.parse::<warp::http::uri::Authority>()
.is_ok_and(|authority| {
["127.0.0.1", "localhost", "FlClashHelperService"]
.iter()
.any(|host| authority.host().eq_ignore_ascii_case(host))
})
});
if origin.is_some() || !local_host {
return Err(warp::reject::custom(ForbiddenClient));
}
Ok(())
})
.untuple_one();
// Matching the path before the method keeps an unknown path rejecting as
// "not found" instead of the method mismatch another endpoint reports.
let api_ping = warp::path("ping")
.and(warp::path::end())
.and(warp::get())
.and(warp::query::<PingParams>())
.and_then(ping_request);
let api_start = warp::path("start")
.and(warp::path::end())
.and(warp::post())
.and(warp::body::content_length_limit(MAX_REQUEST_BODY_BYTES))
.and(warp::body::json())
.and_then(start_request);
let api_stop = warp::path("stop")
.and(warp::path::end())
.and(warp::post())
.and(warp::body::content_length_limit(MAX_REQUEST_BODY_BYTES))
.and(warp::body::json())
.and_then(stop_request);
let api_logs = warp::path("logs")
.and(warp::path::end())
.and(warp::get())
.map(get_logs);
native_client
.and(api_ping.or(api_start).or(api_stop).or(api_logs))
.recover(handle_rejection)
}
pub(super) fn ensure_core_sha256_configured() -> anyhow::Result<()> {
if EXPECTED_CORE_SHA256.is_empty() {
anyhow::bail!("expected Core SHA256 is empty");
}
Ok(())
}
pub(super) fn release_managed_core_on_shutdown() {
let mut managed = lock_surviving_poison(&MANAGED_CORE);
if let Err(error) = release_managed_core(&mut managed) {
log_message(format!(
"Helper could not stop the managed Core on shutdown: {error}"
));
}
}
#[cfg(all(
not(all(feature = "windows-service", target_os = "windows")),
not(target_os = "linux")
))]
pub async fn run_service() -> anyhow::Result<()> {
run_service_until(pending(), || Ok(())).await
}
#[cfg(not(any(
all(feature = "windows-service", target_os = "windows"),
target_os = "linux"
)))]
async fn run_service_until<F, S>(shutdown: F, on_started: S) -> anyhow::Result<()>
where
F: Future<Output = ()> + Send + 'static,
S: FnOnce() -> anyhow::Result<()>,
{
ensure_core_sha256_configured()?;
let (_, server) = warp::serve(routes())
.try_bind_with_graceful_shutdown(([127, 0, 0, 1], LISTEN_PORT), shutdown)
.map_err(|error| anyhow::anyhow!("bind helper server: {error}"))?;
on_started()?;
server.await;
release_managed_core_on_shutdown();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
static PROCESS_STATE: Mutex<()> = Mutex::new(());
fn lock_process_state() -> std::sync::MutexGuard<'static, ()> {
PROCESS_STATE
.lock()
.unwrap_or_else(|error| error.into_inner())
}
#[cfg(not(target_os = "linux"))]
const ALLOWED_CORE_ADDRESS: &str = r"\\.\pipe\FlClashCore_0123456789abcdef0123456789abcdef";
#[cfg(target_os = "linux")]
const ALLOWED_CORE_ADDRESS: &str = "/tmp/FlClashSocket_4821.sock";
fn spawn_placeholder_core() -> Child {
#[cfg(windows)]
let mut command = {
let mut command = Command::new("cmd");
command.args(["/c", "exit"]);
command
};
#[cfg(not(windows))]
let mut command = Command::new("true");
command
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn placeholder Core")
}
fn spawn_running_core() -> Child {
#[cfg(windows)]
let mut command = {
let mut command = Command::new("ping");
command.args(["-n", "60", "127.0.0.1"]);
command
};
#[cfg(not(windows))]
let mut command = {
let mut command = Command::new("sleep");
command.arg("60");
command
};
command
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn running Core")
}
fn adopt_core(session_id: &str) {
*lock_surviving_poison(&MANAGED_CORE) =
Some(ManagedCore::adopt(session_id.to_string(), spawn_running_core()).unwrap());
}
#[test]
fn protocol_uses_lowercase_session_ownership() {
assert_eq!(PROTOCOL_VERSION, if cfg!(windows) { "8" } else { "6" });
assert!(is_valid_session_id("0123456789abcdef0123456789abcdef"));
assert!(!is_valid_session_id("ABCDEF0123456789abcdef0123456789"));
assert!(!is_valid_session_id("0123456789abcdef"));
}
#[test]
fn stop_decision_never_touches_another_session() {
let requested = "0123456789abcdef0123456789abcdef";
let other = "fedcba9876543210fedcba9876543210";
assert_eq!(stop_decision(None, requested), StopDecision::NotRunning);
assert_eq!(
stop_decision(Some(requested), requested),
StopDecision::Stop
);
assert_eq!(
stop_decision(Some(other), requested),
StopDecision::SessionMismatch
);
}
#[tokio::test]
async fn ping_returns_running_helper_path_for_verified_core() {
let response = ping_response(Ok(PathBuf::from("FlClashHelperService.exe")));
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
PROTOCOL_VERSION
);
assert_eq!(
warp::hyper::body::to_bytes(response.into_body())
.await
.unwrap(),
"FlClashHelperService.exe"
);
}
#[tokio::test]
async fn ping_response_renders_core_verification_error() {
let response = ping_response(Err(Error::other("Core executable SHA256 mismatch")));
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
PROTOCOL_VERSION
);
assert_eq!(
warp::hyper::body::to_bytes(response.into_body())
.await
.unwrap(),
"Core executable SHA256 mismatch"
);
}
#[tokio::test]
async fn unknown_endpoints_are_not_found() {
let response = warp::test::request()
.method("GET")
.path("/nope")
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn known_endpoints_reject_the_wrong_method() {
let response = warp::test::request()
.method("POST")
.path("/ping")
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn ping_is_available_without_authentication() {
let response = warp::test::request()
.method("GET")
.path(&format!("/ping?coreSha256={EXPECTED_CORE_SHA256}"))
.reply(&routes())
.await;
assert!(response.status() == StatusCode::OK || response.status() == StatusCode::CONFLICT);
assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
PROTOCOL_VERSION
);
}
#[tokio::test]
async fn ping_requires_the_core_sha256_query_parameter() {
let response = warp::test::request()
.method("GET")
.path("/ping")
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
PROTOCOL_VERSION
);
}
#[tokio::test]
async fn ping_rejects_a_core_sha256_that_does_not_match() {
let requested = if EXPECTED_CORE_SHA256.starts_with('0') {
format!("1{}", EXPECTED_CORE_SHA256.get(1..).unwrap_or(""))
} else {
format!("0{}", EXPECTED_CORE_SHA256.get(1..).unwrap_or(""))
};
assert_ne!(requested, EXPECTED_CORE_SHA256);
let response = warp::test::request()
.method("GET")
.path(&format!("/ping?coreSha256={requested}"))
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
PROTOCOL_VERSION
);
let body: serde_json::Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(body["code"], "coreSha256Mismatch");
}
#[tokio::test]
async fn logs_are_available_without_authentication() {
let response = warp::test::request()
.method("GET")
.path("/logs")
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"text/plain; charset=utf-8"
);
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
}
#[tokio::test]
async fn a_launch_failure_carries_the_os_error_code() {
let response = launch_failure_response(&Error::from_raw_os_error(577));
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body: serde_json::Value = serde_json::from_slice(
&warp::hyper::body::to_bytes(response.into_body())
.await
.unwrap(),
)
.unwrap();
assert_eq!(body["code"], "processLaunchFailed");
assert_eq!(body["details"]["osError"], 577);
}
#[tokio::test]
async fn a_launch_failure_without_an_os_error_omits_the_details() {
let response = launch_failure_response(&Error::other("spawn refused"));
let body: serde_json::Value = serde_json::from_slice(
&warp::hyper::body::to_bytes(response.into_body())
.await
.unwrap(),
)
.unwrap();
assert_eq!(body["message"], "spawn refused");
assert!(body.get("details").is_none());
}
#[tokio::test]
async fn start_rejects_a_caller_supplied_core_argument() {
let response = warp::test::request()
.method("POST")
.path("/start")
.header("content-type", "application/json")
.body(format!(
r#"{{"address":{},"sessionId":"0123456789abcdef0123456789abcdef","path":"attacker.exe"}}"#,
serde_json::to_string(ALLOWED_CORE_ADDRESS).unwrap()
))
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(body["code"], "invalidRequest");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn launch_releases_the_managed_core_before_rejecting_an_unverified_core() {
let _state = lock_process_state();
*lock_surviving_poison(&MANAGED_CORE) = Some(
ManagedCore::adopt(
"fedcba9876543210fedcba9876543210".to_string(),
spawn_placeholder_core(),
)
.unwrap(),
);
let response = launch(StartParams {
address: ALLOWED_CORE_ADDRESS.to_string(),
session_id: "0123456789abcdef0123456789abcdef".to_string(),
});
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(lock_surviving_poison(&MANAGED_CORE).is_none());
let body: serde_json::Value = serde_json::from_slice(
&warp::hyper::body::to_bytes(response.into_body())
.await
.unwrap(),
)
.unwrap();
assert_eq!(body["code"], "coreVerificationFailed");
}
#[test]
fn a_core_that_exits_on_its_own_is_reaped() {
let _state = lock_process_state();
let session_id = "0123456789abcdef0123456789abcdef";
*lock_surviving_poison(&MANAGED_CORE) =
Some(ManagedCore::adopt(session_id.to_string(), spawn_placeholder_core()).unwrap());
reap_exited_core(session_id);
assert!(lock_surviving_poison(&MANAGED_CORE).is_none());
}
#[test]
fn reaping_leaves_a_running_or_newer_core_owned() {
let _state = lock_process_state();
let session_id = "0123456789abcdef0123456789abcdef";
*lock_surviving_poison(&MANAGED_CORE) =
Some(ManagedCore::adopt(session_id.to_string(), spawn_placeholder_core()).unwrap());
reap_exited_core("fedcba9876543210fedcba9876543210");
assert!(lock_surviving_poison(&MANAGED_CORE).is_some());
adopt_core(session_id);
reap_exited_core(session_id);
let mut managed = lock_surviving_poison(&MANAGED_CORE);
assert!(managed
.as_mut()
.unwrap()
.child
.try_wait()
.unwrap()
.is_none());
release_managed_core(&mut managed).unwrap();
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn stop_confirms_the_exit_before_reporting_success() {
let _state = lock_process_state();
let session_id = "0123456789abcdef0123456789abcdef";
adopt_core(session_id);
let response = warp::test::request()
.method("POST")
.path("/stop")
.json(&StopParams {
session_id: session_id.to_string(),
})
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(body["stopped"], true);
assert!(body.get("reason").is_none());
assert!(lock_surviving_poison(&MANAGED_CORE).is_none());
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn stop_keeps_another_session_owned_and_running() {
let _state = lock_process_state();
adopt_core("fedcba9876543210fedcba9876543210");
let response = warp::test::request()
.method("POST")
.path("/stop")
.json(&StopParams {
session_id: "0123456789abcdef0123456789abcdef".to_string(),
})
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::CONFLICT);
let body: serde_json::Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(body["reason"], "sessionMismatch");
let mut managed = lock_surviving_poison(&MANAGED_CORE);
let core = managed.as_mut().expect("Core stays owned");
assert_eq!(core.session_id, "fedcba9876543210fedcba9876543210");
assert!(core.child.try_wait().unwrap().is_none());
release_managed_core(&mut managed).unwrap();
}
#[cfg(target_os = "linux")]
fn spawn_shell_core_once_ready(script: &str) -> Child {
let mut child = Command::new("sh")
.args(["-c", script])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn shell core");
let mut ready = String::new();
std::io::BufReader::new(child.stdout.take().expect("piped stdout"))
.read_line(&mut ready)
.expect("read readiness");
assert_eq!(ready, "ready\n");
child
}
#[cfg(target_os = "linux")]
#[test]
fn terminate_lets_the_core_exit_on_its_own_before_killing_it() {
use std::os::unix::process::ExitStatusExt;
let mut core = ManagedCore::adopt(
"0123456789abcdef0123456789abcdef".to_string(),
spawn_shell_core_once_ready(
"trap 'exit 0' TERM; echo ready; while :; do sleep 0.05; done",
),
)
.unwrap();
core.terminate().unwrap();
let status = core.child.try_wait().unwrap().expect("Core exited");
assert_eq!(status.code(), Some(0));
assert_eq!(status.signal(), None);
}
#[cfg(target_os = "linux")]
#[test]
fn terminate_kills_a_core_that_ignores_the_exit_request() {
use std::os::unix::process::ExitStatusExt;
let mut core = ManagedCore::adopt(
"0123456789abcdef0123456789abcdef".to_string(),
spawn_shell_core_once_ready("trap '' TERM; echo ready; exec sleep 30"),
)
.unwrap();
core.terminate().unwrap();
let status = core.child.try_wait().unwrap().expect("Core exited");
assert_eq!(status.signal(), Some(libc::SIGKILL));
}
#[cfg(windows)]
#[test]
fn closing_the_job_takes_the_core_down_with_the_helper() {
let core = ManagedCore::adopt(
"0123456789abcdef0123456789abcdef".to_string(),
spawn_running_core(),
)
.unwrap();
let ManagedCore {
mut child, _job, ..
} = core;
drop(_job);
let deadline = Instant::now() + CORE_EXIT_TIMEOUT;
while child.try_wait().unwrap().is_none() {
assert!(Instant::now() < deadline, "Core outlived its job");
thread::sleep(CORE_EXIT_POLL_INTERVAL);
}
}
#[test]
fn terminate_confirms_the_exit_of_a_running_core() {
let mut managed = Some(
ManagedCore::adopt(
"0123456789abcdef0123456789abcdef".to_string(),
spawn_running_core(),
)
.unwrap(),
);
release_managed_core(&mut managed).unwrap();
assert!(managed.is_none());
assert!(release_managed_core(&mut managed).is_ok());
}
#[tokio::test]
async fn start_rejects_an_invalid_session_before_core_verification() {
let response = warp::test::request()
.method("POST")
.path("/start")
.json(&StartParams {
address: ALLOWED_CORE_ADDRESS.to_string(),
session_id: "ABCDEF0123456789abcdef0123456789".to_string(),
})
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(body["code"], "invalidRequest");
}
#[test]
fn verifies_core_sha256_in_all_build_modes() {
let path =
std::env::temp_dir().join(format!("flclash-helper-core-sha256-{}", std::process::id()));
let mut file = File::create(&path).unwrap();
file.write_all(b"test").unwrap();
drop(file);
assert!(open_verified_core(
&path,
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
)
.is_ok());
assert_eq!(
open_verified_core(&path, "invalid")
.unwrap_err()
.to_string(),
"Core executable SHA256 mismatch"
);
std::fs::remove_file(path).unwrap();
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn stop_is_available_without_authentication() {
let _state = lock_process_state();
let response = warp::test::request()
.method("POST")
.path("/stop")
.json(&StopParams {
session_id: "0123456789abcdef0123456789abcdef".to_string(),
})
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(body["sessionId"], "0123456789abcdef0123456789abcdef");
assert_eq!(body["stopped"], false);
assert_eq!(body["reason"], "notRunning");
}
#[tokio::test]
async fn stop_requires_a_session_json_body() {
let response = warp::test::request()
.method("POST")
.path("/stop")
.body("")
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(body["code"], "invalidRequest");
}
#[tokio::test]
async fn stop_rejects_unknown_fields() {
let response = warp::test::request()
.method("POST")
.path("/stop")
.header("content-type", "application/json")
.body(r#"{"sessionId":"0123456789abcdef0123456789abcdef","force":true}"#)
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(body["code"], "invalidRequest");
}
#[test]
fn core_path_is_fixed_beside_the_helper() {
assert_eq!(
core_path().unwrap().file_name().unwrap(),
std::ffi::OsStr::new(env!("CORE_NAME"))
);
}
#[tokio::test]
async fn browser_origins_and_rebound_hosts_cannot_reach_any_endpoint() {
for (method, path) in [
("GET", "/ping"),
("GET", "/logs"),
("POST", "/start"),
("POST", "/stop"),
] {
for (header, value) in [
("origin", "https://attacker.test"),
("origin", "null"),
("host", "attacker.test:47890"),
] {
let response = warp::test::request()
.method(method)
.path(path)
.header(header, value)
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(response
.headers()
.get("access-control-allow-origin")
.is_none());
}
}
for host in ["127.0.0.1:47890", "localhost:47890", "FlClashHelperService"] {
let response = warp::test::request()
.path("/logs")
.header("host", host)
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::OK);
}
}
#[tokio::test]
async fn state_changes_are_size_limited() {
let oversized = " ".repeat(MAX_REQUEST_BODY_BYTES as usize + 1);
for path in ["/start", "/stop"] {
let response = warp::test::request()
.method("POST")
.path(path)
.body(&oversized)
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(response.headers()["connection"], "close");
let response = warp::test::request()
.method("POST")
.path(path)
.reply(&routes())
.await;
assert_eq!(response.status(), StatusCode::LENGTH_REQUIRED);
}
}
#[cfg(not(target_os = "linux"))]
#[test]
fn only_accepts_random_core_pipe_namespace() {
assert!(is_allowed_core_address(
r"\\.\pipe\FlClashCore_0123456789abcdef0123456789abcdef"
));
assert!(!is_allowed_core_address(r"\\.\pipe\FlClashCore"));
assert!(!is_allowed_core_address(
r"\\.\pipe\Other_0123456789abcdef0123456789abcdef"
));
assert!(!is_allowed_core_address(
r"\\.\pipe\FlClashCore_0123456789abcdef"
));
assert!(!is_allowed_core_address(
r"\\.\pipe\FlClashCore_0123456789abcdef0123456789abcdeg"
));
assert!(!is_allowed_core_address(
r"\\.\pipe\FlClashCore_ABCDEF0123456789abcdef0123456789"
));
}
#[cfg(target_os = "linux")]
#[test]
fn only_accepts_random_core_socket_namespace() {
assert!(is_allowed_core_address("/tmp/FlClashSocket_4821.sock"));
assert!(!is_allowed_core_address("/tmp/FlClashSocket_.sock"));
assert!(!is_allowed_core_address("/tmp/FlClashSocket_4821"));
assert!(!is_allowed_core_address("/tmp/Other_4821.sock"));
assert!(!is_allowed_core_address("/tmp/FlClashSocket_../x.sock"));
assert!(!is_allowed_core_address(
"/tmp/FlClashSocket_12345678901.sock"
));
}
#[test]
fn a_poisoned_lock_still_hands_out_the_guarded_value() {
let mutex = Mutex::new(7);
let _ = std::panic::catch_unwind(|| {
let _guard = mutex.lock().unwrap();
panic!("poison the lock");
});
assert!(mutex.lock().is_err());
assert_eq!(*lock_surviving_poison(&mutex), 7);
}
#[test]
fn logging_survives_a_panic_that_poisoned_the_log_buffer() {
let _ = std::panic::catch_unwind(|| {
let _guard = LOGS.lock().unwrap();
panic!("poison the log buffer");
});
log_message("helper still logs after poisoning".to_string());
assert!(lock_surviving_poison(&LOGS)
.iter()
.any(|entry| entry == "helper still logs after poisoning"));
}
}