feat: stabilize mobile runtime and VPN portal (#2536)

This commit is contained in:
KKRainbow
2026-08-29 13:40:56 +08:00
committed by GitHub
parent 4a10d1c2b9
commit 25f6e2dc5e
31 changed files with 1415 additions and 1302 deletions
+1
View File
@@ -8,6 +8,7 @@ crate-type = ["staticlib", "rlib"]
[dependencies]
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
easytier-ffi = { path = "../easytier-ffi", default-features = false, features = [
"c-abi",
+25 -5
View File
@@ -28,14 +28,34 @@ extern "C" {
#endif
/**
* @brief Enable diagnostic EasyTier logging to stderr.
* @brief Configure persistent EasyTier diagnostic logging.
*
* Installs a narrow global tracing subscriber that records port-forward
* lifecycle events. Repeated calls are idempotent.
* Enabling writes targeted connection trace/debug events into rotating log
* files in `directory`; disabling turns the filter off and flushes output.
*
* @return 0 on success, -1 if another tracing subscriber was installed first.
* @param directory UTF-8 directory path. Required when enabling; ignored when
* disabling.
* @param enabled Non-zero to enable, zero to disable.
* @return 0 on success, -1 on failure.
*/
int easytier_ios_enable_diagnostic_logging(void);
int easytier_ios_configure_diagnostic_logging(const char *directory,
int enabled);
/**
* @brief Append a host lifecycle or network-path marker to the active log.
*
* This is a no-op while diagnostic logging is disabled.
*
* @param message Non-null NUL-terminated UTF-8 event text.
* @return 0 on success, -1 on failure.
*/
int easytier_ios_append_diagnostic_event(const char *message);
/** @brief Flush diagnostic log output. */
int easytier_ios_flush_diagnostic_logging(void);
/** @brief Delete all diagnostic log content and reopen the active log. */
int easytier_ios_clear_diagnostic_logs(void);
/**
* @brief Start one EasyTier network instance from a TOML config string.
@@ -0,0 +1,308 @@
use std::{
fs::{self, File, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use tracing_subscriber::fmt::MakeWriter;
pub(crate) const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
pub(crate) const MAX_LOG_FILES: usize = 4;
#[derive(Clone)]
pub(crate) struct DiagnosticMakeWriter {
inner: Arc<Mutex<RotatingLog>>,
}
impl DiagnosticMakeWriter {
pub(crate) fn new(directory: &Path) -> io::Result<Self> {
Ok(Self {
inner: Arc::new(Mutex::new(RotatingLog::open(directory)?)),
})
}
pub(crate) fn set_directory(&self, directory: &Path) -> io::Result<()> {
self.lock()?.set_directory(directory)
}
pub(crate) fn clear(&self) -> io::Result<()> {
self.lock()?.clear()
}
pub(crate) fn flush(&self) -> io::Result<()> {
self.lock()?.flush()
}
fn lock(&self) -> io::Result<std::sync::MutexGuard<'_, RotatingLog>> {
self.inner
.lock()
.map_err(|_| io::Error::other("diagnostic log lock poisoned"))
}
}
impl<'a> MakeWriter<'a> for DiagnosticMakeWriter {
type Writer = BufferedEventWriter;
fn make_writer(&'a self) -> Self::Writer {
BufferedEventWriter {
target: self.clone(),
buffer: Vec::new(),
}
}
}
pub(crate) struct BufferedEventWriter {
target: DiagnosticMakeWriter,
buffer: Vec<u8>,
}
impl BufferedEventWriter {
fn commit(&mut self) -> io::Result<()> {
if self.buffer.is_empty() {
return Ok(());
}
let buffer = std::mem::take(&mut self.buffer);
self.target.lock()?.write_event(&buffer)
}
}
impl Write for BufferedEventWriter {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
self.buffer.extend_from_slice(buffer);
Ok(buffer.len())
}
fn flush(&mut self) -> io::Result<()> {
self.commit()
}
}
impl Drop for BufferedEventWriter {
fn drop(&mut self) {
let _ = self.commit();
}
}
struct RotatingLog {
directory: PathBuf,
active: Option<File>,
active_bytes: u64,
}
impl RotatingLog {
fn open(directory: &Path) -> io::Result<Self> {
fs::create_dir_all(directory)?;
let mut log = Self {
directory: directory.to_owned(),
active: None,
active_bytes: 0,
};
log.truncate_oversized_files()?;
log.open_active()?;
Ok(log)
}
fn set_directory(&mut self, directory: &Path) -> io::Result<()> {
if self.directory == directory && self.active.is_some() {
return Ok(());
}
self.flush()?;
self.active = None;
self.directory = directory.to_owned();
fs::create_dir_all(directory)?;
self.truncate_oversized_files()?;
self.open_active()
}
fn active_path(&self) -> PathBuf {
self.directory.join("easytier.log")
}
fn rotated_path(&self, index: usize) -> PathBuf {
self.directory.join(format!("easytier.{index}.log"))
}
fn truncate_oversized_files(&self) -> io::Result<()> {
let paths = std::iter::once(self.active_path())
.chain((1..MAX_LOG_FILES).map(|index| self.rotated_path(index)));
for path in paths {
if path
.metadata()
.is_ok_and(|metadata| metadata.len() > MAX_LOG_BYTES)
{
OpenOptions::new()
.write(true)
.open(path)?
.set_len(MAX_LOG_BYTES)?;
}
}
Ok(())
}
fn open_active(&mut self) -> io::Result<()> {
let path = self.active_path();
let file = OpenOptions::new().create(true).append(true).open(&path)?;
self.active_bytes = file.metadata()?.len();
self.active = Some(file);
if self.active_bytes >= MAX_LOG_BYTES {
self.rotate()?;
}
Ok(())
}
fn write_event(&mut self, event: &[u8]) -> io::Result<()> {
if event.is_empty() {
return Ok(());
}
if self.active_bytes > 0
&& self.active_bytes.saturating_add(event.len() as u64) > MAX_LOG_BYTES
{
self.rotate()?;
}
let remaining = MAX_LOG_BYTES.saturating_sub(self.active_bytes) as usize;
let event = &event[..event.len().min(remaining)];
if let Some(active) = self.active.as_mut() {
active.write_all(event)?;
self.active_bytes += event.len() as u64;
}
Ok(())
}
fn rotate(&mut self) -> io::Result<()> {
self.flush()?;
self.active = None;
let oldest = self.rotated_path(MAX_LOG_FILES - 1);
if oldest.exists() {
fs::remove_file(oldest)?;
}
for index in (1..MAX_LOG_FILES - 1).rev() {
let source = self.rotated_path(index);
if source.exists() {
fs::rename(source, self.rotated_path(index + 1))?;
}
}
let active = self.active_path();
if active.exists() {
fs::rename(active, self.rotated_path(1))?;
}
self.active_bytes = 0;
self.active = Some(
OpenOptions::new()
.create(true)
.append(true)
.open(self.active_path())?,
);
Ok(())
}
fn clear(&mut self) -> io::Result<()> {
self.flush()?;
self.active = None;
for index in 1..MAX_LOG_FILES {
let path = self.rotated_path(index);
if path.exists() {
fs::remove_file(path)?;
}
}
let active = self.active_path();
if active.exists() {
fs::remove_file(&active)?;
}
self.active_bytes = 0;
self.active = Some(OpenOptions::new().create(true).append(true).open(active)?);
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
match self.active.as_mut() {
Some(active) => active.flush(),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
struct TempDir(PathBuf);
impl TempDir {
fn new(name: &str) -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("easytier-ios-{name}-{unique}"));
fs::create_dir_all(&path).unwrap();
Self(path)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn rotates_without_exceeding_file_limit() {
let directory = TempDir::new("rotation");
let mut log = RotatingLog::open(&directory.0).unwrap();
let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize];
for _ in 0..6 {
log.write_event(&event).unwrap();
}
log.flush().unwrap();
let files = fs::read_dir(&directory.0)
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(files.len(), MAX_LOG_FILES);
assert!(
files
.iter()
.all(|entry| entry.metadata().unwrap().len() <= MAX_LOG_BYTES)
);
}
#[test]
fn clear_removes_rotated_content_and_keeps_active_file_writable() {
let directory = TempDir::new("clear");
let mut log = RotatingLog::open(&directory.0).unwrap();
let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize];
log.write_event(&event).unwrap();
log.write_event(&event).unwrap();
log.clear().unwrap();
log.write_event(b"after clear\n").unwrap();
log.flush().unwrap();
assert_eq!(fs::read(log.active_path()).unwrap(), b"after clear\n");
assert!(!log.rotated_path(1).exists());
}
#[test]
fn opening_truncates_oversized_known_files() {
let directory = TempDir::new("oversized");
for name in ["easytier.log", "easytier.1.log"] {
let file = File::create(directory.0.join(name)).unwrap();
file.set_len(MAX_LOG_BYTES + 1).unwrap();
}
let log = RotatingLog::open(&directory.0).unwrap();
for index in 1..MAX_LOG_FILES {
let path = log.rotated_path(index);
if path.exists() {
assert!(path.metadata().unwrap().len() <= MAX_LOG_BYTES);
}
}
assert!(log.active_path().metadata().unwrap().len() <= MAX_LOG_BYTES);
}
}
+171 -19
View File
@@ -12,43 +12,186 @@
//! All exported functions are panic-safe: panics are caught at the FFI
//! boundary and reported through `easytier_ios_last_error`.
mod diagnostic_logging;
mod error;
mod strings;
use std::{
ffi::{CStr, c_char, c_int},
panic::{AssertUnwindSafe, catch_unwind},
path::Path,
ptr,
sync::OnceLock,
};
use diagnostic_logging::DiagnosticMakeWriter;
use strings::cstring_for;
use tracing_subscriber::{
EnvFilter, Registry, layer::SubscriberExt, reload, util::SubscriberInitExt,
};
/// Install a narrow tracing subscriber for an embedding app that needs to
/// diagnose EasyTier transport failures. The subscriber writes port-forward
/// lifecycle events to stderr.
const DIAGNOSTIC_FILTER: &str = concat!(
"easytier_ios::diagnostics=trace,",
"easytier_core::instance=trace,",
"easytier_core::connectivity=debug,",
"easytier_core::socket::udp=debug,",
"easytier_core::gateway::port_forward=trace"
);
struct DiagnosticLogger {
writer: DiagnosticMakeWriter,
filter: reload::Handle<EnvFilter, Registry>,
}
impl DiagnosticLogger {
fn install(directory: &Path) -> Result<Self, String> {
let writer = DiagnosticMakeWriter::new(directory).map_err(|error| error.to_string())?;
let (filter_layer, filter) = reload::Layer::new(EnvFilter::new("off"));
let format_layer = tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_target(true)
.with_writer(writer.clone());
tracing_subscriber::registry()
.with(filter_layer)
.with(format_layer)
.try_init()
.map_err(|error| error.to_string())?;
Ok(Self { writer, filter })
}
fn enable(&self, directory: &Path) -> Result<(), String> {
self.writer
.set_directory(directory)
.map_err(|error| error.to_string())?;
self.filter
.reload(EnvFilter::new(DIAGNOSTIC_FILTER))
.map_err(|error| error.to_string())
}
fn disable(&self) -> Result<(), String> {
self.filter
.reload(EnvFilter::new("off"))
.map_err(|error| error.to_string())?;
self.writer.flush().map_err(|error| error.to_string())
}
}
static DIAGNOSTIC_LOGGER: OnceLock<Result<DiagnosticLogger, String>> = OnceLock::new();
/// Configure persistent EasyTier diagnostic logging for the embedding app.
///
/// Returns 0 on success, -1 when another global subscriber was installed
/// first (see `easytier_ios_last_error`). Repeated calls are idempotent.
/// Enabling installs a process-wide subscriber on first use, writes into
/// `directory`, and turns on trace/debug events for connection lifecycle
/// modules. Disabling reloads the filter to `off`, avoiding event construction
/// while keeping the subscriber ready for a later enable.
///
/// # Safety
/// When `enabled` is non-zero, `directory` must be a non-null pointer to a
/// NUL-terminated UTF-8 path.
#[unsafe(no_mangle)]
pub extern "C" fn easytier_ios_enable_diagnostic_logging() -> c_int {
pub unsafe extern "C" fn easytier_ios_configure_diagnostic_logging(
directory: *const c_char,
enabled: c_int,
) -> c_int {
unsafe {
guarded(-1, || {
error::clear_error();
if enabled == 0 {
let Some(logger) = DIAGNOSTIC_LOGGER.get() else {
return 0;
};
return match logger {
Ok(logger) => diagnostic_result(logger.disable()),
Err(message) => diagnostic_failure(message),
};
}
let directory = match cstr_arg(directory, "diagnostic log directory") {
Ok(directory) if !directory.is_empty() => Path::new(directory),
Ok(_) => {
error::set_error("diagnostic log directory must not be empty");
return -1;
}
Err(message) => {
error::set_error(&message);
return -1;
}
};
let logger = DIAGNOSTIC_LOGGER.get_or_init(|| DiagnosticLogger::install(directory));
match logger {
Ok(logger) => diagnostic_result(logger.enable(directory)),
Err(message) => diagnostic_failure(message),
}
})
}
}
fn diagnostic_result(result: Result<(), String>) -> c_int {
match result {
Ok(()) => 0,
Err(message) => diagnostic_failure(&message),
}
}
fn diagnostic_failure(message: &str) -> c_int {
error::set_error(&format!("diagnostic logging failed: {message}"));
-1
}
/// Append a host-app lifecycle or network-path marker to the diagnostic log.
/// This is a no-op while logging is disabled or has never been enabled.
///
/// # Safety
/// `message` must be a non-null pointer to a NUL-terminated UTF-8 string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn easytier_ios_append_diagnostic_event(message: *const c_char) -> c_int {
unsafe {
guarded(-1, || {
error::clear_error();
let message = match cstr_arg(message, "diagnostic event") {
Ok(message) => message,
Err(message) => {
error::set_error(&message);
return -1;
}
};
tracing::info!(target: "easytier_ios::diagnostics", %message, "host event");
0
})
}
}
/// Flush buffered diagnostic output. A logger that has never been enabled is
/// already flushed and returns success.
#[unsafe(no_mangle)]
pub extern "C" fn easytier_ios_flush_diagnostic_logging() -> c_int {
guarded(-1, || {
error::clear_error();
static RESULT: OnceLock<Result<(), String>> = OnceLock::new();
let result = RESULT.get_or_init(|| {
let filter =
tracing_subscriber::EnvFilter::new("easytier_core::gateway::port_forward=info");
tracing_subscriber::fmt()
.with_ansi(false)
.with_env_filter(filter)
.with_writer(std::io::stderr)
.try_init()
.map_err(|error| error.to_string())
});
match result {
let Some(Ok(logger)) = DIAGNOSTIC_LOGGER.get() else {
return 0;
};
match logger.writer.flush() {
Ok(()) => 0,
Err(message) => {
error::set_error(&format!("failed to enable diagnostic logging: {message}"));
error::set_error(&format!("failed to flush diagnostic log: {message}"));
-1
}
}
})
}
/// Delete all rotated diagnostic content while keeping the active writer open.
#[unsafe(no_mangle)]
pub extern "C" fn easytier_ios_clear_diagnostic_logs() -> c_int {
guarded(-1, || {
error::clear_error();
let Some(Ok(logger)) = DIAGNOSTIC_LOGGER.get() else {
return 0;
};
match logger.writer.clear() {
Ok(()) => 0,
Err(message) => {
error::set_error(&format!("failed to clear diagnostic logs: {message}"));
-1
}
}
@@ -354,6 +497,15 @@ mod tests {
});
}
#[test]
fn diagnostic_filter_excludes_packet_payload_targets() {
// These targets include exceptional Debug events containing complete
// ZCPacket or decrypted packet buffers. Persistent diagnostics must
// never enable them at any level.
assert!(!DIAGNOSTIC_FILTER.contains("peer_manager"));
assert!(!DIAGNOSTIC_FILTER.contains("easytier::tunnel"));
}
fn acquire() -> MutexGuard<'static, ()> {
init_logs();
TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner())