From be2b0104fd35cc642af0fde58c166f53a8e41a70 Mon Sep 17 00:00:00 2001 From: Vladimir Krivopalov Date: Fri, 20 Mar 2026 20:31:47 +0200 Subject: [PATCH] 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 | 2 +- src/cli.rs | 63 ++++- src/daemon/mod.rs | 523 +++++++++++++++++++++++++++++++++++++++++ src/maestro/helpers.rs | 99 ++++++-- src/maestro/mod.rs | 65 ++++- src/main.rs | 43 +++- 6 files changed, 765 insertions(+), 30 deletions(-) create mode 100644 src/daemon/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 97855f3..b6781c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ zeroize = { version = "1.8", features = ["derive"] } # Network socket2 = { version = "0.5", features = ["all"] } -nix = { version = "0.28", default-features = false, features = ["net"] } +nix = { version = "0.28", default-features = false, features = ["net", "user", "process", "fs", "signal"] } shadowsocks = { version = "1.24", features = ["aead-cipher-2022"] } # Serialization diff --git a/src/cli.rs b/src/cli.rs index 87dcfb5..f7b3d9a 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 std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use rand::Rng; +#[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..4fd8f7a --- /dev/null +++ b/src/daemon/mod.rs @@ -0,0 +1,523 @@ +//! 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, dup2, 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); + + // Redirect stdin (fd 0) + dup2(devnull_fd, 0).map_err(DaemonError::RedirectFailed)?; + // Redirect stdout (fd 1) + dup2(devnull_fd, 1).map_err(DaemonError::RedirectFailed)?; + // Redirect stderr (fd 2) + dup2(devnull_fd, 2).map_err(DaemonError::RedirectFailed)?; + + // 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 f43e308..0804672 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -10,7 +10,15 @@ use crate::transport::middle_proxy::{ ProxyConfigData, fetch_proxy_config_with_raw, load_proxy_config_cache, save_proxy_config_cache, }; -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; @@ -55,34 +63,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(); } @@ -93,7 +102,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"); } pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) { diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index dce421c..3f6e470 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -47,8 +47,56 @@ use crate::transport::middle_proxy::MePool; use crate::transport::UpstreamManager; use helpers::parse_cli; +#[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) @@ -58,7 +106,11 @@ pub async fn run() -> std::result::Result<(), Box> { startup_tracker .start_component(COMPONENT_CONFIG_LOAD, Some("load and validate config".to_string())) .await; - let (config_path, data_path, cli_silent, cli_log_level) = parse_cli(); + let cli_args = parse_cli(); + let config_path = 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 mut config = match ProxyConfig::load(&config_path) { Ok(c) => c, @@ -555,6 +607,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 2cfbe28..97cf519 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)] @@ -20,7 +22,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()) + } }