mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-03 17:45:44 +00:00
feat: stabilize mobile runtime and VPN portal (#2536)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user