From 2ea7813ed4c7fd728099a11fa29df54695b31233 Mon Sep 17 00:00:00 2001 From: Vladimir Krivopalov Date: Fri, 20 Mar 2026 20:31:47 +0200 Subject: [PATCH 1/5] Add Unix daemon mode with PID file and privilege dropping Implement core daemon infrastructure for running telemt as a background service on Unix platforms (Linux, FreeBSD, etc.): - Add src/daemon module with classic double-fork daemonization - Implement flock-based PID file management to prevent duplicate instances - Add privilege dropping (setuid/setgid) after socket binding - New CLI flags: --daemon, --foreground, --pid-file, --run-as-user, --run-as-group, --working-dir Daemonization occurs before tokio runtime starts to ensure clean fork. PID file uses exclusive locking to detect already-running instances. Privilege dropping happens after bind_listeners() to allow binding to privileged ports (< 1024) before switching to unprivileged user. Signed-off-by: Vladimir Krivopalov --- Cargo.toml | 18 +- src/cli.rs | 63 ++++- src/daemon/mod.rs | 533 +++++++++++++++++++++++++++++++++++++++++ src/maestro/helpers.rs | 101 ++++++-- src/maestro/mod.rs | 65 ++++- src/main.rs | 43 +++- 6 files changed, 789 insertions(+), 34 deletions(-) create mode 100644 src/daemon/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 53082db..04228a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,13 @@ static_assertions = "1.1" # Network socket2 = { version = "0.6", features = ["all"] } -nix = { version = "0.31", default-features = false, features = ["net", "fs"] } +nix = { version = "0.31", default-features = false, features = [ + "net", + "user", + "process", + "fs", + "signal", +] } shadowsocks = { version = "1.24", features = ["aead-cipher-2022"] } # Serialization @@ -65,8 +71,14 @@ hyper = { version = "1", features = ["server", "http1"] } hyper-util = { version = "0.1", features = ["tokio", "server-auto"] } http-body-util = "0.1" httpdate = "1.0" -tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } -rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = [ + "tls12", +] } +rustls = { version = "0.23", default-features = false, features = [ + "std", + "tls12", + "ring", +] } webpki-roots = "1.0" [dev-dependencies] diff --git a/src/cli.rs b/src/cli.rs index 6dc0e2a..9b59015 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,10 +1,13 @@ -//! CLI commands: --init (fire-and-forget setup) +//! CLI commands: --init (fire-and-forget setup), daemon options use rand::RngExt; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(unix)] +use crate::daemon::DaemonOptions; + /// Options for the init command pub struct InitOptions { pub port: u16, @@ -15,6 +18,64 @@ pub struct InitOptions { pub no_start: bool, } +/// Parse daemon-related options from CLI args. +#[cfg(unix)] +pub fn parse_daemon_args(args: &[String]) -> DaemonOptions { + let mut opts = DaemonOptions::default(); + let mut i = 0; + + while i < args.len() { + match args[i].as_str() { + "--daemon" | "-d" => { + opts.daemonize = true; + } + "--foreground" | "-f" => { + opts.foreground = true; + } + "--pid-file" => { + i += 1; + if i < args.len() { + opts.pid_file = Some(PathBuf::from(&args[i])); + } + } + s if s.starts_with("--pid-file=") => { + opts.pid_file = Some(PathBuf::from(s.trim_start_matches("--pid-file="))); + } + "--run-as-user" => { + i += 1; + if i < args.len() { + opts.user = Some(args[i].clone()); + } + } + s if s.starts_with("--run-as-user=") => { + opts.user = Some(s.trim_start_matches("--run-as-user=").to_string()); + } + "--run-as-group" => { + i += 1; + if i < args.len() { + opts.group = Some(args[i].clone()); + } + } + s if s.starts_with("--run-as-group=") => { + opts.group = Some(s.trim_start_matches("--run-as-group=").to_string()); + } + "--working-dir" => { + i += 1; + if i < args.len() { + opts.working_dir = Some(PathBuf::from(&args[i])); + } + } + s if s.starts_with("--working-dir=") => { + opts.working_dir = Some(PathBuf::from(s.trim_start_matches("--working-dir="))); + } + _ => {} + } + i += 1; + } + + opts +} + impl Default for InitOptions { fn default() -> Self { Self { diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs new file mode 100644 index 0000000..f5fed72 --- /dev/null +++ b/src/daemon/mod.rs @@ -0,0 +1,533 @@ +//! Unix daemon support for telemt. +//! +//! Provides classic Unix daemonization (double-fork), PID file management, +//! and privilege dropping for running telemt as a background service. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; + +use nix::fcntl::{Flock, FlockArg}; +use nix::unistd::{self, ForkResult, Gid, Pid, Uid, chdir, close, fork, getpid, setsid}; +use tracing::{debug, info, warn}; + +/// Default PID file location. +pub const DEFAULT_PID_FILE: &str = "/var/run/telemt.pid"; + +/// Daemon configuration options parsed from CLI. +#[derive(Debug, Clone, Default)] +pub struct DaemonOptions { + /// Run as daemon (fork to background). + pub daemonize: bool, + /// Path to PID file. + pub pid_file: Option, + /// User to run as after binding sockets. + pub user: Option, + /// Group to run as after binding sockets. + pub group: Option, + /// Working directory for the daemon. + pub working_dir: Option, + /// Explicit foreground mode (for systemd Type=simple). + pub foreground: bool, +} + +impl DaemonOptions { + /// Returns the effective PID file path. + pub fn pid_file_path(&self) -> &Path { + self.pid_file + .as_deref() + .unwrap_or(Path::new(DEFAULT_PID_FILE)) + } + + /// Returns true if we should actually daemonize. + /// Foreground flag takes precedence. + pub fn should_daemonize(&self) -> bool { + self.daemonize && !self.foreground + } +} + +/// Error types for daemon operations. +#[derive(Debug, thiserror::Error)] +pub enum DaemonError { + #[error("fork failed: {0}")] + ForkFailed(#[source] nix::Error), + + #[error("setsid failed: {0}")] + SetsidFailed(#[source] nix::Error), + + #[error("chdir failed: {0}")] + ChdirFailed(#[source] nix::Error), + + #[error("failed to open /dev/null: {0}")] + DevNullFailed(#[source] io::Error), + + #[error("failed to redirect stdio: {0}")] + RedirectFailed(#[source] nix::Error), + + #[error("PID file error: {0}")] + PidFile(String), + + #[error("another instance is already running (pid {0})")] + AlreadyRunning(i32), + + #[error("user '{0}' not found")] + UserNotFound(String), + + #[error("group '{0}' not found")] + GroupNotFound(String), + + #[error("failed to set uid/gid: {0}")] + PrivilegeDrop(#[source] nix::Error), + + #[error("io error: {0}")] + Io(#[from] io::Error), +} + +/// Result of a successful daemonize() call. +#[derive(Debug)] +pub enum DaemonizeResult { + /// We are the parent process and should exit. + Parent, + /// We are the daemon child process and should continue. + Child, +} + +/// Performs classic Unix double-fork daemonization. +/// +/// This detaches the process from the controlling terminal: +/// 1. First fork - parent exits, child continues +/// 2. setsid() - become session leader +/// 3. Second fork - ensure we can never acquire a controlling terminal +/// 4. chdir("/") - don't hold any directory open +/// 5. Redirect stdin/stdout/stderr to /dev/null +/// +/// Returns `DaemonizeResult::Parent` in the original parent (which should exit), +/// or `DaemonizeResult::Child` in the final daemon child. +pub fn daemonize(working_dir: Option<&Path>) -> Result { + // First fork + match unsafe { fork() } { + Ok(ForkResult::Parent { .. }) => { + // Parent exits + return Ok(DaemonizeResult::Parent); + } + Ok(ForkResult::Child) => { + // Child continues + } + Err(e) => return Err(DaemonError::ForkFailed(e)), + } + + // Create new session, become session leader + setsid().map_err(DaemonError::SetsidFailed)?; + + // Second fork to ensure we can never acquire a controlling terminal + match unsafe { fork() } { + Ok(ForkResult::Parent { .. }) => { + // Intermediate parent exits + std::process::exit(0); + } + Ok(ForkResult::Child) => { + // Final daemon child continues + } + Err(e) => return Err(DaemonError::ForkFailed(e)), + } + + // Change working directory + let target_dir = working_dir.unwrap_or(Path::new("/")); + chdir(target_dir).map_err(DaemonError::ChdirFailed)?; + + // Redirect stdin, stdout, stderr to /dev/null + redirect_stdio_to_devnull()?; + + Ok(DaemonizeResult::Child) +} + +/// Redirects stdin, stdout, and stderr to /dev/null. +fn redirect_stdio_to_devnull() -> Result<(), DaemonError> { + let devnull = File::options() + .read(true) + .write(true) + .open("/dev/null") + .map_err(DaemonError::DevNullFailed)?; + + let devnull_fd = std::os::unix::io::AsRawFd::as_raw_fd(&devnull); + + // Use libc::dup2 directly for redirecting standard file descriptors + // nix 0.31's dup2 requires OwnedFd which doesn't work well with stdio fds + unsafe { + // Redirect stdin (fd 0) + if libc::dup2(devnull_fd, 0) < 0 { + return Err(DaemonError::RedirectFailed(nix::errno::Errno::last())); + } + // Redirect stdout (fd 1) + if libc::dup2(devnull_fd, 1) < 0 { + return Err(DaemonError::RedirectFailed(nix::errno::Errno::last())); + } + // Redirect stderr (fd 2) + if libc::dup2(devnull_fd, 2) < 0 { + return Err(DaemonError::RedirectFailed(nix::errno::Errno::last())); + } + } + + // Close original devnull fd if it's not one of the standard fds + if devnull_fd > 2 { + let _ = close(devnull_fd); + } + + Ok(()) +} + +/// PID file manager with flock-based locking. +pub struct PidFile { + path: PathBuf, + file: Option, + locked: bool, +} + +impl PidFile { + /// Creates a new PID file manager for the given path. + pub fn new>(path: P) -> Self { + Self { + path: path.as_ref().to_path_buf(), + file: None, + locked: false, + } + } + + /// Checks if another instance is already running. + /// + /// Returns the PID of the running instance if one exists. + pub fn check_running(&self) -> Result, DaemonError> { + if !self.path.exists() { + return Ok(None); + } + + // Try to read existing PID + let mut contents = String::new(); + File::open(&self.path) + .and_then(|mut f| f.read_to_string(&mut contents)) + .map_err(|e| DaemonError::PidFile(format!("cannot read {}: {}", self.path.display(), e)))?; + + let pid: i32 = contents + .trim() + .parse() + .map_err(|_| DaemonError::PidFile(format!("invalid PID in {}", self.path.display())))?; + + // Check if process is still running + if is_process_running(pid) { + Ok(Some(pid)) + } else { + // Stale PID file + debug!(pid, path = %self.path.display(), "Removing stale PID file"); + let _ = fs::remove_file(&self.path); + Ok(None) + } + } + + /// Acquires the PID file lock and writes the current PID. + /// + /// Fails if another instance is already running. + pub fn acquire(&mut self) -> Result<(), DaemonError> { + // Check for running instance first + if let Some(pid) = self.check_running()? { + return Err(DaemonError::AlreadyRunning(pid)); + } + + // Ensure parent directory exists + if let Some(parent) = self.path.parent() { + if !parent.exists() { + fs::create_dir_all(parent).map_err(|e| { + DaemonError::PidFile(format!( + "cannot create directory {}: {}", + parent.display(), + e + )) + })?; + } + } + + // Open/create PID file with exclusive lock + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o644) + .open(&self.path) + .map_err(|e| { + DaemonError::PidFile(format!("cannot open {}: {}", self.path.display(), e)) + })?; + + // Try to acquire exclusive lock (non-blocking) + let flock = Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| { + // Check if another instance grabbed the lock + if let Some(pid) = self.check_running().ok().flatten() { + DaemonError::AlreadyRunning(pid) + } else { + DaemonError::PidFile(format!("cannot lock {}: {}", self.path.display(), errno)) + } + })?; + + // Write our PID + let pid = getpid(); + let mut file = flock.unlock().map_err(|(_, errno)| { + DaemonError::PidFile(format!("unlock failed: {}", errno)) + })?; + + writeln!(file, "{}", pid).map_err(|e| { + DaemonError::PidFile(format!("cannot write PID to {}: {}", self.path.display(), e)) + })?; + + // Re-acquire lock and keep it + let flock = Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| { + DaemonError::PidFile(format!("cannot re-lock {}: {}", self.path.display(), errno)) + })?; + + self.file = Some(flock.unlock().map_err(|(_, errno)| { + DaemonError::PidFile(format!("unlock for storage failed: {}", errno)) + })?); + self.locked = true; + + info!(pid = pid.as_raw(), path = %self.path.display(), "PID file created"); + Ok(()) + } + + /// Releases the PID file lock and removes the file. + pub fn release(&mut self) -> Result<(), DaemonError> { + if let Some(file) = self.file.take() { + drop(file); + } + self.locked = false; + + if self.path.exists() { + fs::remove_file(&self.path).map_err(|e| { + DaemonError::PidFile(format!("cannot remove {}: {}", self.path.display(), e)) + })?; + debug!(path = %self.path.display(), "PID file removed"); + } + + Ok(()) + } + + /// Returns the path to this PID file. + #[allow(dead_code)] + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for PidFile { + fn drop(&mut self) { + if self.locked { + if let Err(e) = self.release() { + warn!(error = %e, "Failed to clean up PID file on drop"); + } + } + } +} + +/// Checks if a process with the given PID is running. +fn is_process_running(pid: i32) -> bool { + // kill(pid, 0) checks if process exists without sending a signal + nix::sys::signal::kill(Pid::from_raw(pid), None).is_ok() +} + +/// Drops privileges to the specified user and group. +/// +/// This should be called after binding privileged ports but before +/// entering the main event loop. +pub fn drop_privileges(user: Option<&str>, group: Option<&str>) -> Result<(), DaemonError> { + // Look up group first (need to do this while still root) + let target_gid = if let Some(group_name) = group { + Some(lookup_group(group_name)?) + } else if let Some(user_name) = user { + // If no group specified but user is, use user's primary group + Some(lookup_user_primary_gid(user_name)?) + } else { + None + }; + + // Look up user + let target_uid = if let Some(user_name) = user { + Some(lookup_user(user_name)?) + } else { + None + }; + + // Drop privileges: set GID first, then UID + // (Setting UID first would prevent us from setting GID) + if let Some(gid) = target_gid { + unistd::setgid(gid).map_err(DaemonError::PrivilegeDrop)?; + // Also set supplementary groups to just this one + unistd::setgroups(&[gid]).map_err(DaemonError::PrivilegeDrop)?; + info!(gid = gid.as_raw(), "Dropped group privileges"); + } + + if let Some(uid) = target_uid { + unistd::setuid(uid).map_err(DaemonError::PrivilegeDrop)?; + info!(uid = uid.as_raw(), "Dropped user privileges"); + } + + Ok(()) +} + +/// Looks up a user by name and returns their UID. +fn lookup_user(name: &str) -> Result { + // Use libc getpwnam + let c_name = std::ffi::CString::new(name).map_err(|_| DaemonError::UserNotFound(name.to_string()))?; + + unsafe { + let pwd = libc::getpwnam(c_name.as_ptr()); + if pwd.is_null() { + Err(DaemonError::UserNotFound(name.to_string())) + } else { + Ok(Uid::from_raw((*pwd).pw_uid)) + } + } +} + +/// Looks up a user's primary GID by username. +fn lookup_user_primary_gid(name: &str) -> Result { + let c_name = std::ffi::CString::new(name).map_err(|_| DaemonError::UserNotFound(name.to_string()))?; + + unsafe { + let pwd = libc::getpwnam(c_name.as_ptr()); + if pwd.is_null() { + Err(DaemonError::UserNotFound(name.to_string())) + } else { + Ok(Gid::from_raw((*pwd).pw_gid)) + } + } +} + +/// Looks up a group by name and returns its GID. +fn lookup_group(name: &str) -> Result { + let c_name = std::ffi::CString::new(name).map_err(|_| DaemonError::GroupNotFound(name.to_string()))?; + + unsafe { + let grp = libc::getgrnam(c_name.as_ptr()); + if grp.is_null() { + Err(DaemonError::GroupNotFound(name.to_string())) + } else { + Ok(Gid::from_raw((*grp).gr_gid)) + } + } +} + +/// Reads PID from a PID file. +#[allow(dead_code)] +pub fn read_pid_file>(path: P) -> Result { + let path = path.as_ref(); + let mut contents = String::new(); + File::open(path) + .and_then(|mut f| f.read_to_string(&mut contents)) + .map_err(|e| DaemonError::PidFile(format!("cannot read {}: {}", path.display(), e)))?; + + contents + .trim() + .parse() + .map_err(|_| DaemonError::PidFile(format!("invalid PID in {}", path.display()))) +} + +/// Sends a signal to the process specified in a PID file. +#[allow(dead_code)] +pub fn signal_pid_file>( + path: P, + signal: nix::sys::signal::Signal, +) -> Result<(), DaemonError> { + let pid = read_pid_file(&path)?; + + if !is_process_running(pid) { + return Err(DaemonError::PidFile(format!( + "process {} from {} is not running", + pid, + path.as_ref().display() + ))); + } + + nix::sys::signal::kill(Pid::from_raw(pid), signal).map_err(|e| { + DaemonError::PidFile(format!("cannot signal process {}: {}", pid, e)) + })?; + + Ok(()) +} + +/// Returns the status of the daemon based on PID file. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DaemonStatus { + /// Daemon is running with the given PID. + Running(i32), + /// PID file exists but process is not running. + Stale(i32), + /// No PID file exists. + NotRunning, +} + +/// Checks the daemon status from a PID file. +#[allow(dead_code)] +pub fn check_status>(path: P) -> DaemonStatus { + let path = path.as_ref(); + + if !path.exists() { + return DaemonStatus::NotRunning; + } + + match read_pid_file(path) { + Ok(pid) => { + if is_process_running(pid) { + DaemonStatus::Running(pid) + } else { + DaemonStatus::Stale(pid) + } + } + Err(_) => DaemonStatus::NotRunning, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_daemon_options_default() { + let opts = DaemonOptions::default(); + assert!(!opts.daemonize); + assert!(!opts.should_daemonize()); + assert_eq!(opts.pid_file_path(), Path::new(DEFAULT_PID_FILE)); + } + + #[test] + fn test_daemon_options_foreground_overrides() { + let opts = DaemonOptions { + daemonize: true, + foreground: true, + ..Default::default() + }; + assert!(!opts.should_daemonize()); + } + + #[test] + fn test_check_status_not_running() { + let path = "/tmp/telemt_test_nonexistent.pid"; + assert_eq!(check_status(path), DaemonStatus::NotRunning); + } + + #[test] + fn test_pid_file_basic() { + let path = "/tmp/telemt_test_pidfile.pid"; + let _ = fs::remove_file(path); + + let mut pf = PidFile::new(path); + assert!(pf.check_running().unwrap().is_none()); + + pf.acquire().unwrap(); + assert!(Path::new(path).exists()); + + // Read it back + let pid = read_pid_file(path).unwrap(); + assert_eq!(pid, std::process::id() as i32); + + pf.release().unwrap(); + assert!(!Path::new(path).exists()); + } +} diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index 35f796f..5624d0e 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -25,7 +25,15 @@ pub(crate) fn resolve_runtime_config_path( absolute.canonicalize().unwrap_or(absolute) } -pub(crate) fn parse_cli() -> (String, Option, bool, Option) { +/// Parsed CLI arguments. +pub(crate) struct CliArgs { + pub config_path: String, + pub data_path: Option, + pub silent: bool, + pub log_level: Option, +} + +pub(crate) fn parse_cli() -> CliArgs { let mut config_path = "config.toml".to_string(); let mut data_path: Option = None; let mut silent = false; @@ -72,36 +80,35 @@ pub(crate) fn parse_cli() -> (String, Option, bool, Option) { log_level = Some(s.trim_start_matches("--log-level=").to_string()); } "--help" | "-h" => { - eprintln!("Usage: telemt [config.toml] [OPTIONS]"); - eprintln!(); - eprintln!("Options:"); - eprintln!( - " --data-path Set data directory (absolute path; overrides config value)" - ); - eprintln!(" --silent, -s Suppress info logs"); - eprintln!(" --log-level debug|verbose|normal|silent"); - eprintln!(" --help, -h Show this help"); - eprintln!(); - eprintln!("Setup (fire-and-forget):"); - eprintln!( - " --init Generate config, install systemd service, start" - ); - eprintln!(" --port Listen port (default: 443)"); - eprintln!( - " --domain TLS domain for masking (default: www.google.com)" - ); - eprintln!( - " --secret 32-char hex secret (auto-generated if omitted)" - ); - eprintln!(" --user Username (default: user)"); - eprintln!(" --config-dir Config directory (default: /etc/telemt)"); - eprintln!(" --no-start Don't start the service after install"); + print_help(); std::process::exit(0); } "--version" | "-V" => { println!("telemt {}", env!("CARGO_PKG_VERSION")); std::process::exit(0); } + // Skip daemon-related flags (already parsed) + "--daemon" | "-d" | "--foreground" | "-f" => {} + s if s.starts_with("--pid-file") => { + if !s.contains('=') { + i += 1; // skip value + } + } + s if s.starts_with("--run-as-user") => { + if !s.contains('=') { + i += 1; + } + } + s if s.starts_with("--run-as-group") => { + if !s.contains('=') { + i += 1; + } + } + s if s.starts_with("--working-dir") => { + if !s.contains('=') { + i += 1; + } + } s if !s.starts_with('-') => { config_path = s.to_string(); } @@ -112,7 +119,49 @@ pub(crate) fn parse_cli() -> (String, Option, bool, Option) { i += 1; } - (config_path, data_path, silent, log_level) + CliArgs { + config_path, + data_path, + silent, + log_level, + } +} + +fn print_help() { + eprintln!("Usage: telemt [config.toml] [OPTIONS]"); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --data-path Set data directory (absolute path; overrides config value)"); + eprintln!(" --silent, -s Suppress info logs"); + eprintln!(" --log-level debug|verbose|normal|silent"); + eprintln!(" --help, -h Show this help"); + eprintln!(" --version, -V Show version"); + eprintln!(); + #[cfg(unix)] + { + eprintln!("Daemon options (Unix only):"); + eprintln!(" --daemon, -d Fork to background (daemonize)"); + eprintln!(" --foreground, -f Explicit foreground mode (for systemd)"); + eprintln!(" --pid-file PID file path (default: /var/run/telemt.pid)"); + eprintln!(" --run-as-user Drop privileges to this user after binding"); + eprintln!(" --run-as-group Drop privileges to this group after binding"); + eprintln!(" --working-dir Working directory for daemon mode"); + eprintln!(); + } + eprintln!("Setup (fire-and-forget):"); + eprintln!( + " --init Generate config, install systemd service, start" + ); + eprintln!(" --port Listen port (default: 443)"); + eprintln!( + " --domain TLS domain for masking (default: www.google.com)" + ); + eprintln!( + " --secret 32-char hex secret (auto-generated if omitted)" + ); + eprintln!(" --user Username (default: user)"); + eprintln!(" --config-dir Config directory (default: /etc/telemt)"); + eprintln!(" --no-start Don't start the service after install"); } #[cfg(test)] diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index 7d3b168..d1959db 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -47,8 +47,56 @@ use crate::transport::UpstreamManager; use crate::transport::middle_proxy::MePool; use helpers::{parse_cli, resolve_runtime_config_path}; +#[cfg(unix)] +use crate::daemon::{DaemonOptions, PidFile, drop_privileges}; + /// Runs the full telemt runtime startup pipeline and blocks until shutdown. +/// +/// On Unix, daemon options should be handled before calling this function +/// (daemonization must happen before tokio runtime starts). +#[cfg(unix)] +pub async fn run_with_daemon( + daemon_opts: DaemonOptions, +) -> std::result::Result<(), Box> { + run_inner(daemon_opts).await +} + +/// Runs the full telemt runtime startup pipeline and blocks until shutdown. +/// +/// This is the main entry point for non-daemon mode or when called as a library. +#[allow(dead_code)] pub async fn run() -> std::result::Result<(), Box> { + #[cfg(unix)] + { + // Parse CLI to get daemon options even in simple run() path + let args: Vec = std::env::args().skip(1).collect(); + let daemon_opts = crate::cli::parse_daemon_args(&args); + run_inner(daemon_opts).await + } + #[cfg(not(unix))] + { + run_inner().await + } +} + +#[cfg(unix)] +async fn run_inner( + daemon_opts: DaemonOptions, +) -> std::result::Result<(), Box> { + + // Acquire PID file if daemonizing or if explicitly requested + // Keep it alive until shutdown (underscore prefix = intentionally kept for RAII cleanup) + let _pid_file = if daemon_opts.daemonize || daemon_opts.pid_file.is_some() { + let mut pf = PidFile::new(daemon_opts.pid_file_path()); + if let Err(e) = pf.acquire() { + eprintln!("[telemt] {}", e); + std::process::exit(1); + } + Some(pf) + } else { + None + }; + let process_started_at = Instant::now(); let process_started_at_epoch_secs = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -61,7 +109,11 @@ pub async fn run() -> std::result::Result<(), Box> { Some("load and validate config".to_string()), ) .await; - let (config_path_cli, data_path, cli_silent, cli_log_level) = parse_cli(); + let cli_args = parse_cli(); + let config_path_cli = cli_args.config_path; + let data_path = cli_args.data_path; + let cli_silent = cli_args.silent; + let cli_log_level = cli_args.log_level; let startup_cwd = match std::env::current_dir() { Ok(cwd) => cwd, Err(e) => { @@ -585,6 +637,17 @@ pub async fn run() -> std::result::Result<(), Box> { std::process::exit(1); } + // Drop privileges after binding sockets (which may require root for port < 1024) + if daemon_opts.user.is_some() || daemon_opts.group.is_some() { + if let Err(e) = drop_privileges( + daemon_opts.user.as_deref(), + daemon_opts.group.as_deref(), + ) { + error!(error = %e, "Failed to drop privileges"); + std::process::exit(1); + } + } + runtime_tasks::apply_runtime_log_filter( has_rust_log, &effective_log_level, diff --git a/src/main.rs b/src/main.rs index c512e6b..1f1b925 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,8 @@ mod api; mod cli; mod config; mod crypto; +#[cfg(unix)] +mod daemon; mod error; mod ip_tracker; #[cfg(test)] @@ -27,7 +29,42 @@ mod tls_front; mod transport; mod util; -#[tokio::main] -async fn main() -> std::result::Result<(), Box> { - maestro::run().await +fn main() -> std::result::Result<(), Box> { + // On Unix, handle daemonization before starting tokio runtime + #[cfg(unix)] + { + let args: Vec = std::env::args().skip(1).collect(); + let daemon_opts = cli::parse_daemon_args(&args); + + // Daemonize if requested (must happen before tokio runtime starts) + if daemon_opts.should_daemonize() { + match daemon::daemonize(daemon_opts.working_dir.as_deref()) { + Ok(daemon::DaemonizeResult::Parent) => { + // Parent process exits successfully + std::process::exit(0); + } + Ok(daemon::DaemonizeResult::Child) => { + // Continue as daemon child + } + Err(e) => { + eprintln!("[telemt] Daemonization failed: {}", e); + std::process::exit(1); + } + } + } + + // Now start tokio runtime and run the server + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(maestro::run_with_daemon(daemon_opts)) + } + + #[cfg(not(unix))] + { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(maestro::run()) + } } From 39875afbffd5dd6e6b715add8f7df69df8a4f8f8 Mon Sep 17 00:00:00 2001 From: Vladimir Krivopalov Date: Fri, 20 Mar 2026 20:39:50 +0200 Subject: [PATCH 2/5] Add comprehensive Unix signal handling for daemon mode Enhance signal handling to support proper daemon operation: - SIGTERM: Graceful shutdown (same behavior as SIGINT) - SIGQUIT: Graceful shutdown with full statistics dump - SIGUSR1: Log rotation acknowledgment for external tools - SIGUSR2: Dump runtime status to log without stopping Statistics dump includes connection counts, ME keepalive metrics, and relay adaptive tuning counters. SIGHUP config reload unchanged (handled in hot_reload.rs). Signals are handled via tokio::signal::unix with async select! to avoid blocking the runtime. Non-shutdown signals (USR1/USR2) run in a background task spawned at startup. Signed-off-by: Vladimir Krivopalov --- src/maestro/mod.rs | 5 +- src/maestro/shutdown.rs | 232 ++++++++++++++++++++++++++++++++++------ 2 files changed, 203 insertions(+), 34 deletions(-) diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index d1959db..b8eef7b 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -668,6 +668,9 @@ async fn run_inner( runtime_tasks::mark_runtime_ready(&startup_tracker).await; + // Spawn signal handlers for SIGUSR1/SIGUSR2 (non-shutdown signals) + shutdown::spawn_signal_handlers(stats.clone(), process_started_at); + listeners::spawn_tcp_accept_loops( listeners, config_rx.clone(), @@ -685,7 +688,7 @@ async fn run_inner( max_connections.clone(), ); - shutdown::wait_for_shutdown(process_started_at, me_pool).await; + shutdown::wait_for_shutdown(process_started_at, me_pool, stats).await; Ok(()) } diff --git a/src/maestro/shutdown.rs b/src/maestro/shutdown.rs index 243c772..cfdee24 100644 --- a/src/maestro/shutdown.rs +++ b/src/maestro/shutdown.rs @@ -1,45 +1,211 @@ +//! Shutdown and signal handling for telemt. +//! +//! Handles graceful shutdown on various signals: +//! - SIGINT (Ctrl+C) / SIGTERM: Graceful shutdown +//! - SIGQUIT: Graceful shutdown with stats dump +//! - SIGUSR1: Reserved for log rotation (logs acknowledgment) +//! - SIGUSR2: Dump runtime status to log +//! +//! SIGHUP is handled separately in config/hot_reload.rs for config reload. + use std::sync::Arc; use std::time::{Duration, Instant}; +#[cfg(unix)] +use tokio::signal::unix::{SignalKind, signal}; +#[cfg(not(unix))] use tokio::signal; -use tracing::{error, info, warn}; +use tracing::{info, warn}; +use crate::stats::Stats; use crate::transport::middle_proxy::MePool; use super::helpers::{format_uptime, unit_label}; -pub(crate) async fn wait_for_shutdown(process_started_at: Instant, me_pool: Option>) { - match signal::ctrl_c().await { - Ok(()) => { - let shutdown_started_at = Instant::now(); - info!("Shutting down..."); - let uptime_secs = process_started_at.elapsed().as_secs(); - info!("Uptime: {}", format_uptime(uptime_secs)); - if let Some(pool) = &me_pool { - match tokio::time::timeout( - Duration::from_secs(2), - pool.shutdown_send_close_conn_all(), - ) - .await - { - Ok(total) => { - info!( - close_conn_sent = total, - "ME shutdown: RPC_CLOSE_CONN broadcast completed" - ); - } - Err(_) => { - warn!("ME shutdown: RPC_CLOSE_CONN broadcast timed out"); - } - } - } - let shutdown_secs = shutdown_started_at.elapsed().as_secs(); - info!( - "Shutdown completed successfully in {} {}.", - shutdown_secs, - unit_label(shutdown_secs, "second", "seconds") - ); +/// Signal that triggered shutdown. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShutdownSignal { + /// SIGINT (Ctrl+C) + Interrupt, + /// SIGTERM + Terminate, + /// SIGQUIT (with stats dump) + Quit, +} + +impl std::fmt::Display for ShutdownSignal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ShutdownSignal::Interrupt => write!(f, "SIGINT"), + ShutdownSignal::Terminate => write!(f, "SIGTERM"), + ShutdownSignal::Quit => write!(f, "SIGQUIT"), } - Err(e) => error!("Signal error: {}", e), } } + +/// Waits for a shutdown signal and performs graceful shutdown. +pub(crate) async fn wait_for_shutdown( + process_started_at: Instant, + me_pool: Option>, + stats: Arc, +) { + let signal = wait_for_shutdown_signal().await; + perform_shutdown(signal, process_started_at, me_pool, &stats).await; +} + +/// Waits for any shutdown signal (SIGINT, SIGTERM, SIGQUIT). +#[cfg(unix)] +async fn wait_for_shutdown_signal() -> ShutdownSignal { + let mut sigint = signal(SignalKind::interrupt()).expect("Failed to register SIGINT handler"); + let mut sigterm = signal(SignalKind::terminate()).expect("Failed to register SIGTERM handler"); + let mut sigquit = signal(SignalKind::quit()).expect("Failed to register SIGQUIT handler"); + + tokio::select! { + _ = sigint.recv() => ShutdownSignal::Interrupt, + _ = sigterm.recv() => ShutdownSignal::Terminate, + _ = sigquit.recv() => ShutdownSignal::Quit, + } +} + +#[cfg(not(unix))] +async fn wait_for_shutdown_signal() -> ShutdownSignal { + signal::ctrl_c().await.expect("Failed to listen for Ctrl+C"); + ShutdownSignal::Interrupt +} + +/// Performs graceful shutdown sequence. +async fn perform_shutdown( + signal: ShutdownSignal, + process_started_at: Instant, + me_pool: Option>, + stats: &Stats, +) { + let shutdown_started_at = Instant::now(); + info!(signal = %signal, "Received shutdown signal"); + + // Dump stats if SIGQUIT + if signal == ShutdownSignal::Quit { + dump_stats(stats, process_started_at); + } + + info!("Shutting down..."); + let uptime_secs = process_started_at.elapsed().as_secs(); + info!("Uptime: {}", format_uptime(uptime_secs)); + + // Graceful ME pool shutdown + if let Some(pool) = &me_pool { + match tokio::time::timeout(Duration::from_secs(2), pool.shutdown_send_close_conn_all()).await + { + Ok(total) => { + info!( + close_conn_sent = total, + "ME shutdown: RPC_CLOSE_CONN broadcast completed" + ); + } + Err(_) => { + warn!("ME shutdown: RPC_CLOSE_CONN broadcast timed out"); + } + } + } + + let shutdown_secs = shutdown_started_at.elapsed().as_secs(); + info!( + "Shutdown completed successfully in {} {}.", + shutdown_secs, + unit_label(shutdown_secs, "second", "seconds") + ); +} + +/// Dumps runtime statistics to the log. +fn dump_stats(stats: &Stats, process_started_at: Instant) { + let uptime_secs = process_started_at.elapsed().as_secs(); + + info!("=== Runtime Statistics Dump ==="); + info!("Uptime: {}", format_uptime(uptime_secs)); + + // Connection stats + info!( + "Connections: total={}, current={} (direct={}, me={}), bad={}", + stats.get_connects_all(), + stats.get_current_connections_total(), + stats.get_current_connections_direct(), + stats.get_current_connections_me(), + stats.get_connects_bad(), + ); + + // ME pool stats + info!( + "ME keepalive: sent={}, pong={}, failed={}, timeout={}", + stats.get_me_keepalive_sent(), + stats.get_me_keepalive_pong(), + stats.get_me_keepalive_failed(), + stats.get_me_keepalive_timeout(), + ); + + // Relay stats + info!( + "Relay idle: soft_mark={}, hard_close={}, pressure_evict={}", + stats.get_relay_idle_soft_mark_total(), + stats.get_relay_idle_hard_close_total(), + stats.get_relay_pressure_evict_total(), + ); + + info!("=== End Statistics Dump ==="); +} + +/// Spawns a background task to handle operational signals (SIGUSR1, SIGUSR2). +/// +/// These signals don't trigger shutdown but perform specific actions: +/// - SIGUSR1: Log rotation acknowledgment (for external log rotation tools) +/// - SIGUSR2: Dump runtime status to log +#[cfg(unix)] +pub(crate) fn spawn_signal_handlers( + stats: Arc, + process_started_at: Instant, +) { + tokio::spawn(async move { + let mut sigusr1 = signal(SignalKind::user_defined1()) + .expect("Failed to register SIGUSR1 handler"); + let mut sigusr2 = signal(SignalKind::user_defined2()) + .expect("Failed to register SIGUSR2 handler"); + + loop { + tokio::select! { + _ = sigusr1.recv() => { + handle_sigusr1(); + } + _ = sigusr2.recv() => { + handle_sigusr2(&stats, process_started_at); + } + } + } + }); +} + +/// No-op on non-Unix platforms. +#[cfg(not(unix))] +pub(crate) fn spawn_signal_handlers( + _stats: Arc, + _process_started_at: Instant, +) { + // No SIGUSR1/SIGUSR2 on non-Unix +} + +/// Handles SIGUSR1 - log rotation signal. +/// +/// This signal is typically sent by logrotate or similar tools after +/// rotating log files. Since tracing-subscriber doesn't natively support +/// reopening files, we just acknowledge the signal. If file logging is +/// added in the future, this would reopen log file handles. +#[cfg(unix)] +fn handle_sigusr1() { + info!("SIGUSR1 received - log rotation acknowledged"); + // Future: If using file-based logging, reopen file handles here +} + +/// Handles SIGUSR2 - dump runtime status. +#[cfg(unix)] +fn handle_sigusr2(stats: &Stats, process_started_at: Instant) { + info!("SIGUSR2 received - dumping runtime status"); + dump_stats(stats, process_started_at); +} From dc2b4395bdcaad73a9a09d49ea647704c73b25f6 Mon Sep 17 00:00:00 2001 From: Vladimir Krivopalov Date: Fri, 20 Mar 2026 20:50:11 +0200 Subject: [PATCH 3/5] Add daemon lifecycle subcommands: start, stop, reload, status Implement CLI subcommands for managing telemt as a daemon: - `start [config.toml]` - Start as background daemon (implies --daemon) - `stop` - Stop running daemon by sending SIGTERM - `reload` - Reload configuration by sending SIGHUP - `status` - Check if daemon is running via PID file Subcommands use the PID file (default /var/run/telemt.pid) to locate the running daemon. Stop command waits up to 10 seconds for graceful shutdown. Status cleans up stale PID files automatically. Updated help text with subcommand documentation and usage examples. Exit codes follow Unix convention: 0 for success, 1 for not running or error. Signed-off-by: Vladimir Krivopalov --- src/cli.rs | 260 ++++++++++++++++++++++++++++++++++++++++- src/maestro/helpers.rs | 23 +++- src/main.rs | 11 +- 3 files changed, 289 insertions(+), 5 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 9b59015..bdfb629 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,11 @@ -//! CLI commands: --init (fire-and-forget setup), daemon options +//! CLI commands: --init (fire-and-forget setup), daemon options, subcommands +//! +//! Subcommands: +//! - `start [OPTIONS] [config.toml]` - Start the daemon +//! - `stop [--pid-file PATH]` - Stop a running daemon +//! - `reload [--pid-file PATH]` - Reload configuration (SIGHUP) +//! - `status [--pid-file PATH]` - Check daemon status +//! - `run [OPTIONS] [config.toml]` - Run in foreground (default behavior) use rand::RngExt; use std::fs; @@ -6,9 +13,258 @@ use std::path::{Path, PathBuf}; use std::process::Command; #[cfg(unix)] -use crate::daemon::DaemonOptions; +use crate::daemon::{self, DaemonOptions, DEFAULT_PID_FILE}; + +/// CLI subcommand to execute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Subcommand { + /// Run the proxy (default, or explicit `run` subcommand). + Run, + /// Start as daemon (`start` subcommand). + Start, + /// Stop a running daemon (`stop` subcommand). + Stop, + /// Reload configuration (`reload` subcommand). + Reload, + /// Check daemon status (`status` subcommand). + Status, + /// Fire-and-forget setup (`--init`). + Init, +} + +/// Parsed subcommand with its options. +#[derive(Debug)] +pub struct ParsedCommand { + pub subcommand: Subcommand, + pub pid_file: PathBuf, + pub config_path: String, + #[cfg(unix)] + pub daemon_opts: DaemonOptions, + pub init_opts: Option, +} + +impl Default for ParsedCommand { + fn default() -> Self { + Self { + subcommand: Subcommand::Run, + #[cfg(unix)] + pid_file: PathBuf::from(DEFAULT_PID_FILE), + #[cfg(not(unix))] + pid_file: PathBuf::from("/var/run/telemt.pid"), + config_path: "config.toml".to_string(), + #[cfg(unix)] + daemon_opts: DaemonOptions::default(), + init_opts: None, + } + } +} + +/// Parse CLI arguments into a command structure. +pub fn parse_command(args: &[String]) -> ParsedCommand { + let mut cmd = ParsedCommand::default(); + + // Check for --init first (legacy form) + if args.iter().any(|a| a == "--init") { + cmd.subcommand = Subcommand::Init; + cmd.init_opts = parse_init_args(args); + return cmd; + } + + // Check for subcommand as first argument + if let Some(first) = args.first() { + match first.as_str() { + "start" => { + cmd.subcommand = Subcommand::Start; + #[cfg(unix)] + { + cmd.daemon_opts = parse_daemon_args(args); + // Force daemonize for start command + cmd.daemon_opts.daemonize = true; + } + } + "stop" => { + cmd.subcommand = Subcommand::Stop; + } + "reload" => { + cmd.subcommand = Subcommand::Reload; + } + "status" => { + cmd.subcommand = Subcommand::Status; + } + "run" => { + cmd.subcommand = Subcommand::Run; + #[cfg(unix)] + { + cmd.daemon_opts = parse_daemon_args(args); + } + } + _ => { + // No subcommand, default to Run + #[cfg(unix)] + { + cmd.daemon_opts = parse_daemon_args(args); + } + } + } + } + + // Parse remaining options + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + // Skip subcommand names + "start" | "stop" | "reload" | "status" | "run" => {} + // PID file option (for stop/reload/status) + "--pid-file" => { + i += 1; + if i < args.len() { + cmd.pid_file = PathBuf::from(&args[i]); + #[cfg(unix)] + { + cmd.daemon_opts.pid_file = Some(cmd.pid_file.clone()); + } + } + } + s if s.starts_with("--pid-file=") => { + cmd.pid_file = PathBuf::from(s.trim_start_matches("--pid-file=")); + #[cfg(unix)] + { + cmd.daemon_opts.pid_file = Some(cmd.pid_file.clone()); + } + } + // Config path (positional, non-flag argument) + s if !s.starts_with('-') => { + cmd.config_path = s.to_string(); + } + _ => {} + } + i += 1; + } + + cmd +} + +/// Execute a subcommand that doesn't require starting the server. +/// Returns `Some(exit_code)` if the command was handled, `None` if server should start. +#[cfg(unix)] +pub fn execute_subcommand(cmd: &ParsedCommand) -> Option { + match cmd.subcommand { + Subcommand::Stop => Some(cmd_stop(&cmd.pid_file)), + Subcommand::Reload => Some(cmd_reload(&cmd.pid_file)), + Subcommand::Status => Some(cmd_status(&cmd.pid_file)), + Subcommand::Init => { + if let Some(opts) = cmd.init_opts.clone() { + match run_init(opts) { + Ok(()) => Some(0), + Err(e) => { + eprintln!("[telemt] Init failed: {}", e); + Some(1) + } + } + } else { + Some(1) + } + } + // Run and Start need the server + Subcommand::Run | Subcommand::Start => None, + } +} + +#[cfg(not(unix))] +pub fn execute_subcommand(cmd: &ParsedCommand) -> Option { + match cmd.subcommand { + Subcommand::Stop | Subcommand::Reload | Subcommand::Status => { + eprintln!("[telemt] Subcommand not supported on this platform"); + Some(1) + } + Subcommand::Init => { + if let Some(opts) = cmd.init_opts.clone() { + match run_init(opts) { + Ok(()) => Some(0), + Err(e) => { + eprintln!("[telemt] Init failed: {}", e); + Some(1) + } + } + } else { + Some(1) + } + } + Subcommand::Run | Subcommand::Start => None, + } +} + +/// Stop command: send SIGTERM to the running daemon. +#[cfg(unix)] +fn cmd_stop(pid_file: &Path) -> i32 { + use nix::sys::signal::Signal; + + println!("Stopping telemt daemon..."); + + match daemon::signal_pid_file(pid_file, Signal::SIGTERM) { + Ok(()) => { + println!("Stop signal sent successfully"); + + // Wait for process to exit (up to 10 seconds) + for _ in 0..20 { + std::thread::sleep(std::time::Duration::from_millis(500)); + if let daemon::DaemonStatus::NotRunning = daemon::check_status(pid_file) { + println!("Daemon stopped"); + return 0; + } + } + println!("Daemon may still be shutting down"); + 0 + } + Err(e) => { + eprintln!("Failed to stop daemon: {}", e); + 1 + } + } +} + +/// Reload command: send SIGHUP to trigger config reload. +#[cfg(unix)] +fn cmd_reload(pid_file: &Path) -> i32 { + use nix::sys::signal::Signal; + + println!("Reloading telemt configuration..."); + + match daemon::signal_pid_file(pid_file, Signal::SIGHUP) { + Ok(()) => { + println!("Reload signal sent successfully"); + 0 + } + Err(e) => { + eprintln!("Failed to reload daemon: {}", e); + 1 + } + } +} + +/// Status command: check if daemon is running. +#[cfg(unix)] +fn cmd_status(pid_file: &Path) -> i32 { + match daemon::check_status(pid_file) { + daemon::DaemonStatus::Running(pid) => { + println!("telemt is running (pid {})", pid); + 0 + } + daemon::DaemonStatus::Stale(pid) => { + println!("telemt is not running (stale pid file, was pid {})", pid); + // Clean up stale PID file + let _ = std::fs::remove_file(pid_file); + 1 + } + daemon::DaemonStatus::NotRunning => { + println!("telemt is not running"); + 1 + } + } +} /// Options for the init command +#[derive(Debug, Clone)] pub struct InitOptions { pub port: u16, pub domain: String, diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index 5624d0e..8caed2d 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -128,7 +128,17 @@ pub(crate) fn parse_cli() -> CliArgs { } fn print_help() { - eprintln!("Usage: telemt [config.toml] [OPTIONS]"); + eprintln!("Usage: telemt [COMMAND] [OPTIONS] [config.toml]"); + eprintln!(); + eprintln!("Commands:"); + eprintln!(" run Run in foreground (default if no command given)"); + #[cfg(unix)] + { + eprintln!(" start Start as background daemon"); + eprintln!(" stop Stop a running daemon"); + eprintln!(" reload Reload configuration (send SIGHUP)"); + eprintln!(" status Check if daemon is running"); + } eprintln!(); eprintln!("Options:"); eprintln!(" --data-path Set data directory (absolute path; overrides config value)"); @@ -162,6 +172,17 @@ fn print_help() { eprintln!(" --user Username (default: user)"); eprintln!(" --config-dir Config directory (default: /etc/telemt)"); eprintln!(" --no-start Don't start the service after install"); + #[cfg(unix)] + { + eprintln!(); + eprintln!("Examples:"); + eprintln!(" telemt config.toml Run in foreground"); + eprintln!(" telemt start config.toml Start as daemon"); + eprintln!(" telemt start --pid-file /tmp/t.pid Start with custom PID file"); + eprintln!(" telemt stop Stop daemon"); + eprintln!(" telemt reload Reload configuration"); + eprintln!(" telemt status Check daemon status"); + } } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index 1f1b925..6ff3491 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,11 +30,18 @@ mod transport; mod util; fn main() -> std::result::Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + let cmd = cli::parse_command(&args); + + // Handle subcommands that don't need the server (stop, reload, status, init) + if let Some(exit_code) = cli::execute_subcommand(&cmd) { + std::process::exit(exit_code); + } + // On Unix, handle daemonization before starting tokio runtime #[cfg(unix)] { - let args: Vec = std::env::args().skip(1).collect(); - let daemon_opts = cli::parse_daemon_args(&args); + let daemon_opts = cmd.daemon_opts; // Daemonize if requested (must happen before tokio runtime starts) if daemon_opts.should_daemonize() { From 909714af318c45edb3b6714edc7428a364e8a553 Mon Sep 17 00:00:00 2001 From: Vladimir Krivopalov Date: Fri, 20 Mar 2026 22:13:21 +0200 Subject: [PATCH 4/5] Add multi-platform service manager integration Implement automatic init system detection and service file generation for systemd, OpenRC (Alpine/Gentoo), and FreeBSD rc.d: - Add src/service module with init system detection and generators - Auto-detect init system via filesystem probes - Generate platform-appropriate service files during --init systemd enhancements: - ExecReload for SIGHUP config reload - PIDFile directive - Comprehensive security hardening (ProtectKernelTunables, RestrictAddressFamilies, MemoryDenyWriteExecute, etc.) - CAP_NET_BIND_SERVICE for privileged ports OpenRC support: - Standard openrc-run script with depend/reload functions - Directory setup in start_pre FreeBSD rc.d support: - rc.subr integration with rc.conf variables - reload extra command The --init command now detects the init system and runs the appropriate enable/start commands (systemctl, rc-update, sysrc). Signed-off-by: Vladimir Krivopalov --- src/cli.rs | 169 +++++++++++--------- src/main.rs | 1 + src/service/mod.rs | 374 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 475 insertions(+), 69 deletions(-) create mode 100644 src/service/mod.rs diff --git a/src/cli.rs b/src/cli.rs index bdfb629..bc38909 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -401,10 +401,16 @@ pub fn parse_init_args(args: &[String]) -> Option { /// Run the fire-and-forget setup. pub fn run_init(opts: InitOptions) -> Result<(), Box> { + use crate::service::{self, InitSystem, ServiceOptions}; + eprintln!("[telemt] Fire-and-forget setup"); eprintln!(); - // 1. Generate or validate secret + // 1. Detect init system + let init_system = service::detect_init_system(); + eprintln!("[+] Detected init system: {}", init_system); + + // 2. Generate or validate secret let secret = match opts.secret { Some(s) => { if s.len() != 32 || !s.chars().all(|c| c.is_ascii_hexdigit()) { @@ -421,72 +427,126 @@ pub fn run_init(opts: InitOptions) -> Result<(), Box> { eprintln!("[+] Port: {}", opts.port); eprintln!("[+] Domain: {}", opts.domain); - // 2. Create config directory + // 3. Create config directory fs::create_dir_all(&opts.config_dir)?; let config_path = opts.config_dir.join("config.toml"); - // 3. Write config + // 4. Write config let config_content = generate_config(&opts.username, &secret, opts.port, &opts.domain); fs::write(&config_path, &config_content)?; eprintln!("[+] Config written to {}", config_path.display()); - // 4. Write systemd unit - let exe_path = - std::env::current_exe().unwrap_or_else(|_| PathBuf::from("/usr/local/bin/telemt")); + // 5. Generate and write service file + let exe_path = std::env::current_exe() + .unwrap_or_else(|_| PathBuf::from("/usr/local/bin/telemt")); - let unit_path = Path::new("/etc/systemd/system/telemt.service"); - let unit_content = generate_systemd_unit(&exe_path, &config_path); + let service_opts = ServiceOptions { + exe_path: &exe_path, + config_path: &config_path, + user: None, // Let systemd/init handle user + group: None, + pid_file: "/var/run/telemt.pid", + working_dir: Some("/var/lib/telemt"), + description: "Telemt MTProxy - Telegram MTProto Proxy", + }; - match fs::write(unit_path, &unit_content) { + let service_path = service::service_file_path(init_system); + let service_content = service::generate_service_file(init_system, &service_opts); + + // Ensure parent directory exists + if let Some(parent) = Path::new(service_path).parent() { + let _ = fs::create_dir_all(parent); + } + + match fs::write(service_path, &service_content) { Ok(()) => { - eprintln!("[+] Systemd unit written to {}", unit_path.display()); + eprintln!("[+] Service file written to {}", service_path); + + // Make script executable for OpenRC/FreeBSD + #[cfg(unix)] + if init_system == InitSystem::OpenRC || init_system == InitSystem::FreeBSDRc { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(service_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(service_path, perms)?; + } } Err(e) => { - eprintln!("[!] Cannot write systemd unit (run as root?): {}", e); - eprintln!("[!] Manual unit file content:"); - eprintln!("{}", unit_content); + eprintln!("[!] Cannot write service file (run as root?): {}", e); + eprintln!("[!] Manual service file content:"); + eprintln!("{}", service_content); - // Still print links and config + // Still print links and installation instructions + eprintln!(); + eprintln!("{}", service::installation_instructions(init_system)); print_links(&opts.username, &secret, opts.port, &opts.domain); return Ok(()); } } - // 5. Reload systemd - run_cmd("systemctl", &["daemon-reload"]); + // 6. Install and enable service based on init system + match init_system { + InitSystem::Systemd => { + run_cmd("systemctl", &["daemon-reload"]); + run_cmd("systemctl", &["enable", "telemt.service"]); + eprintln!("[+] Service enabled"); - // 6. Enable service - run_cmd("systemctl", &["enable", "telemt.service"]); - eprintln!("[+] Service enabled"); + if !opts.no_start { + run_cmd("systemctl", &["start", "telemt.service"]); + eprintln!("[+] Service started"); - // 7. Start service (unless --no-start) - if !opts.no_start { - run_cmd("systemctl", &["start", "telemt.service"]); - eprintln!("[+] Service started"); + std::thread::sleep(std::time::Duration::from_secs(1)); + let status = Command::new("systemctl") + .args(["is-active", "telemt.service"]) + .output(); - // Brief delay then check status - std::thread::sleep(std::time::Duration::from_secs(1)); - let status = Command::new("systemctl") - .args(["is-active", "telemt.service"]) - .output(); - - match status { - Ok(out) if out.status.success() => { - eprintln!("[+] Service is running"); - } - _ => { - eprintln!("[!] Service may not have started correctly"); - eprintln!("[!] Check: journalctl -u telemt.service -n 20"); + match status { + Ok(out) if out.status.success() => { + eprintln!("[+] Service is running"); + } + _ => { + eprintln!("[!] Service may not have started correctly"); + eprintln!("[!] Check: journalctl -u telemt.service -n 20"); + } + } + } else { + eprintln!("[+] Service not started (--no-start)"); + eprintln!("[+] Start manually: systemctl start telemt.service"); } } - } else { - eprintln!("[+] Service not started (--no-start)"); - eprintln!("[+] Start manually: systemctl start telemt.service"); + InitSystem::OpenRC => { + run_cmd("rc-update", &["add", "telemt", "default"]); + eprintln!("[+] Service enabled"); + + if !opts.no_start { + run_cmd("rc-service", &["telemt", "start"]); + eprintln!("[+] Service started"); + } else { + eprintln!("[+] Service not started (--no-start)"); + eprintln!("[+] Start manually: rc-service telemt start"); + } + } + InitSystem::FreeBSDRc => { + run_cmd("sysrc", &["telemt_enable=YES"]); + eprintln!("[+] Service enabled"); + + if !opts.no_start { + run_cmd("service", &["telemt", "start"]); + eprintln!("[+] Service started"); + } else { + eprintln!("[+] Service not started (--no-start)"); + eprintln!("[+] Start manually: service telemt start"); + } + } + InitSystem::Unknown => { + eprintln!("[!] Unknown init system - service file written but not installed"); + eprintln!("[!] You may need to install it manually"); + } } eprintln!(); - // 8. Print links + // 7. Print links print_links(&opts.username, &secret, opts.port, &opts.domain); Ok(()) @@ -581,35 +641,6 @@ weight = 10 ) } -fn generate_systemd_unit(exe_path: &Path, config_path: &Path) -> String { - format!( - r#"[Unit] -Description=Telemt MTProxy -Documentation=https://github.com/telemt/telemt -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -ExecStart={exe} {config} -Restart=always -RestartSec=5 -LimitNOFILE=65535 -# Security hardening -NoNewPrivileges=true -ProtectSystem=strict -ProtectHome=true -ReadWritePaths=/etc/telemt -PrivateTmp=true - -[Install] -WantedBy=multi-user.target -"#, - exe = exe_path.display(), - config = config_path.display(), - ) -} - fn run_cmd(cmd: &str, args: &[&str]) { match Command::new(cmd).args(args).output() { Ok(output) => { diff --git a/src/main.rs b/src/main.rs index 6ff3491..0e872c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod crypto; mod daemon; mod error; mod ip_tracker; +mod service; #[cfg(test)] #[path = "tests/ip_tracker_hotpath_adversarial_tests.rs"] mod ip_tracker_hotpath_adversarial_tests; diff --git a/src/service/mod.rs b/src/service/mod.rs new file mode 100644 index 0000000..c0a6f83 --- /dev/null +++ b/src/service/mod.rs @@ -0,0 +1,374 @@ +//! Service manager integration for telemt. +//! +//! Supports generating service files for: +//! - systemd (Linux) +//! - OpenRC (Alpine, Gentoo) +//! - rc.d (FreeBSD) + +use std::path::Path; + +/// Detected init/service system. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InitSystem { + /// systemd (most modern Linux distributions) + Systemd, + /// OpenRC (Alpine, Gentoo, some BSDs) + OpenRC, + /// FreeBSD rc.d + FreeBSDRc, + /// No known init system detected + Unknown, +} + +impl std::fmt::Display for InitSystem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InitSystem::Systemd => write!(f, "systemd"), + InitSystem::OpenRC => write!(f, "OpenRC"), + InitSystem::FreeBSDRc => write!(f, "FreeBSD rc.d"), + InitSystem::Unknown => write!(f, "unknown"), + } + } +} + +/// Detects the init system in use on the current host. +pub fn detect_init_system() -> InitSystem { + // Check for systemd first (most common on Linux) + if Path::new("/run/systemd/system").exists() { + return InitSystem::Systemd; + } + + // Check for OpenRC + if Path::new("/sbin/openrc-run").exists() || Path::new("/sbin/openrc").exists() { + return InitSystem::OpenRC; + } + + // Check for FreeBSD rc.d + if Path::new("/etc/rc.subr").exists() && Path::new("/etc/rc.d").exists() { + return InitSystem::FreeBSDRc; + } + + // Fallback: check if systemctl exists even without /run/systemd + if Path::new("/usr/bin/systemctl").exists() || Path::new("/bin/systemctl").exists() { + return InitSystem::Systemd; + } + + InitSystem::Unknown +} + +/// Returns the default service file path for the given init system. +pub fn service_file_path(init_system: InitSystem) -> &'static str { + match init_system { + InitSystem::Systemd => "/etc/systemd/system/telemt.service", + InitSystem::OpenRC => "/etc/init.d/telemt", + InitSystem::FreeBSDRc => "/usr/local/etc/rc.d/telemt", + InitSystem::Unknown => "/etc/init.d/telemt", + } +} + +/// Options for generating service files. +pub struct ServiceOptions<'a> { + /// Path to the telemt executable + pub exe_path: &'a Path, + /// Path to the configuration file + pub config_path: &'a Path, + /// User to run as (optional) + pub user: Option<&'a str>, + /// Group to run as (optional) + pub group: Option<&'a str>, + /// PID file path + pub pid_file: &'a str, + /// Working directory + pub working_dir: Option<&'a str>, + /// Description + pub description: &'a str, +} + +impl<'a> Default for ServiceOptions<'a> { + fn default() -> Self { + Self { + exe_path: Path::new("/usr/local/bin/telemt"), + config_path: Path::new("/etc/telemt/config.toml"), + user: Some("telemt"), + group: Some("telemt"), + pid_file: "/var/run/telemt.pid", + working_dir: Some("/var/lib/telemt"), + description: "Telemt MTProxy - Telegram MTProto Proxy", + } + } +} + +/// Generates a service file for the given init system. +pub fn generate_service_file(init_system: InitSystem, opts: &ServiceOptions) -> String { + match init_system { + InitSystem::Systemd => generate_systemd_unit(opts), + InitSystem::OpenRC => generate_openrc_script(opts), + InitSystem::FreeBSDRc => generate_freebsd_rc_script(opts), + InitSystem::Unknown => generate_systemd_unit(opts), // Default to systemd format + } +} + +/// Generates an enhanced systemd unit file. +fn generate_systemd_unit(opts: &ServiceOptions) -> String { + let user_line = opts.user.map(|u| format!("User={}", u)).unwrap_or_default(); + let group_line = opts.group.map(|g| format!("Group={}", g)).unwrap_or_default(); + let working_dir = opts.working_dir.map(|d| format!("WorkingDirectory={}", d)).unwrap_or_default(); + + format!( +r#"[Unit] +Description={description} +Documentation=https://github.com/telemt/telemt +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart={exe} --foreground --pid-file {pid_file} {config} +ExecReload=/bin/kill -HUP $MAINPID +PIDFile={pid_file} +Restart=always +RestartSec=5 +{user} +{group} +{working_dir} + +# Resource limits +LimitNOFILE=65535 +LimitNPROC=4096 + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +MemoryDenyWriteExecute=true +LockPersonality=true + +# Allow binding to privileged ports and writing to specific paths +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +ReadWritePaths=/etc/telemt /var/run /var/lib/telemt + +[Install] +WantedBy=multi-user.target +"#, + description = opts.description, + exe = opts.exe_path.display(), + config = opts.config_path.display(), + pid_file = opts.pid_file, + user = user_line, + group = group_line, + working_dir = working_dir, + ) +} + +/// Generates an OpenRC init script. +fn generate_openrc_script(opts: &ServiceOptions) -> String { + let user = opts.user.unwrap_or("root"); + let group = opts.group.unwrap_or("root"); + + format!( +r#"#!/sbin/openrc-run +# OpenRC init script for telemt + +description="{description}" +command="{exe}" +command_args="--daemon --pid-file {pid_file} {config}" +command_user="{user}:{group}" +pidfile="{pid_file}" + +depend() {{ + need net + after firewall +}} + +start_pre() {{ + checkpath --directory --owner {user}:{group} --mode 0755 /var/run + checkpath --directory --owner {user}:{group} --mode 0755 /var/lib/telemt +}} + +reload() {{ + ebegin "Reloading ${{RC_SVCNAME}}" + start-stop-daemon --signal HUP --pidfile "${{pidfile}}" + eend $? +}} +"#, + description = opts.description, + exe = opts.exe_path.display(), + config = opts.config_path.display(), + pid_file = opts.pid_file, + user = user, + group = group, + ) +} + +/// Generates a FreeBSD rc.d script. +fn generate_freebsd_rc_script(opts: &ServiceOptions) -> String { + let user = opts.user.unwrap_or("root"); + let group = opts.group.unwrap_or("wheel"); + + format!( +r#"#!/bin/sh +# +# PROVIDE: telemt +# REQUIRE: LOGIN NETWORKING +# KEYWORD: shutdown +# +# Add the following lines to /etc/rc.conf to enable telemt: +# +# telemt_enable="YES" +# telemt_config="/etc/telemt/config.toml" # optional +# telemt_user="telemt" # optional +# telemt_group="telemt" # optional +# + +. /etc/rc.subr + +name="telemt" +rcvar="telemt_enable" +desc="{description}" + +load_rc_config $name + +: ${{telemt_enable:="NO"}} +: ${{telemt_config:="{config}"}} +: ${{telemt_user:="{user}"}} +: ${{telemt_group:="{group}"}} +: ${{telemt_pidfile:="{pid_file}"}} + +pidfile="${{telemt_pidfile}}" +command="{exe}" +command_args="--daemon --pid-file ${{telemt_pidfile}} ${{telemt_config}}" + +start_precmd="telemt_prestart" +reload_cmd="telemt_reload" +extra_commands="reload" + +telemt_prestart() {{ + install -d -o ${{telemt_user}} -g ${{telemt_group}} -m 755 /var/run + install -d -o ${{telemt_user}} -g ${{telemt_group}} -m 755 /var/lib/telemt +}} + +telemt_reload() {{ + if [ -f "${{pidfile}}" ]; then + echo "Reloading ${{name}} configuration." + kill -HUP $(cat ${{pidfile}}) + else + echo "${{name}} is not running." + return 1 + fi +}} + +run_rc_command "$1" +"#, + description = opts.description, + exe = opts.exe_path.display(), + config = opts.config_path.display(), + pid_file = opts.pid_file, + user = user, + group = group, + ) +} + +/// Installation instructions for each init system. +pub fn installation_instructions(init_system: InitSystem) -> &'static str { + match init_system { + InitSystem::Systemd => { +r#"To install and enable the service: + sudo systemctl daemon-reload + sudo systemctl enable telemt + sudo systemctl start telemt + +To check status: + sudo systemctl status telemt + +To view logs: + journalctl -u telemt -f + +To reload configuration: + sudo systemctl reload telemt +"# + } + InitSystem::OpenRC => { +r#"To install and enable the service: + sudo chmod +x /etc/init.d/telemt + sudo rc-update add telemt default + sudo rc-service telemt start + +To check status: + sudo rc-service telemt status + +To reload configuration: + sudo rc-service telemt reload +"# + } + InitSystem::FreeBSDRc => { +r#"To install and enable the service: + sudo chmod +x /usr/local/etc/rc.d/telemt + sudo sysrc telemt_enable="YES" + sudo service telemt start + +To check status: + sudo service telemt status + +To reload configuration: + sudo service telemt reload +"# + } + InitSystem::Unknown => { +r#"No supported init system detected. +You may need to create a service file manually or run telemt directly: + telemt start /etc/telemt/config.toml +"# + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_systemd_unit_generation() { + let opts = ServiceOptions::default(); + let unit = generate_systemd_unit(&opts); + assert!(unit.contains("[Unit]")); + assert!(unit.contains("[Service]")); + assert!(unit.contains("[Install]")); + assert!(unit.contains("ExecReload=")); + assert!(unit.contains("PIDFile=")); + } + + #[test] + fn test_openrc_script_generation() { + let opts = ServiceOptions::default(); + let script = generate_openrc_script(&opts); + assert!(script.contains("#!/sbin/openrc-run")); + assert!(script.contains("depend()")); + assert!(script.contains("reload()")); + } + + #[test] + fn test_freebsd_rc_script_generation() { + let opts = ServiceOptions::default(); + let script = generate_freebsd_rc_script(&opts); + assert!(script.contains("#!/bin/sh")); + assert!(script.contains("PROVIDE: telemt")); + assert!(script.contains("run_rc_command")); + } + + #[test] + fn test_service_file_paths() { + assert_eq!(service_file_path(InitSystem::Systemd), "/etc/systemd/system/telemt.service"); + assert_eq!(service_file_path(InitSystem::OpenRC), "/etc/init.d/telemt"); + assert_eq!(service_file_path(InitSystem::FreeBSDRc), "/usr/local/etc/rc.d/telemt"); + } +} From 95685adba72d18cd26973c788f8101825491f54f Mon Sep 17 00:00:00 2001 From: Vladimir Krivopalov Date: Fri, 20 Mar 2026 22:26:42 +0200 Subject: [PATCH 5/5] Add multi-destination logging: syslog and file support Implement logging infrastructure for non-systemd platforms: - Add src/logging.rs with syslog and file logging support - New CLI flags: --syslog, --log-file, --log-file-daily - Syslog uses libc directly with LOG_DAEMON facility - File logging via tracing-appender with optional daily rotation Update service scripts: - OpenRC and FreeBSD rc.d now use --syslog by default - Ensures logs are captured on platforms without journald Default (stderr) behavior unchanged for systemd compatibility. Log destination is selected at startup based on CLI flags. Signed-off-by: Vladimir Krivopalov --- Cargo.lock | 13 ++ Cargo.toml | 1 + src/logging.rs | 291 +++++++++++++++++++++++++++++++++++++++++ src/maestro/helpers.rs | 12 ++ src/maestro/mod.rs | 47 +++++-- src/main.rs | 1 + src/service/mod.rs | 6 +- 7 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 src/logging.rs diff --git a/Cargo.lock b/Cargo.lock index 8159a22..ef6a7c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2822,6 +2822,7 @@ dependencies = [ "tokio-util", "toml", "tracing", + "tracing-appender", "tracing-subscriber", "url", "webpki-roots", @@ -3148,6 +3149,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/Cargo.toml b/Cargo.toml index 04228a9..a5f1fec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ bytes = "1.9" thiserror = "2.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" parking_lot = "0.12" dashmap = "6.1" arc-swap = "1.7" diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..f372798 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,291 @@ +//! Logging configuration for telemt. +//! +//! Supports multiple log destinations: +//! - stderr (default, works with systemd journald) +//! - syslog (Unix only, for traditional init systems) +//! - file (with optional rotation) + +#![allow(dead_code)] // Infrastructure module - used via CLI flags + +use std::path::Path; + +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, fmt, reload}; + +/// Log destination configuration. +#[derive(Debug, Clone, Default)] +pub enum LogDestination { + /// Log to stderr (default, captured by systemd journald). + #[default] + Stderr, + /// Log to syslog (Unix only). + #[cfg(unix)] + Syslog, + /// Log to a file with optional rotation. + File { + path: String, + /// Rotate daily if true. + rotate_daily: bool, + }, +} + +/// Logging options parsed from CLI/config. +#[derive(Debug, Clone, Default)] +pub struct LoggingOptions { + /// Where to send logs. + pub destination: LogDestination, + /// Disable ANSI colors. + pub disable_colors: bool, +} + +/// Guard that must be held to keep file logging active. +/// When dropped, flushes and closes log files. +pub struct LoggingGuard { + _guard: Option, +} + +impl LoggingGuard { + fn new(guard: Option) -> Self { + Self { _guard: guard } + } + + /// Creates a no-op guard for stderr/syslog logging. + pub fn noop() -> Self { + Self { _guard: None } + } +} + +/// Initialize the tracing subscriber with the specified options. +/// +/// Returns a reload handle for dynamic log level changes and a guard +/// that must be kept alive for file logging. +pub fn init_logging( + opts: &LoggingOptions, + initial_filter: &str, +) -> (reload::Handle, LoggingGuard) { + let (filter_layer, filter_handle) = reload::Layer::new(EnvFilter::new(initial_filter)); + + match &opts.destination { + LogDestination::Stderr => { + let fmt_layer = fmt::Layer::default() + .with_ansi(!opts.disable_colors) + .with_target(true); + + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); + + (filter_handle, LoggingGuard::noop()) + } + + #[cfg(unix)] + LogDestination::Syslog => { + // Use a custom fmt layer that writes to syslog + let fmt_layer = fmt::Layer::default() + .with_ansi(false) + .with_target(true) + .with_writer(SyslogWriter::new); + + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); + + (filter_handle, LoggingGuard::noop()) + } + + LogDestination::File { path, rotate_daily } => { + let (non_blocking, guard) = if *rotate_daily { + // Extract directory and filename prefix + let path = Path::new(path); + let dir = path.parent().unwrap_or(Path::new("/var/log")); + let prefix = path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or("telemt"); + + let file_appender = tracing_appender::rolling::daily(dir, prefix); + tracing_appender::non_blocking(file_appender) + } else { + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("Failed to open log file"); + tracing_appender::non_blocking(file) + }; + + let fmt_layer = fmt::Layer::default() + .with_ansi(false) + .with_target(true) + .with_writer(non_blocking); + + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); + + (filter_handle, LoggingGuard::new(Some(guard))) + } + } +} + +/// Syslog writer for tracing. +#[cfg(unix)] +struct SyslogWriter { + _private: (), +} + +#[cfg(unix)] +impl SyslogWriter { + fn new() -> Self { + // Open syslog connection on first use + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + unsafe { + // Open syslog with ident "telemt", LOG_PID, LOG_DAEMON facility + let ident = b"telemt\0".as_ptr() as *const libc::c_char; + libc::openlog(ident, libc::LOG_PID | libc::LOG_NDELAY, libc::LOG_DAEMON); + } + }); + Self { _private: () } + } +} + +#[cfg(unix)] +impl std::io::Write for SyslogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // Convert to C string, stripping newlines + let msg = String::from_utf8_lossy(buf); + let msg = msg.trim_end(); + + if msg.is_empty() { + return Ok(buf.len()); + } + + // Determine priority based on log level in the message + let priority = if msg.contains(" ERROR ") || msg.contains(" error ") { + libc::LOG_ERR + } else if msg.contains(" WARN ") || msg.contains(" warn ") { + libc::LOG_WARNING + } else if msg.contains(" INFO ") || msg.contains(" info ") { + libc::LOG_INFO + } else if msg.contains(" DEBUG ") || msg.contains(" debug ") { + libc::LOG_DEBUG + } else { + libc::LOG_INFO + }; + + // Write to syslog + let c_msg = std::ffi::CString::new(msg.as_bytes()) + .unwrap_or_else(|_| std::ffi::CString::new("(invalid utf8)").unwrap()); + + unsafe { + libc::syslog(priority, b"%s\0".as_ptr() as *const libc::c_char, c_msg.as_ptr()); + } + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[cfg(unix)] +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SyslogWriter { + type Writer = SyslogWriter; + + fn make_writer(&'a self) -> Self::Writer { + SyslogWriter::new() + } +} + +/// Parse log destination from CLI arguments. +pub fn parse_log_destination(args: &[String]) -> LogDestination { + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + #[cfg(unix)] + "--syslog" => { + return LogDestination::Syslog; + } + "--log-file" => { + i += 1; + if i < args.len() { + return LogDestination::File { + path: args[i].clone(), + rotate_daily: false, + }; + } + } + s if s.starts_with("--log-file=") => { + return LogDestination::File { + path: s.trim_start_matches("--log-file=").to_string(), + rotate_daily: false, + }; + } + "--log-file-daily" => { + i += 1; + if i < args.len() { + return LogDestination::File { + path: args[i].clone(), + rotate_daily: true, + }; + } + } + s if s.starts_with("--log-file-daily=") => { + return LogDestination::File { + path: s.trim_start_matches("--log-file-daily=").to_string(), + rotate_daily: true, + }; + } + _ => {} + } + i += 1; + } + LogDestination::Stderr +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_log_destination_default() { + let args: Vec = vec![]; + assert!(matches!(parse_log_destination(&args), LogDestination::Stderr)); + } + + #[test] + fn test_parse_log_destination_file() { + let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()]; + match parse_log_destination(&args) { + LogDestination::File { path, rotate_daily } => { + assert_eq!(path, "/var/log/telemt.log"); + assert!(!rotate_daily); + } + _ => panic!("Expected File destination"), + } + } + + #[test] + fn test_parse_log_destination_file_daily() { + let args = vec!["--log-file-daily=/var/log/telemt".to_string()]; + match parse_log_destination(&args) { + LogDestination::File { path, rotate_daily } => { + assert_eq!(path, "/var/log/telemt"); + assert!(rotate_daily); + } + _ => panic!("Expected File destination"), + } + } + + #[cfg(unix)] + #[test] + fn test_parse_log_destination_syslog() { + let args = vec!["--syslog".to_string()]; + assert!(matches!(parse_log_destination(&args), LogDestination::Syslog)); + } +} diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index 8caed2d..c376d0b 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -8,6 +8,7 @@ use tracing::{debug, error, info, warn}; use crate::cli; use crate::config::ProxyConfig; +use crate::logging::LogDestination; use crate::transport::middle_proxy::{ ProxyConfigData, fetch_proxy_config_with_raw, load_proxy_config_cache, save_proxy_config_cache, }; @@ -31,6 +32,7 @@ pub(crate) struct CliArgs { pub data_path: Option, pub silent: bool, pub log_level: Option, + pub log_destination: LogDestination, } pub(crate) fn parse_cli() -> CliArgs { @@ -41,6 +43,9 @@ pub(crate) fn parse_cli() -> CliArgs { let args: Vec = std::env::args().skip(1).collect(); + // Parse log destination + let log_destination = crate::logging::parse_log_destination(&args); + // Check for --init first (handled before tokio) if let Some(init_opts) = cli::parse_init_args(&args) { if let Err(e) = cli::run_init(init_opts) { @@ -124,6 +129,7 @@ pub(crate) fn parse_cli() -> CliArgs { data_path, silent, log_level, + log_destination, } } @@ -147,6 +153,12 @@ fn print_help() { eprintln!(" --help, -h Show this help"); eprintln!(" --version, -V Show version"); eprintln!(); + eprintln!("Logging options:"); + eprintln!(" --log-file Log to file (default: stderr)"); + eprintln!(" --log-file-daily Log to file with daily rotation"); + #[cfg(unix)] + eprintln!(" --syslog Log to syslog (Unix only)"); + eprintln!(); #[cfg(unix)] { eprintln!("Daemon options (Unix only):"); diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index b8eef7b..ecd7f6d 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -114,6 +114,7 @@ async fn run_inner( let data_path = cli_args.data_path; let cli_silent = cli_args.silent; let cli_log_level = cli_args.log_level; + let log_destination = cli_args.log_destination; let startup_cwd = match std::env::current_dir() { Ok(cwd) => cwd, Err(e) => { @@ -213,17 +214,43 @@ async fn run_inner( ) .await; - // Configure color output based on config - let fmt_layer = if config.general.disable_colors { - fmt::Layer::default().with_ansi(false) - } else { - fmt::Layer::default().with_ansi(true) - }; + // Initialize logging based on destination + let _logging_guard: Option; + match log_destination { + crate::logging::LogDestination::Stderr => { + // Default: log to stderr (works with systemd journald) + let fmt_layer = if config.general.disable_colors { + fmt::Layer::default().with_ansi(false) + } else { + fmt::Layer::default().with_ansi(true) + }; + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); + _logging_guard = None; + } + #[cfg(unix)] + crate::logging::LogDestination::Syslog => { + // Syslog: for OpenRC/FreeBSD + let logging_opts = crate::logging::LoggingOptions { + destination: log_destination, + disable_colors: true, + }; + let (_, guard) = crate::logging::init_logging(&logging_opts, "info"); + _logging_guard = Some(guard); + } + crate::logging::LogDestination::File { .. } => { + // File logging with optional rotation + let logging_opts = crate::logging::LoggingOptions { + destination: log_destination, + disable_colors: true, + }; + let (_, guard) = crate::logging::init_logging(&logging_opts, "info"); + _logging_guard = Some(guard); + } + } - tracing_subscriber::registry() - .with(filter_layer) - .with(fmt_layer) - .init(); startup_tracker .complete_component( COMPONENT_TRACING_INIT, diff --git a/src/main.rs b/src/main.rs index 0e872c5..26a10f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod crypto; mod daemon; mod error; mod ip_tracker; +mod logging; mod service; #[cfg(test)] #[path = "tests/ip_tracker_hotpath_adversarial_tests.rs"] diff --git a/src/service/mod.rs b/src/service/mod.rs index c0a6f83..160c36c 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -181,18 +181,20 @@ r#"#!/sbin/openrc-run description="{description}" command="{exe}" -command_args="--daemon --pid-file {pid_file} {config}" +command_args="--daemon --syslog --pid-file {pid_file} {config}" command_user="{user}:{group}" pidfile="{pid_file}" depend() {{ need net + use logger after firewall }} start_pre() {{ checkpath --directory --owner {user}:{group} --mode 0755 /var/run checkpath --directory --owner {user}:{group} --mode 0755 /var/lib/telemt + checkpath --directory --owner {user}:{group} --mode 0755 /var/log/telemt }} reload() {{ @@ -246,7 +248,7 @@ load_rc_config $name pidfile="${{telemt_pidfile}}" command="{exe}" -command_args="--daemon --pid-file ${{telemt_pidfile}} ${{telemt_config}}" +command_args="--daemon --syslog --pid-file ${{telemt_pidfile}} ${{telemt_config}}" start_precmd="telemt_prestart" reload_cmd="telemt_reload"