diff --git a/src/cli/init.rs b/src/cli/init.rs index 41e0953..0df4df3 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -97,6 +97,9 @@ pub fn run_init(opts: InitOptions) -> Result<(), Box> { let init_system = service::detect_init_system(); eprintln!("[+] Detected init system: {}", init_system); + let service_path = service::service_file_path(init_system); + // A port-managed rc script and its configuration must survive --init. + ensure_service_install_allowed(init_system, Path::new(service_path))?; let secret = match opts.secret { Some(s) => { @@ -124,7 +127,7 @@ pub fn run_init(opts: InitOptions) -> Result<(), Box> { let service_opts = ServiceOptions { exe_path: &exe_path, config_path: &config_path, - // Let the selected init system manage process identity. + // Preserve root startup unless an administrator configures a service identity. user: None, group: None, pid_file: "/var/run/telemt.pid", @@ -132,7 +135,6 @@ pub fn run_init(opts: InitOptions) -> Result<(), Box> { description: "Telemt MTProxy - Telegram MTProto Proxy", }; - let service_path = service::service_file_path(init_system); let service_content = service::generate_service_file(init_system, &service_opts); let service_mode = if init_system == InitSystem::OpenRC || init_system == InitSystem::FreeBSDRc { @@ -218,6 +220,26 @@ pub fn run_init(opts: InitOptions) -> Result<(), Box> { Ok(()) } +fn ensure_service_install_allowed( + init_system: crate::service::InitSystem, + path: &Path, +) -> std::io::Result<()> { + if init_system != crate::service::InitSystem::FreeBSDRc { + return Ok(()); + } + match std::fs::symlink_metadata(path) { + Ok(_) => Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!( + "refusing to replace existing FreeBSD service {}; manage it through its package or edit it explicitly", + path.display() + ), + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + fn write_init_file(path: &Path, contents: &str, mode: u32) -> std::io::Result<()> { #[cfg(unix)] { @@ -362,3 +384,34 @@ fn print_links(username: &str, secret: &str, port: u16, domain: &str) { println!("Check: journalctl -u telemt.service | head -30"); println!("==================="); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::InitSystem; + + #[test] + fn freebsd_init_preserves_existing_service_files() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("telemt"); + ensure_service_install_allowed(InitSystem::FreeBSDRc, &path).unwrap(); + std::fs::write(&path, b"package-managed service").unwrap(); + let error = ensure_service_install_allowed(InitSystem::FreeBSDRc, &path).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&path).unwrap(), b"package-managed service"); + ensure_service_install_allowed(InitSystem::Systemd, &path).unwrap(); + ensure_service_install_allowed(InitSystem::OpenRC, &path).unwrap(); + } + + #[cfg(unix)] + #[test] + fn freebsd_init_rejects_dangling_service_symlinks() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("telemt"); + let target = dir.path().join("missing"); + std::os::unix::fs::symlink(&target, &path).unwrap(); + let error = ensure_service_install_allowed(InitSystem::FreeBSDRc, &path).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_link(&path).unwrap(), target); + } +} diff --git a/src/service/freebsd_tests.rs b/src/service/freebsd_tests.rs new file mode 100644 index 0000000..312c222 --- /dev/null +++ b/src/service/freebsd_tests.rs @@ -0,0 +1,188 @@ +use super::*; +use std::io::Write; +use std::process::{Command, Stdio}; + +// Replace rc.subr and privileged commands, but execute the generated shell itself. +fn run_script(opts: &ServiceOptions, config: &str, fail_install: bool) -> std::process::Output { + let harness = format!( + r#" +load_rc_config() {{ + : +{config} +}} +id() {{ + printf '%s_primary\n' "$2" +}} +install() {{ + printf 'install' + printf ' <%s>' "$@" + printf '\n' + {install_result} +}} +run_rc_command() {{ + printf 'rc-user=<%s> rc-group=<%s>\n' "${{telemt_user-unset}}" "${{telemt_group-unset}}" + "$start_precmd" || return $? + printf 'pid=<%s>\n' "$pidfile" + eval "set -- $command_args" + printf 'arg=<%s>\n' "$@" +}} +"#, + install_result = if fail_install { + "return 19" + } else { + "return 0" + }, + ); + let script = generate_freebsd_rc_script(opts).replace(". /etc/rc.subr", &harness); + let mut child = Command::new("/bin/sh") + .arg("-s") + .env_clear() + .env("PATH", "/usr/bin:/bin") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(script.as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +#[test] +fn freebsd_default_owns_only_service_directories_and_drops_privileges_internally() { + let output = run_script(&ServiceOptions::default(), "", false); + assert!(output.status.success(), "{:?}", output); + let text = String::from_utf8(output.stdout).unwrap(); + let installs: Vec<_> = text + .lines() + .filter(|line| line.starts_with("install ")) + .collect(); + assert_eq!( + installs, + [ + "install <-d> <-o> <-g> <-m> <755> ", + "install <-d> <-o> <-g> <-m> <755> ", + ] + ); + assert!(text.contains("rc-user= rc-group=")); + assert!(text.contains("pid=")); + assert!(text.contains("arg=<--run-as-user>\narg=")); + assert!(text.contains("arg=<--run-as-group>\narg=")); + assert!(text.contains("arg=<--working-dir>\narg=")); +} + +#[test] +fn freebsd_legacy_identity_settings_use_internal_privilege_drop() { + let output = run_script( + &ServiceOptions::default(), + "telemt_user=legacy\ntelemt_group=legacygroup", + false, + ); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("rc-user= rc-group=")); + assert!(text.contains("arg=<--run-as-user>\narg=")); + assert!(text.contains("arg=<--run-as-group>\narg=")); +} + +#[test] +fn freebsd_new_identity_settings_override_legacy_aliases() { + let output = run_script( + &ServiceOptions::default(), + "telemt_user=legacy\ntelemt_group=legacygroup\ntelemt_uid=newuser\ntelemt_gid=newgroup", + false, + ); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("arg=<--run-as-user>\narg=")); + assert!(text.contains("arg=<--run-as-group>\narg=")); + assert!(!text.contains("legacy")); +} + +#[test] +fn freebsd_root_start_preserves_privileged_network_initialization() { + let opts = ServiceOptions { + user: None, + group: None, + ..Default::default() + }; + let output = run_script(&opts, "", false); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(!text.contains("arg=<--run-as-")); + assert!(text.contains("rc-user= rc-group=")); +} + +#[test] +fn freebsd_user_override_without_group_uses_the_accounts_primary_group() { + let opts = ServiceOptions { + user: None, + group: None, + ..Default::default() + }; + for config in ["telemt_user=legacy", "telemt_uid=legacy"] { + let output = run_script(&opts, config, false); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("arg=<--run-as-group>\narg=")); + assert!(text.contains("<-g> ")); + assert!(!text.contains("wheel")); + } +} + +#[test] +fn freebsd_custom_paths_are_preserved_without_chowning_the_pid_parent() { + let opts = ServiceOptions { + pid_file: "/custom run/proxy.pid", + working_dir: Some("/custom data/telemt"), + config_path: Path::new("/custom config/telemt.toml"), + ..Default::default() + }; + let output = run_script(&opts, "", false); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("pid=")); + assert!(text.contains("arg=")); + assert!(text.contains("arg=")); + assert!(text.contains("arg=")); + assert!( + !text + .lines() + .any(|line| line.starts_with("install ") && line.ends_with("")) + ); +} + +#[test] +fn freebsd_rc_conf_pid_override_does_not_change_shared_directory_ownership() { + let output = run_script( + &ServiceOptions::default(), + "telemt_pidfile=/var/run/custom.pid", + false, + ); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("pid=")); + assert!( + !text + .lines() + .any(|line| line.starts_with("install ") && line.ends_with("")) + ); +} + +#[test] +fn freebsd_directory_setup_failure_aborts_startup() { + let output = run_script(&ServiceOptions::default(), "", true); + assert_eq!(output.status.code(), Some(19)); + let text = String::from_utf8(output.stdout).unwrap(); + assert_eq!( + text.lines() + .filter(|line| line.starts_with("install ")) + .count(), + 1 + ); + assert!(!text.contains("arg=")); +} diff --git a/src/service/mod.rs b/src/service/mod.rs index a31758d..a2c353b 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -222,7 +222,16 @@ reload() {{ /// 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"); + let group = opts.group.unwrap_or(""); + // The shared PID default cannot support cleanup after dropping privileges. + let pid_file = match opts.pid_file { + "/var/run/telemt.pid" => "/var/run/telemt/telemt.pid", + path => path, + }; + let working_dir = match opts.working_dir { + None | Some("/var/lib/telemt") => "/var/db/telemt", + Some(path) => path, + }; format!( r#"#!/bin/sh @@ -237,8 +246,10 @@ fn generate_freebsd_rc_script(opts: &ServiceOptions) -> String { # The configuration path can be overridden in rc.conf. # telemt_config="/etc/telemt/config.toml" # The service user and group can be overridden in rc.conf. -# telemt_user="telemt" -# telemt_group="telemt" +# telemt_uid="telemt" +# telemt_gid="telemt" +# Legacy telemt_user/telemt_group settings remain supported as aliases. +# Custom telemt_pidfile parents must already be writable by the service user. # . /etc/rc.subr @@ -251,27 +262,41 @@ load_rc_config $name : ${{telemt_enable:="NO"}} : ${{telemt_config:="{config}"}} -: ${{telemt_user:="{user}"}} -: ${{telemt_group:="{group}"}} +: ${{telemt_uid:="${{telemt_user:-{user}}}"}} +: ${{telemt_gid:="${{telemt_group:-{group}}}"}} : ${{telemt_pidfile:="{pid_file}"}} +: ${{telemt_data_dir:="{working_dir}"}} + +# An unspecified group follows the selected account, never an implicit wheel grant. +if [ -z "${{telemt_gid}}" ]; then + telemt_gid="$(id -gn "${{telemt_uid}}")" || exit 1 +fi + +# rc.subr must not drop privileges before Telemt binds privileged sockets. +unset telemt_user telemt_group pidfile="${{telemt_pidfile}}" command="{exe}" -command_args="--daemon --syslog --pid-file ${{telemt_pidfile}} ${{telemt_config}}" +command_args="--daemon --syslog --working-dir \"${{telemt_data_dir}}\" --pid-file \"${{telemt_pidfile}}\"" +# Root startup must retain privileged network setup without a drop request. +if [ "${{telemt_uid}}" != "root" ]; then + command_args="${{command_args}} --run-as-user \"${{telemt_uid}}\" --run-as-group \"${{telemt_gid}}\"" +fi +command_args="${{command_args}} \"${{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 + install -d -o "${{telemt_uid}}" -g "${{telemt_gid}}" -m 755 /var/run/telemt || return $? + install -d -o "${{telemt_uid}}" -g "${{telemt_gid}}" -m 755 "${{telemt_data_dir}}" || return $? }} telemt_reload() {{ if [ -f "${{pidfile}}" ]; then echo "Reloading ${{name}} configuration." - kill -HUP $(cat ${{pidfile}}) + kill -HUP "$(cat "${{pidfile}}")" else echo "${{name}} is not running." return 1 @@ -283,7 +308,8 @@ run_rc_command "$1" description = opts.description, exe = opts.exe_path.display(), config = opts.config_path.display(), - pid_file = opts.pid_file, + pid_file = pid_file, + working_dir = working_dir, user = user, group = group, ) @@ -343,6 +369,10 @@ You may need to create a service file manually or run telemt directly: } } +#[cfg(all(test, unix))] +// Executable shell checks for the generated FreeBSD service lifecycle. +mod freebsd_tests; + #[cfg(test)] mod tests { use super::*;